From ae6f40460dcd56af6c5f60ba52c883854c3bac55 Mon Sep 17 00:00:00 2001
From: wangzhibo <wangzhibo@shsening.com>
Date: 星期日, 16 八月 2026 15:30:04 +0800
Subject: [PATCH] first commit
---
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java | 284
app/src/main/res/xml/devicesettings_device_name.xml | 9
app/src/main/res/xml/devicesettings_fossilhybridhr_calibration.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eFWInstallHandler.java | 49
app/src/main/assets/ic_device_sony_800n_disabled.svg | 268
app/src/main/res/anim/flyright.xml | 8
app/src/main/res/drawable/ic_activity_obstacle_race.xml | 17
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBEnvironment.java | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/bandwpseries/BandWPSeriesDeviceSupport.java | 315
app/src/main/res/xml/devicesettings_vitality_score.xml | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/deviceinfo/DeviceInfoProfile.java | 186
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SetDeviceSettingsMessage.java | 53
app/src/main/res/drawable/gadgetbridge_img.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7SCoordinator.java | 18
src/mainline/fastlane/metadata/android/en-US/changelogs/175.txt | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/liberty3_pro/SoundcoreLiberty3ProCoordinator.java | 83
app/src/main/res/drawable/ic_activity_cricket.xml | 16
app/src/main/res/drawable/ic_access_alarms.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardActiveTimeWidget.java | 74
app/src/main/res/mipmap-mdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9CalibrationActivity.java | 123
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moondrop/MoondropSpaceTravelCoordinator.java | 82
app/src/main/res/xml/devicesettings_header_sound_vibration.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/SetCurrentTimeServiceRequest.java | 45
app/src/main/assets/logback.xml | 19
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/file/FileEncryptedGetRequest.java | 244
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordHeader.java | 87
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivityHeartrate.java | 47
app/src/main/java/lineageos/weatherservice/ServiceRequestResult.java | 197
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FeaturesCommand.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/StopFindPhoneRequest.java | 40
app/src/main/res/drawable/ic_timer.xml | 3
app/src/main/res/drawable/ic_nothing_ear_l.xml | 11
app/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java | 338
renovate.json | 46
app/src/main/res/layout/activity_configure_reminders.xml | 27
src/mainline/fastlane/metadata/android/en-US/changelogs/105.txt | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/jyou/y5/Y5Support.java | 234
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/RedmiBuds5ProCoordinator.java | 103
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java | 212
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiHealthService.java | 952
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/v1/params/BatteryType.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit3/HuaweiWatchFit3Coordinator.java | 57
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProCoordinator.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/id115/SendNotificationOperation.java | 209
app/src/main/res/drawable/ic_activity_watertubing.xml | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_85.java | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/CzechTransliterator.java | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHandler.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetPpgDataCommand.java | 135
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5FWInstallHandler.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetTimeZoneIdRequest.java | 55
app/src/main/java/org/bouncycastle/shaded/crypto/InvalidCipherTextException.java | 40
src/mainline/fastlane/metadata/android/de-DE/images/icon.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleMisfitActivitySample.java | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java | 143
app/src/main/res/xml/devicesettings_automatic_power_off_when_taken_off.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/EstonianTransliterator.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetSleepTimeCommand.java | 73
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/FindMyPhoneRequestMessage.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSSupport.java | 345
app/src/main/res/drawable/ic_activity_rowing.xml | 10
app/src/main/res/drawable/ic_device_miwatch_disabled.xml | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ItemWithDetailsAdapter.java | 55
app/src/main/res/drawable/ic_battery_full.xml | 9
src/mainline/fastlane/metadata/android/en-US/changelogs/184.txt | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband5/MiBand5Support.java | 73
app/src/main/res/drawable/ic_galaxy_buds_r.xml | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCameraRemote.java | 57
app/src/main/res/drawable/ic_calendar_to.xml | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java | 164
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/ServerTransaction.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesIoThread.java | 75
src/mainline/fastlane/metadata/android/en-US/changelogs/166.txt | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/EqualizerPreset.java | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitTimestampCorrelation.java | 32
app/src/main/res/values-vi/strings.xml | 403
app/src/main/res/drawable/ic_device_banglejs_disabled.xml | 35
app/src/main/res/xml/devicesettings_device_internet_access.xml | 10
app/src/main/res/xml/devicesettings_root_calls_and_notifications.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/GetCurrentStepCountRequest.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestBodyEnergySampleProvider.java | 71
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java | 91
app/src/main/res/drawable/ic_pageview.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/UnhandledMessage.java | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DaySleepChartFragment.java | 616
app/src/main/res/drawable/ic_checklist.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProFWHelper.java | 39
app/src/main/res/drawable/ic_history.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FindDeviceCommand.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/StepsPacketParser.java | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitStressLevel.java | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java | 441
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/SonyHeadphonesProtocol.java | 361
src/mainline/fastlane/metadata/android/en-US/changelogs/202.txt | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendGpsAndTimeToDeviceRequest.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendRunPaceConfigRequest.java | 48
app/src/main/res/xml/devicesettings_force_connection_type.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXCoordinator.java | 151
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/tlw64/TLW64Support.java | 598
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridConfigActivity.java | 671
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/AuthenticationHandler.java | 128
app/src/main/res/drawable/ic_circle.xml | 10
app/src/main/res/drawable/ic_phone_outline.xml | 9
app/src/main/res/drawable/ic_font_download.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeFloat.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/zetime/ZeTimeDeviceSupport.java | 2251
src/mainline/fastlane/metadata/android/cs-CZ/short_description.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateCollectionFragment.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileLookupAndGetRequest.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/control/ControlPointWithValue.java | 37
app/src/main/java/net/osmand/aidlapi/search/SearchResult.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiCalendarService.java | 147
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/TestCalls.java | 68
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6FWInstallHandler.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBodyEnergyWidget.java | 191
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSConstants.java | 137
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/liberty/SoundcoreLibertyIOThread.java | 57
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsSteelHRDeviceCoordinator.java | 212
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/GattCallback.java | 116
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepSessionSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AudioUpsampling.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/deviceevents/FileDownloadedDeviceEvent.java | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/UriHelper.java | 198
app/src/main/res/drawable/ic_device_supercars.xml | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeCoordinator.java | 209
app/src/main/res/layout/item_app_specific_notification_app_list.xml | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/flipper/zero/FlipperZeroCoordinator.java | 150
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/ActivityPacketParser.java | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/LanguageUtils.java | 149
app/src/main/res/drawable/ic_u_turn_right.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionHrZoneHighBoundary.java | 10
app/src/main/res/xml/devicesettings_fake_ring_duration.xml | 9
app/src/main/res/xml/devicesettings_root_activity_info.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSActivityPoint.java | 241
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummary.java | 51
app/src/main/res/layout/fragment_charts.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSport.java | 153
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendFileUploadChunk.java | 52
app/src/nopebble/res/mipmap-xxxhdpi/ic_launcher_foreground.png | 0
app/src/main/java/lineageos/weather/util/WeatherUtils.java | 84
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/EventStreamRequest.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sonyswr12/SonySWR12DeviceCoordinator.java | 150
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/LiveWorkoutHandler.java | 147
app/src/main/res/layout/item_with_details.xml | 67
app/src/main/res/drawable/ic_surround.xml | 10
app/src/main/res/xml/devicesettings_amazfitbipu.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiPaiSampleProvider.java | 56
app/src/main/res/xml/devicesettings_sony_headphones_button_modes_left_right.xml | 87
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/GoalTrackingSetRequest.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileCloseRequest.java | 98
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractTransaction.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/message/Response.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendWatchfaceConfirm.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendWeatherStartRequest.java | 69
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/MediaManager.java | 183
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/Ear1Coordinator.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxWidgetInterface.java | 8
app/src/main/res/drawable/ic_device_default_disabled.xml | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleKitSupport.java | 201
app/src/main/assets/fossil_hr/widgetBattery_preview.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/WideAreaTap.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiActivitySummaryParser.java | 391
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/Mi1SFirmwareInfoFW2.java | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandler.java | 91
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbipu/AmazfitBipUSupport.java | 58
app/src/main/res/xml/about_user.xml | 132
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/NonSwipeableViewPager.java | 53
app/src/main/res/drawable/ic_list_numbered.xml | 10
app/src/main/res/xml/devicesettings_ftp_server.xml | 55
app/src/main/res/drawable/ic_lock_open.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/authentication/ConfirmOnDeviceRequest.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupMetadata.java | 60
app/src/main/res/drawable/ic_battery_charging_80.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115SampleProvider.java | 77
app/src/main/aidl/net/osmand/aidlapi/navigation/ANavigationUpdateParams.aidl | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiP2PManager.java | 71
app/src/main/assets/ic_launcher_neon.svg | 751
src/mainline/fastlane/metadata/android/de-DE/full_description.txt | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHrvValueSample.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/EnergyPacketParser.java | 68
app/src/main/res/drawable/ic_invert_colors.xml | 10
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/TestBase.java | 95
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/ImportExportSharedPreferencesTest.java | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/SaveCalibrationRequest.java | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/notification/NotificationFilterPutRequest.java | 107
external/build_fossil_hr_gbapps.sh | 29
app/src/main/res/layout/fragment_welcome_intro.xml | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/pebble/GBDeviceEventDataLogging.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3BehaviorSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/activity/ActivitySyncStartPacket.java | 29
app/src/main/res/layout/qhybrid_activity_widget_settings.xml | 73
app/src/main/res/xml/devicesettings_fossilhybridhr_post_fw300.xml | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/enduro/GarminEnduro3Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java | 230
src/mainline/fastlane/metadata/android/cs-CZ/full_description.txt | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrSampleProvider.java | 235
app/src/main/assets/womp/appointments.html | 73
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestStressSampleProvider.java | 89
app/src/main/res/layout/activity_cycling_live_data.xml | 51
app/src/main/res/drawable/ic_translate.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java | 346
app/src/main/java/lineageos/weather/LineageWeatherManager.java | 435
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband4/MiBand4Support.java | 66
app/src/main/res/drawable/ic_activity_bowling.xml | 11
app/src/main/res/layout/fragment_steps_list.xml | 44
app/src/main/res/layout/activity_welcome.xml | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendDndAddRequest.java | 92
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeInstallHandler.java | 132
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchSleepRespiratoryRateOperation.java | 118
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesChartFragment.java | 313
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pinetime/PineTimeJFSupport.java | 1274
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/configuration/ConfigurationPutRequest.java | 51
app/src/main/res/drawable-mdpi/ic_notification_qhybrid.png | 0
app/src/main/res/xml/devicesettings_zetime_vibrationpatterns.xml | 112
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gwb5600/CasioGWB5600DeviceCoordinator.java | 116
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/epix/GarminEpixProCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Watchface.java | 274
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/GpxReceiverActivity.java | 162
app/src/main/aidl/lineageos/weather/RequestInfo.aidl | 19
app/src/main/res/layout/fragment_gps.xml | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/DoNotDisturbSettings.java | 51
app/src/main/res/layout/layout_widget_generic.xml | 45
app/src/main/res/drawable/ic_always_on_display.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5Coordinator.java | 188
app/src/main/res/drawable-xxhdpi/ic_notification_disconnected_qhybrid.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/miligatt.txt | 34
src/mainline/fastlane/metadata/android/en-US/changelogs/211.txt | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/PpgResultCommand.java | 71
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/v2/SonyProtocolImplV2Test.java | 525
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRFWInstallHandler.java | 49
src/mainline/fastlane/metadata/android/en-US/changelogs/110.txt | 5
app/src/main/res/layout/activity_qhybrid_hr_settings.xml | 109
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4FWInstallHandler.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband5/HonorBand5Coordinator.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleMisfitSampleProvider.java | 77
app/src/main/res/layout/activity_reminder_details.xml | 176
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiReportThreshold.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipService.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxRunningWidget.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HRVStatusFragment.java | 415
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityUser.java | 213
app/src/main/res/drawable/ic_wb_twilight.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppe/ZeppEFirmwareInfo.java | 116
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java | 661
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nothing/NothingProtocol.java | 341
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetSleepDataCountRequest.java | 84
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TimestampValueFormatter.java | 50
app/src/main/res/xml/devicesettings_pairingkey_explanation.xml | 15
app/src/main/assets/ic_device_sony_800n.svg | 215
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileCloseAndPutRequest.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/control/ControlPointLowVibration.java | 44
app/src/main/res/layout/dashboard_widget_goals.xml | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3FWInstallHandler.java | 49
src/mainline/fastlane/metadata/android/en-US/changelogs/132.txt | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/DisplaySetting.java | 63
app/src/main/res/drawable/ic_play.xml | 10
app/src/test/resources/gpx-parser-test-multiple-segments.gpx | 57
app/src/main/res/xml/devicesettings_morning_updates.xml | 29
src/mainline/fastlane/metadata/android/en-US/changelogs/179.txt | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetStepDataRequest.java | 96
app/src/main/java/org/bouncycastle/shaded/crypto/modes/SICBlockCipher.java | 381
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventWearState.java | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SetRemoteCameraCommand.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivityType.java | 181
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/WeightChartFragment.java | 257
app/src/main/res/layout/widget.xml | 215
app/src/main/assets/womp/home.html | 100
app/src/main/res/layout/activity_um25_data.xml | 101
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSport.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEAction.java | 78
app/src/main/res/xml/devicesettings_miband3.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgtr/AmazfitGTRLiteSupport.java | 65
app/src/main/assets/fossil_hr/widgetWeather_preview.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetDateFormatRequest.java | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/watch9/Watch9DeviceSupport.java | 497
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetActivityTypeRequest.java | 62
app/src/main/res/xml/preferences.xml | 483
app/src/main/res/layout/fragment_steps_period.xml | 120
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband3/MiBand3Support.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiCharacteristic.java | 570
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miwatch/MiWatchLiteCoordinator.java | 70
app/src/main/res/drawable/ic_file_open.xml | 12
src/mainline/fastlane/metadata/android/en-US/changelogs/180.txt | 8
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/ArrayUtilsTest.java | 157
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner165Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vibratissimo/VibratissimoCoordinator.java | 156
src/mainline/fastlane/metadata/android/en-US/changelogs/148.txt | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/PhoneCallReceiver.java | 172
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/activity/ActivitySyncTimePacketTypeB.java | 27
app/src/main/assets/sports_badmington.svg | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitcor2/AmazfitCor2FirmwareInfo.java | 87
app/src/main/assets/nothing_ear_r.svg | 73
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/Mi1SFirmwareInfo.java | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5FWHelper.java | 40
app/src/main/res/drawable/ic_gps_location.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/v2/PayloadTypeV2.java | 68
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/VibrationProfile.java | 110
app/src/main/res/drawable/ic_insights.xml | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFindPhone.java | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsFileTransferService.java | 615
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchHeartRateMaxOperation.java | 118
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WithingsStructure.java | 103
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java | 897
app/src/main/res/xml/devicesettings_huami2021_shortcuts.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java | 100
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband8/HuaweiBand8Coordinator.java | 41
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/FirmwareTest.java | 190
app/src/main/assets/fossil_hr/menu_layout.json | 207
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java | 496
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/itag/ITagCoordinator.java | 172
app/src/main/assets/womp/app.js | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive4Coordinator.java | 18
app/src/main/res/drawable/ic_device_sony_overhead.xml | 23
src/mainline/fastlane/metadata/android/en-US/changelogs/206.txt | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbip/AmazfitBipLiteFirmwareInfo.java | 107
app/src/main/res/xml/devicesettings_workout_keep_screen_on.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksContentObserver.java | 100
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/TestDisconnectNotification.java | 104
app/src/main/res/drawable/ic_device_headphones.xml | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FileManagementActivity.java | 194
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/CobsCoDec.java | 128
app/src/main/res/xml/devicesettings_disconnectnotification.xml | 32
src/mainline/fastlane/metadata/android/en-US/changelogs/123.txt | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBOpenHelper.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiPreferences.java | 94
app/src/main/res/xml/devicesettings_redmibuds5pro_sound.xml | 144
app/src/nopebble/res/mipmap-hdpi/ic_launcher_round.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/ActivityPointGetRequest.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsAlarmsService.java | 220
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendNotifyRestHeartRateCapabilityRequest.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/HeartRate.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR03Coordinator.java | 38
app/src/main/res/layout/activity_supercars_control.xml | 142
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/SetProgressAction.java | 69
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/status/WeatherType.java | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java | 128
.gitignore | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/battery/BatteryInfo.java | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetSkinTemperatureMeasurement.java | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PBWReader.java | 358
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch4/RedmiWatch4Coordinator.java | 61
src/mainline/fastlane/metadata/android/en-US/changelogs/139.txt | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveStyleCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3SettingKeys.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/GpxParser.java | 295
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctCrossoverCoordinator.java | 18
src/mainline/fastlane/metadata/android/en-US/changelogs/162.txt | 2
app/src/main/res/drawable/baseline_merge_24.xml | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/DownloadRequestMessage.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AppCompatPreferenceActivity.java | 130
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/notification/defines/LedColor.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/QuickAccess.java | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsMenuType.java | 142
app/src/main/aidl/com/android/internal/telephony/ITelephony.aidl | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java | 462
app/src/main/res/drawable/ic_device_vesc_disabled.xml | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/deviceinfo/DeviceInfo.java | 164
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsGpxRouteFile.java | 170
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/makibeshr3/MakibesHR3DeviceSupport.java | 1083
app/src/main/assets/fossil_hr/widgetHR.bin | 0
app/src/main/assets/ic_activity_threadmill.svg | 76
app/src/main/res/layout/preference_checkbox.xml | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitFileCreator.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/prefs/Gestures.java | 22
app/src/main/res/layout/activity_notification_filter.xml | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/RequestConnectionPriorityAction.java | 42
app/src/main/res/drawable/ic_select_all.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetMusicList.java | 45
app/src/main/res/drawable/ic_activity_graphs.xml | 10
app/src/main/res/xml/devicesettings_battery_minimum_charge.xml | 10
app/src/nightly/res/mipmap-xhdpi/ic_launcher_round.png | 0
src/mainline/fastlane/metadata/android/en-US/changelogs/150.txt | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/GenericStatusMessage.java | 39
src/mainline/fastlane/metadata/android/en-US/changelogs/193.txt | 14
app/src/main/res/values-sr/strings.xml | 2016
src/banglejs/fastlane/metadata/android/en-US/changelogs | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySample.java | 101
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendGpsDataRequest.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiBRSupport.java | 215
app/src/main/res/drawable/ic_navigation.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/LinearSamplePacketParser.java | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/BatteryStateHandler.java | 57
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/activity/EventWithValue.java | 34
app/src/main/java/org/bouncycastle/shaded/crypto/engines/AESEngine.java | 609
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java | 260
app/src/main/res/drawable/ic_music_note.xml | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionTimestamp.java | 26
app/src/nopebble/res/mipmap-mdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/liberty4_nc/SoundcoreLiberty4NCCoordinator.java | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBException.java | 35
app/src/main/res/values-zh-rCN/strings.xml | 3701
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nut/NutUtilsTest.java | 247
app/src/main/res/layout/activity_open_fw_app_installer.xml | 88
app/src/main/res/drawable/ic_activity_snorkeling.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/WeekSleepChartFragment.java | 499
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/SetAlarmRequest.java | 116
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FirmwareFilePutRequest.java | 50
app/src/main/res/layout/fossil_hr_row_installed_app.xml | 21
app/src/main/res/menu/menu_file_manager.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/AbstractMi1SFirmwareInfo.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/activity/ActivityWithData.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/util/UIntBitWriter.java | 53
app/src/main/res/xml/devicesettings_header_display.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gbx100/SetConfigurationOperation.java | 326
app/src/main/res/xml/devicesettings_disable_find_phone_with_dnd.xml | 9
app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlCallback.aidl | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahpro/AmazfitCheetahProCoordinator.java | 85
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/WithingsServerAction.java | 43
app/src/main/res/layout/item_musicmanager_song.xml | 63
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts4mini/AmazfitGTS4MiniCoordinator.java | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WithingsStructureType.java | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractWeekChartFragment.java | 392
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/translation/TranslationItem.java | 51
app/src/main/res/xml/devicesettings_reserve_reminders_calendar.xml | 15
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivitySampleSwim.java | 36
app/src/main/res/drawable/ic_link.xml | 5
app/src/main/res/xml/devicesettings_root_display.xml | 9
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/TestDeviceSupport.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Workout.java | 740
src/mainline/fastlane/metadata/android/en-US/changelogs/220.txt | 23
app/src/main/assets/ic_activity_bike_trainer.svg | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java | 729
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProFWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java | 226
app/src/main/res/drawable/ic_nights_stay.xml | 13
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/CobsCoDecTest.java | 113
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/itag/ITagSupport.java | 158
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband2/MiBand2Support.java | 94
app/src/main/res/xml/devicesettings_canned_dismisscall_16.xml | 122
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleColor.java | 88
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/BFH16Constants.java | 125
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/SendCallNotificationRequest.java | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/GlyphRequestHandler.java | 92
app/src/main/res/drawable/ic_activity_bike_trainer.xml | 34
app/src/main/assets/fossil_hr/icWthRainy.rle | 1
app/src/main/res/layout/fragment_steps.xml | 101
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEServerAction.java | 75
app/src/main/res/menu/menu_discovery.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ActivitySummariesAdapter.java | 394
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteFWInstallHandler.java | 49
app/src/main/res/xml/devicesettings_liftwrist_display_sensitivity.xml | 39
src/mainline/fastlane/metadata/android/en-US/changelogs/109.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetWorkoutPaceRequest.java | 127
app/src/main/java/com/mobeta/android/dslv/DragSortItemView.java | 95
app/src/main/res/xml/devicesettings_inactivity_noshed.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/WithingsUUID.java | 34
app/src/main/res/drawable/ic_loyalty.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/configuration/ConfigurationGetRequest.java | 54
app/src/main/res/xml/fossil_hr_widget_settings.xml | 109
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AutomaticPowerOff.java | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/FormatUtils.java | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/device_info/DeviceSecurityVersionInfo.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM4Coordinator.java | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_17.java | 38
app/src/main/assets/fossil_hr/widgetUV_preview.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3ActivitySampleCombiner.java | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgts2/AmazfitGTS2eSupport.java | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSampleProvider.java | 211
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/time/BandDaylightSavingTime.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendWeatherUnitRequest.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/SonySWR12Constants.java | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/SwipeEvents.java | 74
app/src/main/res/drawable-xhdpi/ic_watchface.png | 0
app/src/main/res/values-zh-rTW/strings.xml | 3411
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceMusicUpdate.java | 11
src/mainline/fastlane/metadata/android/en-US/images/icon.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiCompositionScaleCoordinator.java | 142
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/EarSettingsCustomizer.java | 92
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/model/GpxTrackSegment.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsDeviceCoordinator.java | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/moondrop/MoondropSpaceTravelProtocol.java | 187
app/src/main/assets/fossil_hr/widget_bg_dashed_circle.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/AccountRelated.java | 82
app/src/main/java/org/bouncycastle/shaded/util/Arrays.java | 1227
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceWidgetActivity.java | 273
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/divoom/PixooIOThread.java | 62
app/src/main/assets/ic_sony_wf_800n_right.svg | 95
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendSetUpDeviceStatusRequest.java | 58
app/src/main/res/xml/devicesettings_sony_headphones_equalizer_with_custom_bands.xml | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiFirmwareType.java | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java | 619
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/GetNotificationAttributes.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgtr2/AmazfitGTR2FirmwareInfo.java | 120
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ImageData.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/LiveWorkoutPauseState.java | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/CompositeMiFirmwareInfo.java | 112
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiUuids.java | 121
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilHRInstallHandler.java | 189
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vesc/CommandType.java | 63
app/src/main/assets/ic_sony_wf_800n_left.svg | 95
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivitySampleWalk.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/SetCountdownSettings.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsContactsService.java | 118
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitEvent.java | 47
app/src/main/res/drawable/ic_done_all.xml | 10
app/src/main/res/xml/devicesettings_mismartscale.xml | 18
src/banglejs/fastlane/metadata/android/pt-PT/full_description.txt | 6
src/mainline/fastlane/metadata/android/en-US/changelogs/209.txt | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/v1/PayloadTypeV1.java | 128
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband4pro/HuaweiBand4ProCoordinator.java | 41
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/fitpro/FitProDeviceSupportTest.java | 69
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsFragment.java | 134
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Earphones.java | 119
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/MoveHand.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothPairingRequestReceiver.java | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TimeSample.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/CasioGWB5600DeviceSupport.java | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BLETypeConversions.java | 422
app/src/main/res/values-v14/dimens.xml | 10
app/src/main/res/layout/activity_main.xml | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopFWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetAuthRequest.java | 122
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivitySampleSleep.java | 50
src/mainline/fastlane/metadata/android/en-US/changelogs/136.txt | 17
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/SourceAppId.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/hplus/HPlusHandlerThread.java | 650
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband4/MiBand4FirmwareInfo.java | 99
app/src/main/res/drawable/ic_activity_rope_jump.xml | 14
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractSettingsActivityV2.java | 92
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java | 524
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsFtpServerService.java | 177
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsFirmwareUpdateOperation.java | 376
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/buttonconfig/ConfigPayload.java | 105
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCaloriesTotalSegmentedWidget.java | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/liberty/TapFunction.java | 22
app/src/main/assets/fossil_hr/icSteps.rle | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/AuthNegotiationMessage.java | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SleepScoreSample.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBFragmentActivity.java | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/watch9/operations/InitOperation.java | 93
app/src/main/res/layout/activity_list.xml | 46
app/src/main/proto/garmin/gdi_calendar_service.proto | 48
app/src/main/assets/fossil_hr/navigationApp.wapp | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsAgpsUpdateOperation.java | 162
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/TestResponseManager.java | 485
app/src/nightly/res/mipmap-hdpi/ic_launcher_round.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitcor2/AmazfitCor2Support.java | 40
app/src/main/assets/ic_intensity.svg | 20
app/src/main/res/drawable/ic_file_upload.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProConstants.java | 250
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MessageWriter.java | 88
app/src/main/assets/womp/wastesale.html | 87
app/src/main/res/drawable/ic_chair.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitCourse.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuCoordinator.java | 18
app/src/main/res/xml/devicesettings_sony_headphones_ambient_sound_control.xml | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoCoordinator.java | 153
app/src/main/res/drawable/ic_device_flipper_disabled.xml | 243
.woodpecker/nightly.yml | 140
app/src/main/res/xml/devicesettings_sony_headphones_protocol_version.xml | 15
app/src/main/res/xml/devicesettings_screenorientation.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/SoundcorePacket.java | 112
app/src/main/res/xml/devicesettings_root_generic.xml | 9
app/src/main/res/drawable/ic_verified.xml | 13
app/src/main/res/values-et/strings.xml | 856
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/PaiChartFragment.java | 477
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/activity/ActivityBase.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BatteryInfoChartFragment.java | 292
app/src/main/res/drawable/ic_activity_running.xml | 10
app/src/main/res/drawable/ic_activity_surfing.xml | 14
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBSummaryOfDay.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitcor/AmazfitCorFirmwareInfo.java | 104
src/mainline/fastlane/metadata/android/en-US/changelogs/224.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BatteryState.java | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWorkoutType.java | 89
app/src/main/res/layout/layout_widget_heartrate_average.xml | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1active/XiaomiWatchS1ActiveCoordinator.java | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband4/HonorBand4Coordinator.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/divoom/PixooInstallHandler.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitMonitoringHrData.java | 37
LICENSE | 661
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutGpsParser.java | 180
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuSq2Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordData.java | 291
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honormagicwatch2/HonorMagicWatch2Coordinator.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/GoalStepsSetting.java | 42
app/src/main/res/layout/item_app_blacklist.xml | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetFileDownloadCompleteRequest.java | 54
app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/fitpro/FitProDeviceSupport.java | 1461
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionCoordinate.java | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopFWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendDeviceReportThreshold.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java | 1472
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gbx100/GetConfigurationOperation.java | 174
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/DownloadFileRequest.java | 117
app/src/main/res/xml/devicesettings_prefix_notification_with_app.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfHeartRateSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiSupportProvider.java | 2514
app/src/main/res/xml/devicesettings_keep_activity_data_on_device.xml | 10
app/src/nopebble/res/mipmap-anydpi-v26/ic_launcher.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Notifications.java | 300
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband6/HonorBand6Coordinator.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsLoyaltyCardService.java | 264
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetProgressAction.java | 73
app/src/main/res/drawable/ic_create.xml | 10
app/src/main/res/values-fi/strings.xml | 664
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eCoordinator.java | 148
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/WaitAction.java | 43
app/src/main/res/drawable-hdpi/ic_languagepack.png | 0
app/src/main/res/xml/devicesettings_app_logs_start_stop.xml | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java | 262
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Ephemeris.java | 114
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/GetVibrationStrengthRequest.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ArrayUtils.java | 123
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/liveview/LiveviewCoordinator.java | 138
app/src/main/res/xml/devicesettings_donotdisturb_withauto.xml | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeSampleProvider.java | 85
app/src/main/res/layout/layout_widget_intensity_total2.xml | 47
src/mainline/fastlane/metadata/android/en-US/changelogs/145.txt | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qc35/QC35Coordinator.java | 91
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner955Coordinator.java | 18
src/banglejs/fastlane/metadata/android/en-US/title.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/PackageConfigHelper.java | 156
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiUploadManager.java | 211
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitZonesTarget.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiWorkoutGbParser.java | 1004
app/src/main/res/layout/activity_qhybrid_calibration.xml | 96
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GenericItem.java | 153
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetActivateOnLiftRequest.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java | 1700
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendWeatherCurrentRequest.java | 76
app/src/main/res/drawable/ic_device_lovetoy.xml | 35
src/mainline/fastlane/metadata/android/en-US/changelogs/126.txt | 2
app/src/main/res/layout/activity_commute_actions.xml | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/protobuf/messagefields/Int32MessageField.java | 24
app/src/main/assets/fossil_hr/icRainChance.rle | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacketV2.java | 514
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/alertnotification/OverflowStrategy.java | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionArray.java | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitDeviceSettings.java | 102
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/util/TimeUtil.java | 31
app/src/main/res/xml/devicesettings_root_calendar.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHrvSummarySampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgts2/AmazfitGTS2Support.java | 66
app/src/main/res/xml/devicesettings_custom_deviceicon.xml | 10
app/src/main/res/xml/charts_preferences.xml | 151
src/mainline/fastlane/metadata/android/ru/title.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSpo2SampleProvider.java | 56
app/src/main/res/xml/devicesettings_huami2021_shortcut_cards.xml | 13
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/musicmanager/MusicManagerActivity.java | 720
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSDeviceCoordinator.java | 98
app/src/main/res/drawable/ic_calendar_from.xml | 3
app/src/main/res/drawable/ic_activity_waterskiing.xml | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/time/BandTime.java | 78
app/src/main/aidl/lineageos/weatherservice/IWeatherProviderServiceClient.aidl | 25
app/src/main/res/drawable-hdpi/ic_watchapp.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLEDeviceCoordinator.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiConnectionSupport.java | 37
app/src/main/res/xml/devicesettings_moondrop_space_travel_equalizer.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/Roidmi3Coordinator.java | 68
app/src/main/res/xml/changelog_master.xml | 738
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SedentaryReminderIntervalCommand.java | 85
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetDeviceStatusRequest.java | 59
src/mainline/fastlane/metadata/android/en-US/changelogs/219.txt | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/SerbianTransliterator.java | 68
app/src/main/res/drawable/ic_brightness_2.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPaiSampleProvider.java | 241
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/RequestBondingCommand.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetStepDataCountRequest.java | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/TransactionBuilder.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WorkoutScreen.java | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitAlarmSettings.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoEncoAir2Coordinator.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesCapabilities.java | 49
app/src/main/res/xml/devicesettings_relax_firmware_checks.xml | 10
src/mainline/fastlane/metadata/android/en-US/changelogs/154.txt | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java | 1091
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSendBytes.java | 28
src/mainline/fastlane/metadata/android/en-US/changelogs/197.txt | 7
app/src/main/res/drawable/level_list_battery.xml | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/data/DashboardStressData.java | 85
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64SampleProvider.java | 85
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetAlarmsActivity.java | 144
app/src/main/res/drawable-xxhdpi/ic_watchface.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleInstallable.java | 47
app/src/main/res/xml/devicesettings_redmibuds5pro_headphones.xml | 50
app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxBitmap.aidl | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner245Coordinator.java | 18
app/src/main/res/xml/devicesettings_connect_two_devices.xml | 11
app/src/main/res/xml/devicesettings_soundcore_touch_options.xml | 107
src/banglejs/fastlane/metadata/android/en-US/images/phoneScreenshots/B1-AppManager.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip5/AmazfitBip5Coordinator.java | 81
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/GetFirmwareInfoRequest.java | 64
app/src/main/res/drawable/ic_activity_curling.xml | 15
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SystemEventMessage.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/SetTimeRequest.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/AlarmSettings.java | 111
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/AbstractColmiR0xCoordinator.java | 233
app/src/main/assets/app_config/configure.html | 102
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AppNotificationType.java | 197
app/src/main/res/values-my/strings.xml | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/SetStepGoalRequest.java | 44
src/mainline/fastlane/metadata/android/en-US/changelogs/232.txt | 77
app/src/main/res/mipmap-hdpi/ic_launcher_round.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBWorldClockListAdapter.java | 132
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiPhonebookService.java | 165
app/src/main/res/layout/item_with_details_horizontal.xml | 51
app/src/main/res/values-eu/strings.xml | 2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/bandwpseries/BandWBLEProfile.java | 88
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/activity/EventBase.java | 37
app/src/main/res/drawable/ic_device_default.xml | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsGenericCoordinator.java | 144
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/HexToBinaryInputStream.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/commute/CommuteConfigPutRequest.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesFilter.java | 481
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEventProcessor.java | 262
app/src/main/res/xml/devicesettings_donotdisturb_withauto_and_always.xml | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSampleProvider.java | 540
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/protobuf/messagefields/NestedMessageField.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiWorkoutStatus.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/IncomingMessageHandlerFactory.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryProgressEntry.java | 78
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/database/EntitiesTest.java | 205
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6FWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/protobuf/messagefields/Int64MessageField.java | 24
src/banglejs/fastlane/metadata/android/en-US/full_description.txt | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SampleProvider.java | 122
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SSolarCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/status/DeviceInfo.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt4/HuaweiWatchGT4Coordinator.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/Message.java | 440
app/src/main/res/values-fr/strings.xml | 3663
app/src/main/res/menu/file_manager_file.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentOverview.java | 42
app/src/main/res/xml/devicesettings_sony_headphones_ambient_sound_control_button_modes.xml | 11
app/src/main/res/drawable/ic_mood_bad.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/WeatherHandler.java | 439
app/src/main/res/layout/activity_configure_world_clocks.xml | 27
app/src/main/res/drawable/ic_help_outline.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetMusicRequest.java | 103
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3HeartRateSampleProvider.java | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/agps/GarminAgpsDataType.java | 25
app/lint-baseline.xml | 16179 +
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/redmibuds5pro/protocol/MessageType.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitFile.java | 247
app/src/main/assets/womp/checkin.html | 50
app/src/nopebble/res/values/strings.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitvergel/AmazfitVergeLFirmwareInfo.java | 118
app/src/main/res/layout/activity_qhybrid_file_management.xml | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitLap.java | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbips/AmazfitBipSFirmwareInfo.java | 132
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingDashboard.java | 329
app/src/main/java/ru/gelin/android/weather/notification/ParcelableWeather2.java | 291
app/src/main/res/xml/devicesettings_notifications_enable.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/NotificationsHandler.java | 521
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java | 27
app/src/main/res/drawable/ic_activity_badmington.xml | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/p2p/HuaweiP2PTrackService.java | 106
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendCameraRemoteSetupEvent.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HeartRateZonesConfig.java | 287
app/src/main/aidl/lineageos/weather/WeatherLocation.aidl | 19
app/src/main/res/xml/devicesettings_device_intents.xml | 10
app/src/main/res/xml/devicesettings_fitpro.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBReminderListAdapter.java | 156
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vesc/VescDeviceSupport.java | 338
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AboutActivity.java | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/VibrateRequest.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/defines/DisplayOrientation.java | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepStageSampleProvider.java | 56
app/src/main/res/drawable/ic_engineering.xml | 19
app/src/main/res/values/values.xml | 148
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_42.java | 38
app/src/main/res/.gitignore | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/application/ApplicationInformation.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/LatvianTransliterator.java | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/PendingFileProvider.java | 100
app/src/main/res/xml/devicesettings_wear_sensor_toggle.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/y5/Y5Coordinator.java | 161
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetProductInformationRequest.java | 57
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/notification/NotificationRemoval.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Logging.java | 208
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendFileUploadComplete.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsAgpsService.java | 108
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/hplus/HPlusDataRecord.java | 79
app/src/main/res/xml/devicesettings_sony_headphones_audio_upsampling.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceMusicPlaylist.java | 41
app/src/main/res/xml/devicesettings_dateformat.xml | 12
app/src/main/res/layout/fragment_welcome_docs_source.xml | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gbx100/CasioGBX100SampleProvider.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiHeartRateRestingSampleProvider.java | 113
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/SuperCarsConstants.java | 101
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetTemperatureUnitSetting.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/ServerTransactionBuilder.java | 87
app/src/main/res/drawable/ic_search.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java | 1113
src/mainline/fastlane/metadata/android/en-US/changelogs/171.txt | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/UploadFileRequest.java | 113
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/image/AssetImage.java | 90
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java | 532
app/src/main/res/xml/devicesettings_galaxy_buds.xml | 112
app/src/main/res/xml/devicesettings_soundcore_motion300.xml | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/CheckInitializedAction.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WorkoutScreenList.java | 48
src/mainline/fastlane/metadata/android/pt-PT/short_description.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetMusicInfoParams.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vibratissimo/VibratissimoSupport.java | 162
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/TinyWeatherForecastGermanyReceiver.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/SetTimeRequest.java | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRActivitySampleProvider.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Contact.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/SonySWR12Util.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vesc/VescBaseDeviceSupport.java | 45
app/src/main/res/xml/devicesettings_key_vibration.xml | 9
app/src/main/res/drawable/ic_activity_outdoor_walking.xml | 10
app/src/main/assets/ic_galaxy_buds_live_l.svg | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSSettingsCustomizer.java | 141
app/src/main/res/drawable/ic_device_vesc.xml | 26
src/mainline/fastlane/metadata/android/en-US/changelogs/117.txt | 9
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/70-SportActivities.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/XiaomiComplexActivityParser.java | 116
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleLESupport.java | 265
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsServicesService.java | 96
app/src/main/res/drawable/ic_music.xml | 21
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3StressSampleProvider.java | 73
app/src/main/res/drawable/ic_buds_pro_left.xml | 17
src/mainline/fastlane/metadata/android/en-US/changelogs/141.txt | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/WithingsMessage.java | 64
app/src/main/res/drawable/ic_wena_icon_left.xml | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/redmibuds5pro/RedmiBuds5ProDeviceSupport.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandFWHelper.java | 151
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/RoidmiConst.java | 36
app/src/main/res/xml/devicesettings_root_sound.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/constants/ResultCode.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/activity/ActivityType.java | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ItemWithDetails.java | 38
app/src/main/res/drawable/ic_tabs.xml | 10
src/banglejs/fastlane/metadata/android/pt-PT/short_description.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendAppDelete.java | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/translation/TranslationsGetRequest.java | 71
app/src/main/res/xml/devicesettings_header_connection.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr3/AmazfitGTR3Coordinator.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/HeartPulseSampleProvider.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/TimeSetting.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/SetupFinishedHandler.java | 35
app/src/main/res/xml/devicesettings_lock_unlock.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/redmibuds5pro/RedmiBuds5ProIOThread.java | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/notification/DismissTextNotificationRequest.java | 25
app/src/main/res/drawable/ic_horizontal_rule.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7XCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusSampleProvider.java | 541
app/src/main/res/layout/activity_backup_restore_progress.xml | 62
src/mainline/fastlane/metadata/android/en-US/changelogs/228.txt | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipFWHelper.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/HttpHandler.java | 140
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/StressPacketParser.java | 81
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendEphemerisFileUploadChunk.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceWidget.java | 217
app/src/main/res/xml/devicesettings_root_location.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesDailyFragment.java | 253
app/src/main/res/drawable-hdpi/ic_watchface.png | 0
src/mainline/fastlane/metadata/android/en-US/changelogs/188.txt | 3
src/mainline/fastlane/metadata/android/en-US/changelogs/102.txt | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutCoordinator.java | 172
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetFileDownloadInitRequest.java | 122
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendEphemerisFileConsultResponse.java | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahsquare/AmazfitCheetahSquareCoordinator.java | 95
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCharacteristic.java | 329
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/itag/ITagConstants.java | 26
app/src/main/aidl/lineageos/weather/IRequestInfoListener.aidl | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendFileUploadHash.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/SetFileFlagsStatusMessage.java | 58
app/src/main/res/drawable/ic_auto_awesome.xml | 5
app/src/main/assets/fossil_hr/widgetDate_preview.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM2Coordinator.java | 56
app/src/main/res/xml/devicesettings_inactivity_extended.xml | 41
app/src/main/assets/fossil_hr/icHeart.rle | 2
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/TestHuaweiCrypto.java | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BarChartStackedTimeValueFormatter.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3ActivitySampleProvider.java | 123
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryParser.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soflow/SoFlowSupport.java | 240
app/src/main/res/xml/devicesettings_amazfitbip3pro.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/Time.java | 98
app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl | 25
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/NotificationAttributeTest.java | 56
src/mainline/fastlane/metadata/android/en-US/changelogs/215.txt | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/femometer/FemometerVinca2SampleProvider.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsLiveDeviceCoordinator.java | 72
app/src/main/res/values-cs/strings.xml | 3699
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TimestampTranslation.java | 48
app/src/main/res/drawable/ic_voice.xml | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetFirmwareInfoCommand.java | 76
app/src/main/res/values-sk/strings.xml | 566
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/RespiratoryRateFragmentAdapter.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/activity/BehaviorSample.java | 57
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/PlayNotificationRequest.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/widget/CustomWidget.java | 75
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/AlarmListSettings.java | 60
app/src/main/res/drawable/ic_device_thermometer.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/AbstractFetchOperation.java | 376
app/src/main/res/drawable/ic_more_vert.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/alarm/BandAlarms.java | 51
app/src/main/res/xml/devicesettings_send_app_notifications.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleIntentApi.java | 241
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/authentication/PerformDevicePairingRequest.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/colmi/ColmiR0xDeviceSupport.java | 715
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/waspos/WaspOSCoordinator.java | 173
app/src/main/res/drawable/ic_events.xml | 10
app/src/main/res/drawable/ic_intensity_hollow.xml | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PBWInstallHandler.java | 213
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetWearMessagePushRequest.java | 60
app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/motion300/SoundcoreMotion300IOThread.java | 75
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/receivers/AutoConnectIntervalReceiver.java | 121
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBinAppParser.java | 111
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateMaxSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleHealthSampleProvider.java | 149
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileLookupRequest.java | 150
app/src/main/res/drawable/ic_email.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255SMusicCoordinator.java | 18
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/TestBtBRQueue.java | 68
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/liberty/TapAction.java | 19
app/src/main/res/layout/fragment_nested_tabs.xml | 17
app/src/main/assets/fossil_hr/icSpO2.rle | 4
app/src/main/res/menu/activity_main_drawer.xml | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/MusicControl.java | 555
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendWeatherErrorRequest.java | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/dialogs/MaterialListPreferenceDialogFragment.java | 117
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/SetLanguageRequest.java | 63
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/quickreply/QuickReplyConfirmationPutRequest.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsActivity.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryEntry.java | 23
src/mainline/fastlane/metadata/android/en-US/full_description.txt | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/PauseWhenTakenOff.java | 46
app/src/main/res/drawable/ic_device_hplus_disabled.xml | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit/HuaweiWatchFitCoordinator.java | 41
app/src/main/res/drawable/ic_settings.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/TextReceiverActivity.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/liveview/LiveviewIoThread.java | 98
app/src/main/assets/fossil_hr/widgetActiveMins.bin | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java | 602
.idea/icon.svg | 574
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionLanguage.java | 57
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/binary_sensor/activity/DataActivity.java | 86
app/src/main/res/values-ml/strings.xml | 89
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/WorkMode.java | 57
app/src/main/res/xml/devicesettings_goal_notification.xml | 10
app/src/main/res/xml/devicesettings_time_sync.xml | 9
app/src/main/res/xml/devicesettings_custom_emoji_font.xml | 10
.artifacts/5fb7b17c-2744-457a-b1a3-a3b9fbd1ae6d/implementation_plan.artifact.md | 24
app/src/main/res/drawable/ic_distance_total.xml | 6
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SleepAsAndroidFeature.java | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/Prefs.java | 255
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/alertnotification/AlertCategory.java | 97
app/src/main/res/xml/devicesettings_root_audio.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUFWInstallHandler.java | 49
app/src/main/res/layout/activity_discovery.xml | 68
app/src/main/res/drawable/ic_galaxy_buds_live_r.xml | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgtcyber/HuaweiWatchGTCyberCoordinator.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ResponseHandler.java | 23
app/src/main/java/com/mobeta/android/dslv/DragSortCursorAdapter.java | 233
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/StatusPageOrderSetting.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_35.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/FileUtils.java | 445
app/src/main/res/xml/devicesettings_dateformat_day_month_order.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vesc/VescControlActivity.java | 251
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractSpo2Sample.java | 45
app/src/main/res/drawable/ic_camera_remote.xml | 16
app/src/main/assets/fossil_hr/widget_bg_double_circle.png | 0
app/src/main/res/xml/devicesettings_allow_accept_reject_calls.xml | 17
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_45.java | 44
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupportTest.java | 71
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/TestDebugRequestParser.java | 401
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiDailySummarySampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/JYouSampleProvider.java | 87
app/src/main/res/drawable/ic_activity_eliptical.xml | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/PreformattedXIndexLabelFormatter.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java | 269
app/src/main/res/drawable/ic_shortcut.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/SimpleTransliterator.java | 69
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/agps/AgpsHandler.java | 143
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfStressSampleProvider.java | 56
app/src/main/java/org/bouncycastle/shaded/crypto/macs/CBCBlockCipherMac.java | 229
src/mainline/fastlane/metadata/android/en-US/changelogs/158.txt | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/HomeIconOrderSetting.java | 47
.gitea/pull_request_template.md | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/GeorgianTransliterator.java | 40
app/src/main/res/xml/devicesettings_preview_message_in_title.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/SilentModeReceiver.java | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ContactDetails.java | 154
app/src/main/res/layout/activity_list_dashboard_item.xml | 132
app/src/main/res/xml/devicesettings_zetime_activity_info.xml | 22
src/mainline/fastlane/metadata/android/en-US/changelogs/96.txt | 8
app/src/main/res/drawable/ic_pressure.xml | 5
app/src/main/res/layout/activity_widget_screen_details.xml | 254
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeDFUService.java | 35
app/src/nopebble/res/mipmap-xxhdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/widget/CustomWidgetElement.java | 108
app/src/main/res/menu/bottom_nav_menu.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/HeartRatePacketParser.java | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentIntro.java | 74
app/src/main/res/drawable/ic_videogame.xml | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsActivityType.java | 224
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gatt_client/BleGattClientCoordinator.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FilePutRawRequest.java | 241
app/src/main/res/drawable/ic_equalizer.xml | 3
app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java | 193
app/src/main/res/layout/item_alarm.xml | 143
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHybridHRActivitySample.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/deviceevents/SupportedFileTypesDeviceEvent.java | 19
app/src/main/res/xml/devicesettings_workout_activity_types.xml | 13
app/src/main/res/drawable/ic_activity_kitesurfing.xml | 23
app/src/main/res/xml/devicesettings_galaxy_buds_2_pro.xml | 214
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepDataRaw.java | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_27.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband7/HonorBand7Coordinator.java | 46
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/40-SleepPerDay.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java | 159
src/mainline/fastlane/metadata/android/en-US/changelogs/167.txt | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendWeatherForecastRequest.java | 94
app/src/main/res/drawable/ic_password.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/waspos/WaspOSConstants.java | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gbx100/CasioGBX100DeviceCoordinator.java | 159
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/DisconnectNotification.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gb6900/CasioGB6900DeviceCoordinator.java | 152
app/src/main/proto/garmin/gdi_data_transfer_service.proto | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/LiveHeartRate.java | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesProtocol.java | 363
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorwatchgs3/HonorWatchGS3Coordinator.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3CaloriesSampleProvider.java | 56
app/src/main/java/org/bouncycastle/shaded/crypto/params/KeyParameter.java | 56
src/mainline/fastlane/metadata/android/en-US/changelogs/201.txt | 2
app/src/main/res/xml/devicesettings_pebble_calls_notifications.xml | 19
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchPairingActivity.java | 166
app/src/main/res/layout/activity_widget_screens_list.xml | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBChangeLog.java | 115
src/mainline/fastlane/metadata/android/en-US/changelogs/210.txt | 19
app/src/nightly/res/mipmap-xhdpi/ic_launcher_foreground.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ConversationQueue.java | 100
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java | 193
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandPairingActivity.java | 266
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3FWHelper.java | 39
app/src/main/assets/womp/siteweight.html | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSettingsCustomizer.java | 92
app/src/main/res/drawable/ic_device_thermometer_disabled.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HrvSummarySample.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendEphemerisFileListResponse.java | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/SetVibrationStrengthRequest.java | 45
app/src/main/java/org/bouncycastle/shaded/crypto/modes/CCMModeCipher.java | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/SchemaMigration.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/womp/WompKeepAliveService.java | 329
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppSupport.java | 303
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandDateConverter.java | 98
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt3/HuaweiWatchGT3Coordinator.java | 47
app/src/main/res/drawable/ic_block.xml | 10
app/src/main/res/drawable/ic_filter_3.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SpeakToChatConfig.java | 124
app/src/main/res/drawable/ic_device_galaxy_buds_disabled.xml | 45
app/src/main/res/layout/fragment_vo2max.xml | 122
app/src/main/res/xml/shared_paths.xml | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/ActivateDisplayOnLift.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchHeartRateManualOperation.java | 118
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/TouchSensor.java | 46
app/src/main/res/layout/heart_rate_dialog.xml | 84
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/StepsSampleCollection.java | 30
app/src/main/res/xml/devicesettings_always_on_display.xml | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/WorkoutDetectionCapability.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/json/JsonPutRequest.java | 32
src/mainline/fastlane/metadata/android/en-US/changelogs/176.txt | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsPhoneService.java | 211
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/SetSedentaryReminderIntervalRequest.java | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/deviceevents/WeatherRequestDeviceEvent.java | 18
src/mainline/fastlane/metadata/android/de-DE/images/featureGraphic.jpg | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/motion300/SoundcoreMotion300Coordinator.java | 93
app/src/main/res/layout/activity_world_clock_details.xml | 161
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java | 1520
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/FitDataMessage.java | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java | 234
app/src/main/java/org/bouncycastle/shaded/crypto/CryptoException.java | 48
app/src/main/res/xml/devicesettings_test_features.xml | 12
app/src/main/res/values-uk/strings.xml | 2958
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/GPXParserTest.java | 82
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/idasen/IdasenDeviceSupport.java | 208
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/message/Message.java | 49
app/src/main/res/drawable/ic_save.xml | 9
app/src/main/res/layout/activity_permissions.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v1/CommunicatorV1.java | 119
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Handler.java | 21
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbalance/AmazfitBalanceCoordinator.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsShortcutCardsService.java | 275
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/SetCurrentStepCountRequest.java | 40
app/src/main/assets/fossil_hr/complication_layout.json | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiGpsParser.java | 101
app/src/main/res/drawable/ic_developer_mode.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BitmapUtil.java | 397
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SleepFragmentAdapter.java | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/alertnotification/AlertLevel.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivitySleepChartFragment.java | 203
app/src/main/res/drawable/ic_phone_msg.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WorkoutGpsState.java | 43
src/mainline/fastlane/metadata/android/en-US/changelogs/190.txt | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppInfo.java | 24
app/src/main/res/xml/devicesettings_transliteration.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/WatchAdapter.java | 179
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/RequestedNotificationAttribute.java | 58
src/mainline/fastlane/metadata/android/en-US/changelogs/113.txt | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBAlarmListAdapter.java | 166
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminTimeUtils.java | 29
app/src/main/res/values-tr/strings.xml | 3546
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/SonySWR12HandlerThread.java | 152
app/src/main/assets/fossil_hr/icActiveMins.rle | 3
app/src/main/res/xml/sleep_alarm_widget_info.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/sleepasandroid/SleepAsAndroidReceiver.java | 23
app/src/main/res/layout/activity_weather_notification.xml | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardVO2MaxWidget.java | 124
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitFieldDescription.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmismartband2/RedmiSmartBand2Coordinator.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLCoordinator.java | 134
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/Track.java | 205
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java | 588
app/src/main/assets/fossil_hr/widget2ndTZ.bin | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/notification/NotificationFilterPutHRRequest.java | 149
app/src/main/res/drawable/ic_device_galaxy_buds_live.xml | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/Casio2C2DDeviceCoordinator.java | 28
app/src/main/assets/ic_no_watch.svg | 121
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cycling_sensor/support/CyclingSensorSupport.java | 268
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiLanguageType.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eFWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventVersionInfo.java | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AmbientSoundControl.java | 87
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/SocketCallback.java | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomePageIndicator.java | 135
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSpo2SampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Vo2SampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressSimpleWidget.java | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/activity/Vo2MaxSample.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfittrexpro/AmazfitTRexProSupport.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProSampleProvider.java | 99
src/mainline/fastlane/metadata/android/en-US/changelogs/106.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java | 602
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetBondParamsRequest.java | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/notification/PlayNotificationRequest.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/OpenFwAppInstallerActivity.java | 93
app/src/main/res/xml/devicesettings_withingssteelhr.xml | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/notification/defines/NotificationFlags.java | 29
app/src/main/res/xml/devicesettings_zetime_display.xml | 31
app/src/main/assets/ic_device_galaxy_buds_live.svg | 166
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/OmniJawsObserver.java | 194
app/src/main/res/xml/devicesettings_world_clocks.xml | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch3/RedmiWatch3Coordinator.java | 60
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/RangeMapTest.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventLEDColor.java | 25
app/src/main/res/values-w820dp/dimens.xml | 6
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/StringUtilsTest.java | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/password/PasswordCapabilityImpl.java | 153
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CustomBarChart.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchPaiOperation.java | 147
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityParser.java | 132
app/src/main/res/xml/devicesettings_find_phone.xml | 24
app/src/main/res/drawable/ic_delete.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandSettingsCustomizer.java | 96
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/protobuf/ProtobufUtils.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBContactListAdapter.java | 110
app/src/main/res/xml/devicesettings_miband2_display.xml | 21
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eFWHelper.java | 40
app/src/main/res/drawable/ic_launcher_foreground.xml | 21
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/ActivateDisplayOnLiftSensitivity.java | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nut/NutSupport.java | 488
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/JYouConstants.java | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminByteBufferReader.java | 82
app/src/main/res/xml/devicesettings_device_actions.xml | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBToStringBuilder.java | 50
app/src/main/res/drawable-xxhdpi/ic_languagepack.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/SilentMode.java | 99
app/src/main/res/drawable-hdpi/ic_notification_low_battery.png | 0
app/src/main/res/layout/fragment_pai_chart.xml | 192
app/src/main/res/values/dslv_attrs.xml | 39
app/src/main/res/drawable/ic_settings_remote.xml | 10
app/src/main/res/layout/activity_summary_dashboard_item.xml | 476
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbipupro/AmazfitBipUProSupport.java | 34
app/src/main/res/drawable/ic_humidity_mid.xml | 10
app/src/main/res/xml/devicesettings_fake_timeoffset.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiFwHelper.java | 354
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NavigationInfoSpec.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepStage.java | 33
app/src/main/res/drawable/ic_heart.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/ConditionalWriteAction.java | 46
app/src/main/res/xml/devicesettings_loyalty_cards.xml | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/receivers/GBCallControlReceiver.java | 92
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdateDeviceInfo.java | 32
app/src/main/res/drawable/ic_device_pinetime_disabled.xml | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SpeedZonesFragment.java | 174
src/mainline/fastlane/metadata/android/en-US/changelogs/131.txt | 5
app/src/main/res/xml/devicesettings_battery_discharge_5.xml | 169
app/src/main/res/mipmap-hdpi/ic_launcher.png | 0
app/src/main/assets/sports_pingpong.svg | 167
app/src/nopebble/res/mipmap-xhdpi/ic_launcher_foreground.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/OTAResetRequest.java | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeActivitySampleProvider.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/DeviceConfig.java | 1756
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniFWInstallHandler.java | 49
app/src/main/res/layout/steps_streak_current_line_layout.xml | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/ControlActivity.java | 294
app/src/main/res/xml/devicesettings_amazfitgtsgtr2.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr3pro/AmazfitGTR3ProCoordinator.java | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FieldDefinitionFactory.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/AbstractTestSampleProvider.java | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiNotificationService.java | 570
app/src/main/res/layout/steps_streak_total_line_layout.xml | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FilePutRequest.java | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHrvValueSampleProvider.java | 56
app/src/nightly/res/mipmap-hdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/NotificationSubscriptionMessage.java | 42
src/mainline/fastlane/metadata/android/pt-PT/full_description.txt | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/roidmi/Roidmi3Protocol.java | 156
app/src/main/res/drawable/ic_calories.xml | 3
app/src/main/res/drawable/ic_touch.xml | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SProCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gb6900/InitOperation.java | 168
app/src/main/res/layout/sleep_alarm_widget.xml | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/Vo2MaxSampleProvider.java | 15
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListenerTest.java | 111
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/constants/SensorState.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminIntensityMinutesSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/update/UpdateFirmwareOperation2020.java | 303
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/HexToBinaryInputStreamTest.java | 50
app/src/test/resources/GPXExporterTest-SampleTracksHR.csv | 14
app/src/main/res/drawable/ic_arrow_upward.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CyclingChartFragment.java | 295
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/status/WeatherDay.java | 46
app/src/main/res/xml/devicesettings_autoheartrate.xml | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/NotificationDataStatusMessage.java | 51
app/src/main/res/layout/activity_binary_sensor_data.xml | 32
app/src/main/res/layout/device_itemv2.xml | 546
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gb6900/SetAlarmOperation.java | 135
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/AlwaysOnDisplay.java | 24
app/src/main/res/drawable-mdpi/ic_notification.png | 0
src/mainline/fastlane/metadata/android/en-US/changelogs/181.txt | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/IntentListener.java | 26
app/src/main/res/drawable/ic_star_gray.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/AlarmsRequest.java | 95
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/HttpUtils.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitpop/AmazfitPopSupport.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/v1/params/VirtualSoundParam.java | 43
app/src/main/res/layout/fragment_cycling.xml | 16
app/src/main/res/xml/devicesettings_root_workout.xml | 9
app/src/main/res/xml/devicesettings_inactivity_dnd_no_threshold.xml | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiTemperatureSampleProvider.java | 126
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesGpsFragment.java | 180
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SpinnerWithIconItem.java | 48
app/src/main/res/xml/devicesettings_header_workout.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/agps/GarminAgpsFile.java | 97
app/src/main/res/drawable-xhdpi/ic_notification_low_battery.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/defines/MenuIconId.java | 36
app/src/main/res/xml/devicesettings_battery_allow_pass_through.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/SyncFinishedHandler.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/AbstractHuamiActivityDetailsParser.java | 47
app/src/main/assets/ic_device_flipper.svg | 1772
app/src/main/proto/garmin/gdi_notifications_service.proto | 37
app/src/main/res/values-pl/strings.xml | 3718
app/src/nopebble/res/values/ic_launcher_background.xml | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3FWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiScheduleService.java | 630
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/CalendarReceiver.java | 281
app/src/main/res/drawable/ic_events_gold.xml | 21
src/mainline/fastlane/metadata/android/de-DE/title.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsSettingsCustomizer.java | 457
app/src/main/res/drawable/ic_device_miband6_disabled.xml | 32
app/src/main/res/drawable/ic_device_pebble_disabled.xml | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiBatteryInfo.java | 121
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoEncoAirCoordinator.java | 75
src/mainline/fastlane/metadata/android/en-US/changelogs/205.txt | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiSyncState.java | 106
app/src/main/res/drawable/ic_activity_wakeboarding.xml | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/Status.java | 36
app/src/main/res/drawable/ic_activity_soccer.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/flipper/zero/support/FlipperZeroSupport.java | 249
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xPacketHandler.java | 491
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/ButtonModes.java | 65
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/52-StepsPerMonth.png | 0
src/mainline/fastlane/metadata/android/en-US/changelogs/163.txt | 4
app/src/main/res/drawable/ic_hearing.xml | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepSessionSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/constants/MessageId.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityAmount.java | 95
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceTypeTest.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProFWInstallHandler.java | 49
app/src/main/res/drawable/ic_sync.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AbstractActivityListingAdapter.java | 181
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/notification/EndOfIncomingCall.java | 30
app/src/main/assets/fossil_hr/battery_layout.json | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbipupro/AmazfitBipUProFirmwareInfo.java | 93
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendNotificationRequest.java | 108
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBleSupport.java | 341
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/ArmenianTransliterator.java | 305
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_66.java | 56
src/banglejs/fastlane/metadata/android/cz-CZ/short_description.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiConst.java | 184
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ScreenSettings.java | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/Spo2ChartFragment.java | 287
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5FWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchCoordinator.java | 141
app/src/main/res/xml/devicesettings_amazfitband5.xml | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentInstalledApps.java | 83
app/src/main/res/drawable/ic_battery_50.xml | 13
app/src/main/res/xml/devicesettings_musicmanagement.xml | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/Request.java | 81
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHeartRateRestingSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/file/ResultCode.java | 72
app/src/main/assets/ic_device_vesc.svg | 146
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitpoppro/AmazfitPopProSupport.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileGetRequest.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractFitProActivitySample.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java | 282
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/util/ByteArrayReader.java | 69
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/jyou/JYouSupport.java | 339
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WorldClockDetails.java | 220
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WorkoutType.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/StressSample.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/alertnotification/AlertNotificationProfile.java | 118
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsData.java | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/OperationStatus.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/activity/TestActivitySummaryParser.java | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRatePeriodFragment.java | 239
src/banglejs/fastlane/metadata/android/en-US/images/phoneScreenshots/10-MainScreen.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/Mi1SFirmwareInfoFW1.java | 80
app/src/main/assets/fossil_hr/widgetChanceOfRain.bin | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/DeviceCandidateAdapter.java | 100
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiFileDownloadManager.java | 686
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeLong.java | 59
app/src/main/res/values-pt/strings.xml | 2383
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/GetHydrationReminderIntervalRequest.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionHrvStatus.java | 58
app/src/main/res/layout/fragment_devices.xml | 37
app/src/main/res/layout/steps_streak_total.xml | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/LithuanianTransliterator.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PendingIntentUtils.java | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1Coordinator.java | 173
app/src/main/res/drawable/ic_vpn_key.xml | 10
app/src/main/java/lineageos/weather/WeatherLocation.java | 274
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/preferences/MinMaxTextWatcher.java | 59
app/src/nopebble/res/mipmap-mdpi/ic_launcher_foreground.png | 0
build.gradle | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiMusicUtils.java | 130
app/src/main/assets/fossil_hr/icWthClearNite.rle | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/v1/params/NoiseCancellingOptimizerStatus.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/MenuIconSetting.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/App.java | 144
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesSettingsCustomizer.java | 226
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WaitAction.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/agps/GarminAgpsStatus.java | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfittrex/AmazfitTRexSupport.java | 43
app/src/main/proto/garmin/garmin_contacts.proto | 46
src/mainline/fastlane/metadata/android/en-US/changelogs/194.txt | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreferenceFragment.java | 72
app/src/main/res/layout/activity_legacy_external_pebble_js.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/FileTransferDataMessage.java | 60
src/mainline/fastlane/metadata/android/en-US/changelogs/223.txt | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/FitDefinitionMessage.java | 50
app/src/main/res/xml/devicesettings_galaxy_buds_live.xml | 71
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerYWeather.java | 244
app/src/main/res/drawable/ic_screenshot.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetExtendedMusicInfoParams.java | 44
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/CalendarEventTest.java | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RestingMetabolicRateSample.java | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppMessage.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdateDeviceState.java | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbip3pro/AmazfitBip3ProFirmwareInfo.java | 82
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/GaugeDrawer.java | 356
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts3/AmazfitGTS3Coordinator.java | 66
app/src/main/res/layout/activity_fragmentappmanager.xml | 52
app/src/main/res/layout/item_contact.xml | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitactive/AmazfitActiveCoordinator.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/model/GpxFile.java | 106
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/WithingsSteelHRDeviceSupport.java | 748
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/GetGeneralSettingsRequest.java | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/um25/Support/UM25Support.java | 286
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/VibrationActivity.java | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleMorpheuzSampleProvider.java | 80
app/src/main/assets/ic_launcher.svg | 574
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationService.java | 186
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/MusicSpec.java | 92
src/mainline/fastlane/metadata/android/en-US/changelogs/138.txt | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive3Coordinator.java | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveTrendCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gson/GsonUtcDateAdapter.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3Coordinator.java | 255
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/liveview/LiveviewProtocol.java | 148
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeShort.java | 56
app/src/main/res/xml/devicesettings_galaxy_buds_2.xml | 212
app/src/main/res/xml/devicesettings_huawei_freebuds.xml | 18
src/mainline/fastlane/metadata/android/en-US/changelogs/122.txt | 6
app/src/main/res/drawable/ic_device_miscale.xml | 41
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/FitProTests.java | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu3Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband2pro/HuaweiBand2ProCoordinator.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v2/CommunicatorV2.java | 500
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorFWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsd03Coordinator.java | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StressChartFragment.java | 571
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RecordedDataTypes.java | 39
app/src/main/res/xml/devicesettings_sony_headphones_pause_when_taken_off.xml | 9
app/src/main/res/menu/menu_charts.xml | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BTLEOperation.java | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/motion300/SoundcoreMotion300SettingsCustomizer.java | 117
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/Message.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/GarminCapability.java | 183
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soflow/SoFlowCoordinator.java | 166
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/alertnotification/NewAlert.java | 81
app/src/main/res/values-el/strings.xml | 1004
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendEphemerisFileUploadDoneResponse.java | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gatt_client/BleGattClientSupport.java | 71
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilAppWriter.java | 170
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/BatteryLevelRequest.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gbx100/InitOperation.java | 385
app/src/main/assets/ic_device_miband6_disabled.svg | 198
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java | 544
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingDetail.java | 106
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTemperatureSampleProvider.java | 213
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/AbstractInfo.java | 26
app/src/main/java/org/bouncycastle/shaded/crypto/OutputLengthException.java | 10
app/src/main/res/drawable/ic_warning.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/model/GpxWaypoint.java | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java | 2160
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/colacao/ColaCao21Coordinator.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/CaloriesPacketParser.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2PlusCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/PlainAction.java | 40
app/src/main/assets/womp/login.html | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/CameraAppTypeSetting.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AngledLabelsChartRenderer.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiWeatherConditions.java | 160
app/src/main/res/drawable/ic_device_banglejs.xml | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleToTimeSampleProvider.java | 104
app/src/main/res/xml/devicesettings_screen_on_on_notifications.xml | 10
app/src/main/assets/fossil_hr/widgetSteps_preview.png | 0
app/src/main/res/xml/devicesettings_fossilhybridhr_navigation.xml | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/SendNotificationRequest.java | 109
app/src/main/res/xml/devicesettings_sony_headphones_quick_access.xml | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoFWInstallHandler.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband3/HonorBand3Coordinator.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/CroatianTransliterator.java | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner265Coordinator.java | 18
app/src/main/java/lineageos/weather/WeatherInfo.java | 642
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/activity/ActivitySyncTimePacketTemplate.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsActivityDetailsParser.java | 357
app/src/main/res/xml/devicesettings_amazfitx.xml | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miscale/MiCompositionScaleDeviceSupport.java | 164
app/src/main/res/menu/app_blacklist_menu.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetAppInfoParams.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/id115/FetchActivityOperation.java | 176
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupCallback.java | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetFileBlockRequest.java | 69
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSWorkoutParser.java | 170
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSMediaCommand.java | 61
app/src/main/res/drawable-xhdpi/ic_notification_disconnected_qhybrid.png | 0
app/src/main/res/values-nl/strings.xml | 3563
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetSmartAlarmList.java | 96
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/SimpleConversation.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gwb5600/CasioGMWB5000DeviceCoordinator.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/OsmandEventReceiver.java | 237
app/src/main/res/layout/activity_solar_equipment_status.xml | 21
app/src/main/res/drawable/ic_beep.xml | 10
app/src/main/res/xml/devicesettings_fossilhybridhr_dev.xml | 51
app/src/main/res/xml/devicesettings_pixoo.xml | 17
app/src/main/aidl/lineageos/weatherservice/ServiceRequestResult.aidl | 19
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/MusicListAdapter.java | 118
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchd2/HuaweiWatchD2Coordinator.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/redmibuds5pro/RedmiBuds5ProProtocol.java | 548
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/ResponseManager.java | 137
app/src/main/res/drawable-xxhdpi/ic_notification_full_battery.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/Request.java | 151
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eFWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_73.java | 43
app/src/main/aidl/lineageos/weather/ILineageWeatherManager.aidl | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardHrvWidget.java | 157
app/src/main/res/layout/fragment_welcome_get_started.xml | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/DataType.java | 47
app/src/main/proto/xiaomi.proto | 974
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBUpdateScript.java | 33
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/TestVarInt.java | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java | 312
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/NotificationCommand.java | 123
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressSegmentedWidget.java | 96
app/src/main/assets/tws_case.svg | 61
src/mainline/fastlane/metadata/android/en-US/changelogs/144.txt | 1
app/src/main/assets/ic_device_supercars_disabled.svg | 179
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestHrvValueSampleProvider.java | 82
src/mainline/fastlane/metadata/android/en-US/changelogs/233.txt | 138
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/SonySWR12DeviceSupport.java | 246
app/src/main/assets/ic_device_flipper_disabled.svg | 1825
app/src/main/assets/fossil_hr/widgetCalories.bin | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleSettingsCustomizer.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/Transaction.java | 83
app/src/main/res/drawable/ic_device_galaxy_buds_live_disabled.xml | 41
app/src/main/res/drawable/ic_bluetooth_searching.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255MusicCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/buttons/ButtonConfiguration.java | 45
app/src/main/res/xml/devicesettings_liftwrist_display_sensitivity_with_smart.xml | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsAgpsFile.java | 82
app/src/main/res/xml/devicesettings_amazfittrex.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppEFWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfFwHelper.java | 187
app/src/main/assets/ic_galaxy_buds_l.svg | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandCoordinator.java | 309
app/src/main/res/drawable/ic_phone_missed_outline.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSWeather.java | 136
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceDesignerActivity.java | 676
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestPaiSampleProvider.java | 147
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivitySampleTime.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Service.java | 136
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetSupportedCommandsRequest.java | 105
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/TimePreference.java | 120
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaContentProvider.java | 264
app/src/main/proto/garmin/gdi_smart_proto.proto | 63
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ZipFileException.java | 27
app/src/main/res/xml/devicesettings_antilost.xml | 10
app/src/main/res/xml/devicesettings_operating_sounds.xml | 9
app/src/main/res/xml/devicesettings_workout_detection.xml | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/liberty/SoundcoreLiberty3ProDeviceSupport.java | 39
app/src/main/res/xml/devicesettings_scannable.xml | 29
app/src/main/res/xml/devicesettings_lefun_interface_language.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiWeatherManager.java | 302
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHrvSummarySampleProvider.java | 56
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/Tryout.java | 42
app/src/main/res/layout/activity_list_detail.xml | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitactiveedge/AmazfitActiveEdgeCoordinator.java | 90
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorFWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetGpsParameterRequest.java | 48
app/src/main/res/drawable/ic_info.xml | 10
app/src/nopebble/res/mipmap-xhdpi/ic_launcher_round.png | 0
app/src/main/res/xml/devicesettings_sony_headphones_speak_to_chat_with_settings.xml | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java | 241
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3Coordinator.java | 191
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/InstallActivity.java | 38
src/mainline/fastlane/metadata/android/ru/short_description.txt | 1
app/src/main/java/org/bouncycastle/shaded/crypto/SkippingStreamCipher.java | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTServer.java | 195
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/TypeVersion.java | 39
app/src/main/assets/fossil_hr/icBattCharging.rle | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridConstants.java | 54
app/src/main/aidl/net/osmand/aidlapi/navigation/ADirectionInfo.aidl | 3
app/src/main/res/xml/devicesettings_donotdisturb_allday_liftwirst.xml | 76
app/src/main/res/layout/activity_watch9_pairing.xml | 33
src/mainline/fastlane/metadata/android/en-US/changelogs/135.txt | 5
app/src/main/res/layout/list_item_checkable.xml | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/activity/ActivitySyncDataPacket.java | 94
app/src/main/res/xml/devicesettings_soundcore_motion300_audio.xml | 202
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/QHybridBaseSupport.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/um25/Activity/DataActivity.java | 151
app/src/main/res/values-fa/strings.xml | 232
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryJsonSummary.java | 217
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java | 155
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgts/AmazfitGTSFirmwareInfo.java | 120
app/src/main/res/layout/activity_banglejs_apps_management.xml | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileType.java | 118
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/BehaviorPacketParser.java | 104
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java | 932
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitx/AmazfitXSupport.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/liberty/SoundcoreLiberty4NCDeviceSupport.java | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardTodayWidget.java | 600
src/mainline/fastlane/metadata/android/en-US/changelogs/218.txt | 2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiWorkoutScreenActivityType.java | 75
app/src/main/res/color/btn_dark.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/GetSedentaryReminderIntervalRequest.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband3pro/HuaweiBand3ProCoordinator.java | 41
app/src/main/res/values-ia/strings.xml | 2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardDistanceWidget.java | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridAppChoserActivity.java | 197
src/mainline/fastlane/metadata/android/en-US/changelogs/198.txt | 5
app/src/main/res/values-hr/strings.xml | 1609
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2HRXCoordinator.java | 131
src/mainline/fastlane/metadata/android/ru/full_description.txt | 9
app/src/main/res/xml/devicesettings_miband2_dateformat.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/flipper/zero/support/FlipperZeroBaseSupport.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProCoordinator.java | 143
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_14.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepWidget.java | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/roidmi/RoidmiIoThread.java | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/GBActivity.java | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit2/HuaweiWatchFit2Coordinator.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java | 1603
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionWeatherCondition.java | 171
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/TestHuaweiPacket.java | 269
app/src/main/res/xml/devicesettings_sony_headphones_equalizer.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleHealthActivitySample.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/SupportedFileTypesStatusMessage.java | 49
app/src/main/res/layout-land/activity_notification_filter.xml | 96
app/src/main/res/layout/activity_pebble_pairing.xml | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/AbstractRepeatingFetchOperation.java | 140
app/src/main/res/drawable/ic_mtu.xml | 10
app/src/main/res/xml/devicesettings_sony_warning_wh1000xm3.xml | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/GetActivityDataRequest.java | 85
app/src/main/res/xml/devicesettings_activity_info_header.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbips/AmazfitBipSSupport.java | 110
src/mainline/fastlane/metadata/android/en-US/changelogs/99.txt | 2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3EnergySampleProvider.java | 56
app/src/main/res/drawable/ic_device_roidmi_disabled.xml | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AbstractNotificationPattern.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/test/TestDeviceSupport.java | 84
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitband5/AmazfitBand5FirmwareInfo.java | 82
app/src/main/res/values-ca/strings.xml | 1527
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/activity/EventCode.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115Constants.java | 107
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileVerifyRequest.java | 94
app/src/main/res/layout/activity_calblacklist.xml | 14
app/src/main/assets/fossil_hr/icWthPartCloudyDay.rle | 2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractFragmentPagerAdapter.java | 68
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/buttons/ButtonConfigurationPutRequest.java | 53
app/src/main/res/drawable/ic_device_sony_wf_800n_disabled.xml | 69
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBExceptionHandler.java | 103
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivosmart/GarminVivosmart5Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveHrCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchService.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM5Coordinator.java | 85
app/src/main/res/drawable/ic_battery_charging_50.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRCoordinator.java | 143
app/src/main/res/drawable/ic_wena_icon_center.xml | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunSampleProvider.java | 100
app/src/banglejs/AndroidManifest.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SpeakToChatEnabled.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWISP600NCoordinator.java | 56
src/mainline/fastlane/metadata/android/en-US/changelogs/227.txt | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner620Coordinator.java | 34
app/src/main/res/xml/devicesettings_header_authentication.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/p2p/HuaweiBaseP2PService.java | 114
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/p2p/HuaweiP2PCalendarService.java | 491
app/src/main/res/menu/dashboard_menu.xml | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/ValueDecoder.java | 41
app/src/main/res/drawable/ic_activity_threadmill.xml | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusHealthSampleProvider.java | 240
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3FWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/DatalogSessionAnalytics.java | 68
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressBreakdownWidget.java | 89
app/src/main/res/drawable/ic_activity_hiking.xml | 10
app/src/nightly/res/values/ic_launcher_background.xml | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWidgetManager.java | 525
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendEphemerisSingleFileInfoResponse.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgts2/AmazfitGTS2eFirmwareInfo.java | 120
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinatorSupplier.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/Transaction.java | 70
app/src/main/res/xml/devicesettings_display_caller.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendFitnessUserInfoRequest.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetSecurityNegotiationRequest.java | 65
app/src/main/res/xml/devicesettings_contacts.xml | 8
app/src/main/res/xml/devicesettings_weardirection.xml | 11
app/src/main/assets/ic_shoe_prints_many.svg | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStageSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/AndroidUtils.java | 355
gradle.properties | 25
app/src/main/res/layout/item_widget_screen.xml | 52
src/mainline/fastlane/metadata/android/en-US/changelogs/189.txt | 2
app/src/main/res/layout/qhybrid_app_view.xml | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendWeatherDeviceRequest.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/OneBytePerSamplePacketParser.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerTimeStylePebble.java | 171
app/src/main/res/drawable-hdpi/ic_systemapp.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStatsSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityAnalysis.java | 157
app/src/main/res/drawable/ic_drag_handle.xml | 10
app/src/main/res/xml/devicesettings_phone_calls_watch_pair.xml | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_79.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/MusicStateSpec.java | 91
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRespirationRate.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbip/AmazfitBipLiteSupport.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8active/MiBand8ActiveCoordinator.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/alarm/AlarmState.java | 29
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/TestHuaweiTLV.java | 532
app/src/main/res/xml/devicesettings_canned_reply_16.xml | 131
app/src/main/java/org/bouncycastle/shaded/crypto/Mac.java | 71
app/src/main/res/xml/devicesettings_sony_headphones_speak_to_chat_simple.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/music/MusicInfoSetRequest.java | 63
app/src/main/res/values-id/strings.xml | 3157
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardGoalsWidget.java | 177
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueueTest.java | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/widget/Widget.java | 139
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsCollectionFragment.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/ExtendedAsciiTransliterator.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/hplus/HPlusDataRecordSleep.java | 145
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/LefunDeviceSupport.java | 1036
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniCoordinator.java | 93
app/src/main/res/xml/devicesettings_reserve_alarms_calendar.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband2/Mi2TextNotificationStrategy.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSleepTimeSampleProvider.java | 56
app/src/main/assets/app_config/js/gadgetbridge_boilerplate.js | 297
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchHeartRateRestingOperation.java | 119
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfPreferences.java | 391
app/src/nightly/res/drawable-xxxhdpi/ic_launcher.png | 0
src/mainline/fastlane/metadata/android/en-US/changelogs/125.txt | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerMisfit.java | 129
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/control/CommandCode.java | 31
external/patches/jerryscript-gcc-12-build-fix.patch | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractListActivity.java | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetType.java | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProFWInstallHandler.java | 49
app/src/main/res/drawable/ic_activity_biking.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/WriteAction.java | 74
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetConnectStatusRequest.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/util/IntFormat.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GFDIMessage.java | 221
app/src/main/res/layout/custom_chart_marker.xml | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2FWInstallHandler.java | 49
app/src/main/res/values-de/strings.xml | 3414
app/src/main/assets/migrations/devicetype.json | 122
app/src/main/assets/fossil_hr/icWthPartCloudyNite.rle | 2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner245MusicCoordinator.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitfalcon/AmazfitFalconCoordinator.java | 71
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommand.java | 125
src/mainline/fastlane/metadata/android/en-US/changelogs/153.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendNotifyHeartRateCapabilityRequest.java | 56
app/src/main/java/lineageos/weatherservice/ServiceRequest.java | 161
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBZipFile.java | 131
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/jyou/BFH16DeviceSupport.java | 552
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSpo2SampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qc35/QC35BaseSupport.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetSleepDataRequest.java | 97
app/src/main/res/menu/menu_garmin_realtime_settings.xml | 17
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchultimate/HuaweiWatchUltimateCoordinator.java | 52
app/src/main/res/xml/devicesettings_sony_headphones_surround_mode.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SleepState.java | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationProvider.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/IconHelper.java | 71
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/Cmd22Command.java | 83
app/src/main/res/drawable/ic_action_find_lost_device.xml | 10
app/src/main/res/drawable/ic_graphic_eq.xml | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleProtocol.java | 2723
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java | 297
app/src/main/res/xml/dashboard_preferences.xml | 137
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5FWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetDeviceBusyAction.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/asteroidos/AsteroidOSDeviceSupport.java | 291
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/um25/Support/UM25BaseSupport.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSFWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendHeartRateZonesConfig.java | 57
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/GattCharacteristic.java | 414
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Contacts.java | 93
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupport.java | 142
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ShowDurationDialog.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lenovo/operations/InitOperation.java | 92
src/mainline/fastlane/metadata/android/en-US/changelogs/140.txt | 4
app/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatch3/HuaweiWatch3Coordinator.java | 47
app/src/main/res/xml/devicesettings_amazfitgtsgtr.xml | 13
app/src/main/res/drawable/ic_filter_9_plus.xml | 10
app/src/main/res/xml/devicesettings_amazfitneo_display.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/.gitignore | 2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java | 1299
app/src/main/assets/olive_laurel.svg | 340
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfittrexpro/AmazfitTRexProFirmwareInfo.java | 113
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleSupport.java | 263
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceRand.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/GBDeviceProtocol.java | 187
app/src/main/res/drawable/ic_bluetooth.xml | 10
app/src/main/res/xml/devicesettings_pairingkey.xml | 11
app/src/main/res/xml/devicesettings_sony_headphones_sound_position.xml | 11
app/src/main/res/xml/devicesettings_huami2021_displayitems.xml | 13
app/src/main/assets/activity_stacked.svg | 102
app/src/main/res/xml/devicesettings_mijia_lywsd.xml | 45
app/src/main/res/layout/simple_spinner_item_themed.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/supercars/SuperCarsSupport.java | 224
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPaiSample.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgts2/AmazfitGTS2MiniFirmwareInfo.java | 120
.gitea/issue_template/feature_request.yml | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteFWHelper.java | 40
app/src/main/res/xml/devicesettings_bt_connected_advertisement.xml | 10
app/src/main/res/drawable-xhdpi/ic_notification_qhybrid.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/contentprovider/PebbleContentProvider.java | 117
external/pack.py | 124
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/configuration/ConfigurationPutRequest.java | 529
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/update/UpdateFirmwareOperation.java | 293
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/TimeSampleProvider.java | 99
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/RealtimeSamplesSupport.java | 125
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerActivity.java | 217
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetNavigateOnRotateRequest.java | 60
app/src/main/res/drawable/ic_message_outline.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java | 277
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/BatteryInfo.java | 82
app/src/main/res/drawable/ic_emergency.xml | 12
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/ReleaseHandsControlRequest.java | 45
app/src/main/res/layout/fragment_welcome_permissions.xml | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceConst.java | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/ServerResponseAction.java | 71
src/mainline/fastlane/metadata/android/en-US/changelogs/116.txt | 2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/marstek/MarstekB2500DeviceSupport.java | 346
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLClassicDeviceCoordinator.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteCoordinator.java | 136
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ECDH_B163.java | 515
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/protobuf/messagefields/StringMessageField.java | 40
src/mainline/fastlane/metadata/android/en-US/changelogs/214.txt | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/WorkoutScreenListHandler.java | 65
app/src/main/res/drawable/ic_radio.xml | 10
app/src/main/res/values-en-rGB/strings.xml | 1580
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiVibrationPatternNotificationType.java | 45
app/src/main/res/drawable/ic_star_gold.xml | 9
app/src/main/res/drawable/ic_wifi_tethering.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandService.java | 275
app/src/main/res/drawable-xxhdpi/ic_watchapp.png | 0
app/src/main/res/xml/devicesettings_wena3_auto_power_off.xml | 34
app/src/main/res/xml/devicesettings_zetime_date_format.xml | 15
app/src/main/proto/garmin/gdi_sms_notification.proto | 50
app/src/main/res/values/dimens.xml | 19
app/src/main/res/layout/activity_android_pairing.xml | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR06Coordinator.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeJFCoordinator.java | 186
app/src/main/assets/ic_device_vesc_disabled.svg | 199
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetWorkoutSwimSegmentsRequest.java | 102
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/XiaomiSimpleDataEntry.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/NotificationAttribute.java | 88
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AmbientSoundControlButtonMode.java | 63
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2Coordinator.java | 158
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiEphemerisManager.java | 563
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreference.java | 114
app/src/main/assets/nothing_ear_l.svg | 73
app/src/nopebble/res/mipmap-hdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLFWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/GetNotificationAttributesResponse.java | 57
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFC700NCoordinator.java | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsSteelHRSampleProvider.java | 98
app/src/main/res/drawable/ic_activity_sleep.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerRealWeather.java | 146
app/src/main/res/layout/fragment_heart_rate.xml | 132
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java | 4358
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSConstants.java | 60
app/src/main/res/xml/devicesettings_casio_connection_duration.xml | 11
app/src/main/res/drawable-xxhdpi/ic_notification_low_battery.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/misfit/MisfitWatchAdapter.java | 510
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/STM32CRC.java | 180
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminHttpRequest.java | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitband5/AmazfitBand5Support.java | 47
app/src/main/res/values-lv/strings.xml | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileDownloadService0A.java | 279
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AppBlacklistActivity.java | 86
src/mainline/fastlane/metadata/android/en-US/changelogs/172.txt | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiIcon.java | 147
app/src/main/aidl/net/osmand/aidlapi/navigation/ANavigationVoiceRouterMessageParams.aidl | 3
app/src/main/res/drawable/ic_device_scannable_disabled.xml | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/CurrentTimeRequestMessage.java | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAppAdapter.java | 175
app/src/main/res/layout/layout_widget_duration_selector.xml | 161
app/src/main/res/drawable/splash.xml | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceApp.java | 163
app/src/main/res/drawable/brand_speedtest.xml | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileTransferHandler.java | 382
app/src/main/res/layout/fragment_weightchart.xml | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/WidgetSettingsActivity.java | 220
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/CasioSupport.java | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/galaxy_buds/GalaxyBudsIOThread.java | 68
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DailyTotals.java | 207
app/src/main/res/drawable/ic_wb_sunny.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HrvValueSample.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ConfigurationMessage.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/VoiceNotifications.java | 46
app/src/test/resources/GPXExporterTest-SampleTracks.csv | 14
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsDisplayItemsService.java | 510
app/src/main/res/drawable/ic_filter_alt.xml | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardPreferencesActivity.java | 104
app/src/nightly/res/drawable-hdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch5active/RedmiWatch5ActiveCoordinator.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AlarmReceiver.java | 113
app/src/main/res/values-fr-rCA/strings.xml | 895
app/src/main/res/xml/devicesettings_zetime_health.xml | 139
app/src/main/java/org/bouncycastle/shaded/crypto/params/AEADParameters.java | 61
src/mainline/fastlane/metadata/android/en-US/changelogs/185.txt | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/RecordDefinition.java | 126
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiFWHelper.java | 109
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband7/HuaweiBand7Coordinator.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/UpdateFirmwareOperation.java | 490
app/src/main/aidl/lineageos/weather/WeatherInfo.aidl | 19
app/src/main/res/values-ko/strings.xml | 768
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractHeadphoneDeviceSupport.java | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenDetailsActivity.java | 280
app/src/test/resources/gpx.xsd | 784
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeByte.java | 59
app/src/nightly/res/mipmap-mdpi/ic_launcher_foreground.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivosport/GarminVivosportCoordinator.java | 18
app/src/main/res/anim/flyleft.xml | 8
app/src/main/res/drawable/widget_preview.png | 0
app/src/main/assets/fossil_hr/openSourceWatchface.bin | 0
app/src/main/res/drawable/ic_activity_billiard_pool.xml | 15
app/src/main/java/net/osmand/aidlapi/navigation/OnVoiceNavigationParams.java | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepStageSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventBatteryInfo.java | 39
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/TestFitnessData.java | 502
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporterTest.java | 124
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java | 242
app/src/main/res/xml/devicesettings_ble_api.xml | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestTemperatureSampleProvider.java | 87
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityEntry.java | 78
app/src/main/assets/ic_device_unknown.svg | 144
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/P2P.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiInstallHandler.java | 93
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgts2/AmazfitGTS2FirmwareInfo.java | 120
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsWatchfaceService.java | 266
app/src/main/java/org/bouncycastle/shaded/crypto/modes/AEADBlockCipher.java | 17
app/src/main/java/org/bouncycastle/shaded/crypto/DefaultMultiBlockCipher.java | 33
app/src/main/res/anim/bounceleft.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSCoordinator.java | 165
app/src/main/res/layout/layout_widget_duration.xml | 47
src/mainline/fastlane/metadata/android/en-US/changelogs/157.txt | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/DatalogSession.java | 95
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitDeviceInfo.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/file/FileEncryptedLookupAndGetRequest.java | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothConnectReceiver.java | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweibandaw70/HuaweiBandAw70Coordinator.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepStats.java | 107
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivitySampleUnknown.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionTemperature.java | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetActivityDataCommand.java | 108
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/TimeCommand.java | 148
src/mainline/fastlane/metadata/android/en-US/changelogs/129.txt | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/GetStepGoalRequest.java | 45
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/LoggingTest.java | 82
.woodpecker/run_lint.yml | 19
app/src/main/res/xml/devicesettings_garmin_realtime_settings.xml | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/Probe.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventNotificationControl.java | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenControlActivity.java | 105
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetMusicStatusRequest.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSleepRespiratoryRateSampleProvider.java | 56
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/71-SportActivitiesFilter.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SampleXLabelFormatter.java | 53
src/mainline/fastlane/metadata/android/en-US/short_description.txt | 1
app/src/main/res/drawable/ic_activity_basketball.xml | 10
app/src/main/res/layout/fragment_live_activity.xml | 45
app/src/main/res/drawable-mdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/parameter/SensorType.java | 34
app/src/main/res/xml/devicesettings_root_notifications.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/TestMi1AFirmwareInfo.java | 92
app/src/main/res/layout/dashboard_widget_generic_gauge.xml | 43
app/src/mainline/res/values/strings.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaManager.java | 128
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9DeviceCoordinator.java | 181
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractChartsActivity.java | 361
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/10-Dashboard.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java | 608
src/banglejs/fastlane/metadata/android/en-US/short_description.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3Constants.java | 338
app/src/main/res/xml/devicesettings_volume.xml | 9
app/src/main/res/menu/musicmanager_context.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerHealthify.java | 85
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/TestMusicControl.java | 389
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/TestFindPhone.java | 120
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationType.java | 172
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/IcelandicTransliterator.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiStressSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/heartrate/BodySensorLocation.java | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/GattListenerAction.java | 21
app/src/main/assets/fossil_hr/widgetDate.bin | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Weather.java | 829
app/src/main/res/drawable/ic_device_sony_wf_800n.xml | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/GattService.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java | 518
app/src/main/assets/ic_device_supercars.svg | 134
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/notification/PlayCallNotificationRequest.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/receivers/GBMusicControlReceiver.java | 220
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendExtendedAccountRequest.java | 65
app/src/main/res/drawable-xxhdpi/ic_notification_qhybrid.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBuds2ProDeviceCoordinator.java | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUCoordinator.java | 192
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2FWHelper.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerM7S.java | 162
app/src/main/res/values-hi/strings.xml | 162
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSupport.java | 760
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/common/SimpleNotification.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/NotificationControlStatusMessage.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AboutUserPreferencesActivity.java | 117
app/src/main/res/xml/devicesettings_header_calendar.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btclassic/BtClassicIoThread.java | 214
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/EndOfTransmission.java | 44
app/src/main/res/drawable/ic_device_itag_disabled.xml | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java | 103
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM3Coordinator.java | 81
app/src/main/res/drawable-hdpi/ic_notification_disconnected_qhybrid.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gbx100/FetchStepCountDataOperation.java | 310
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/activity/ActivitySyncTimePacketTypeA.java | 27
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/ArmenianTransliteratorTest.java | 168
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchConstants.java | 79
app/src/nopebble/res/mipmap-xxhdpi/ic_launcher_round.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2XSolarCoordinator.java | 19
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1SampleProvider.java | 84
app/src/main/java/org/bouncycastle/shaded/util/Strings.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSNotification.java | 176
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/liberty/SoundcoreLibertyProtocol.java | 348
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentGetStarted.java | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR02Coordinator.java | 38
app/src/main/res/drawable/ic_smart_button.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWidgetPosition.java | 27
app/src/main/res/xml/devicesettings_headphones.xml | 32
app/src/main/proto/garmin/gdi_settings_service.proto | 228
app/src/main/res/xml/devicesettings_buttonactions_with_longpress.xml | 102
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java | 547
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM3Coordinator.java | 59
app/src/main/java/org/bouncycastle/shaded/crypto/modes/CBCModeCipher.java | 15
app/src/main/res/drawable/ic_led_color.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStepsWidget.java | 63
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ChallengeResponse.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AdaptiveVolumeControl.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/alarm/AlarmRepeat.java | 69
app/src/main/res/drawable/ic_extension.xml | 10
app/src/main/res/xml/devicesettings_active_noise_cancelling_toggle.xml | 10
src/mainline/fastlane/metadata/android/en-US/changelogs/191.txt | 10
.gitea/issue_template/device_request.yml | 79
app/src/main/assets/womp/orders.html | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiStressSampleProvider.java | 66
app/src/main/res/layout/activity_charts_durationdialog.xml | 13
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/TestLocaleConfig.java | 87
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/MessageBuilderTest.java | 151
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsPreferencesActivity.java | 131
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/AlarmUtils.java | 194
app/src/main/res/drawable-hdpi/ic_notification_disconnected.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/AbstractBleProfile.java | 115
app/src/main/res/layout/preference_material_switch.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FieldDefinition.java | 93
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Menstrual.java | 75
src/mainline/fastlane/metadata/android/en-US/changelogs/112.txt | 4
app/src/main/res/drawable/ic_device_headphones_disabled.xml | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/GetPpgDataRequest.java | 85
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/BinarySensorSupport.java | 203
app/src/main/res/layout/activity_withings_calibration.xml | 58
app/src/main/res/drawable-xhdpi/ic_systemapp.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepScoreWidget.java | 105
app/src/main/res/values/colors.xml | 90
app/src/main/assets/fossil_hr/icBattery.rle | 1
app/src/main/res/drawable/ic_widgets.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive5Coordinator.java | 18
app/src/main/res/layout/activity_idasen_control.xml | 122
app/src/main/aidl/net/osmand/aidlapi/navigation/OnVoiceNavigationParams.aidl | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/AbstractSendNotificationRequest.java | 99
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/application/ApplicationsListRequest.java | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/FunctionAction.java | 47
app/src/main/res/xml/devicesettings_amazfitbips.xml | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_44.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/NotificationFilterActivity.java | 233
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/PebbleUtilsTest.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/Request.java | 377
src/mainline/fastlane/metadata/android/en-US/changelogs/159.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/GetActivitySamples.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/PaiSample.java | 35
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ProbeReplyTest.java | 32
app/src/main/res/xml/devicesettings_root_touch_options.xml | 9
app/src/main/res/drawable/ic_filter_2.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/PersianTransliterator.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2Coordinator.java | 152
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/LocaleConfig.java | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetTruSleepRequest.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetWorkoutTotalsRequest.java | 141
app/src/main/res/drawable/ic_activity_free_training.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTemperatureSample.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupExportJob.java | 249
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummarySimpleEntry.java | 57
app/src/main/res/drawable/ic_activity_swimming.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/file/FileHandle.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/StopNotificationRequest.java | 50
app/src/main/res/drawable/ic_firmware.xml | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/Transliterator.java | 21
app/src/main/assets/activity.svg | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/CurrentPosition.java | 93
app/src/main/res/drawable/ic_pause.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/ActivityPacketCrc.java | 90
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceManager.java | 196
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/SonyTestUtils.java | 121
app/src/main/res/drawable/ic_paint.xml | 12
app/src/main/res/drawable-xhdpi/ic_notification_full_battery.png | 0
app/src/main/res/drawable/ic_gps_edit.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/MultiTransliterator.java | 42
app/src/main/assets/ic_distance.svg | 81
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/AbstractEarCoordinator.java | 103
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSFWInstallHandler.java | 49
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/HuamiActivityDetailsParserTest.java | 107
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SpinnerWithIconAdapter.java | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/womp/WompJsBridge.java | 116
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbip/AmazfitBipSupport.java | 93
app/src/main/res/drawable/ic_device_hplus.xml | 34
app/src/main/res/drawable-mdpi/ic_notification_disconnected.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetBondRequest.java | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/GetTripleTapEnabledRequest.java | 26
app/src/main/res/xml/devicesettings_liftwrist_display_noshed.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetStepsDataCommand.java | 84
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppECoordinator.java | 143
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilFileReader.java | 328
app/src/main/res/drawable/ic_activity_frisbee.xml | 14
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiWeatherService.java | 651
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilInstallHandler.java | 111
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/MiBandSupport.java | 1287
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchSpo2NormalOperation.java | 128
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetAppNames.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband7pro/MiBand7ProCoordinator.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/BodyPropertiesSetting.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/calendar/CalendarEntry.java | 81
src/mainline/fastlane/metadata/android/en-US/changelogs/177.txt | 2
app/src/main/res/xml/devicesettings_expose_hr_thirdparty.xml | 10
.gitattributes | 17
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ChecksumCalculator.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiChannelHandler.java | 30
app/src/main/res/drawable/ic_alert_circle_outline.xml | 9
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesProtocolTest.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_26.java | 43
app/src/main/res/drawable/ic_device_amazfit_bip.xml | 26
app/src/main/res/xml/devicesettings_hplus.xml | 18
app/src/main/res/drawable/ic_no_watch.xml | 15
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateDialog.java | 153
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetBatteryLevelRequest.java | 99
app/src/main/res/values-ta/strings.xml | 2
app/src/main/res/xml/devicesettings_miband5.xml | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/notification/NotificationFilterGetRequest.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/TurkishTransliterator.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nothing/NothingIOThread.java | 69
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridSettingsCustomizer.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/CreateFileMessage.java | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/MultiFetchRequest.java | 110
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/Message.java | 213
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminFitFileInstallHandler.java | 231
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TemperatureSample.java | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/async/ConfirmAppStatusRequest.java | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/dialogs/MaterialDialogFragment.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendEphemerisOperatorResponse.java | 27
src/mainline/fastlane/metadata/android/en-US/changelogs/130.txt | 4
app/src/main/res/drawable/ic_activity_sailing.xml | 12
app/src/main/res/mipmap-mdpi/ic_launcher_round.png | 0
src/mainline/fastlane/metadata/android/en-US/changelogs/173.txt | 14
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ImageMetaData.java | 66
app/src/main/res/drawable/ic_notifications.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbips/AmazfitBipSLiteFirmwareInfo.java | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeatherSpec.java | 505
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/TeclastH30/TeclastH30Coordinator.java | 184
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetSupportedServicesRequest.java | 68
app/src/main/res/xml/devicesettings_wearmode.xml | 12
app/src/main/assets/fossil_hr/widgetCustom.bin | 0
app/src/nightly/res/mipmap-anydpi-v26/ic_launcher_round.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFC500Coordinator.java | 59
app/src/main/res/drawable/ic_u_turn_left.xml | 10
app/src/nopebble/res/mipmap-anydpi-v26/ic_launcher_round.xml | 5
app/src/main/res/xml/devicesettings_nutmini.xml | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlEntityUpdateMessage.java | 112
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetEventAlarmList.java | 109
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHeartRateSample.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchPro2Coordinator.java | 54
app/src/main/res/drawable/list_selector.xml | 5
app/src/main/res/layout/activity_qhybrid_apps_management.xml | 17
app/src/main/res/drawable/ic_battery_unknown.xml | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/ecbs100/CasioECBS100DeviceCoordinator.java | 67
app/src/main/java/org/bouncycastle/shaded/crypto/CipherParameters.java | 8
src/mainline/fastlane/metadata/android/en-US/changelogs/182.txt | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java | 601
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java | 587
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/SetEnabledFeaturesRequest.java | 61
app/src/main/res/drawable/ic_activity_hula_hoop.xml | 14
app/src/main/res/drawable/ic_activity_windsurfing.xml | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/file/FileEncryptedPutRequest.java | 279
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DashboardUtils.java | 235
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiMenuType.java | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/InstallHandler.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/LiveWorkoutStart.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java | 369
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qc35/QC35Protocol.java | 92
src/mainline/fastlane/metadata/android/en-US/changelogs/103.txt | 6
app/src/main/java/lineageos/weather/RequestInfo.java | 379
app/src/main/res/drawable/ic_device_unknown.xml | 22
app/src/main/res/drawable/ic_device_pinetime.xml | 66
app/src/main/assets/buds_pro_left.svg | 67
app/src/main/res/menu/activity_take_screenshot_menu.xml | 78
app/src/main/res/menu/fragment_devices_device_submenu.xml | 25
app/src/main/java/net/osmand/aidlapi/gpx/AGpxBitmap.java | 49
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/BLETypeConversionsTest.java | 218
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/UiPagesCommand.java | 90
app/src/main/res/drawable-mdpi/ic_notification_disconnected_qhybrid.png | 0
app/src/main/res/xml/devicesettings_temperature_automatic_enable.xml | 9
app/src/main/res/drawable/ic_activity_indoor_running.xml | 14
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfWorkoutGpsSampleProvider.java | 56
app/src/main/res/xml/devicesettings_hourly_chime.xml | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SolarCoordinator.java | 19
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileUpload.java | 239
app/src/banglejs/res/values/strings.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/status/NotificationServiceStatusRequest.java | 26
app/src/main/res/xml/devicesettings_device_card_activity_card_preferences.xml | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/AbstractMiFirmwareInfo.java | 141
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProCoordinator.java | 183
src/mainline/fastlane/metadata/android/en-US/changelogs/204.txt | 9
src/mainline/fastlane/metadata/android/it-IT/title.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/um25/Coordinator/UM25Coordinator.java | 120
app/src/main/res/xml/devicesettings_buttonactions_lower_short.xml | 11
app/src/main/res/drawable/ic_power_settings_new.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/UnknownDeviceCoordinator.java | 177
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/CmfBudsPro2Coordinator.java | 43
app/src/main/res/drawable/ic_drink.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsPeriodFragment.java | 231
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiPacket.java | 1006
app/src/main/res/drawable/ic_buds_pro_right.xml | 17
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/DevFieldDefinition.java | 60
app/src/main/java/org/bouncycastle/shaded/crypto/BlockCipher.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendMenstrualCapabilityRequest.java | 56
src/mainline/fastlane/metadata/android/en-US/changelogs/168.txt | 2
app/src/nightly/res/mipmap-xxxhdpi/ic_launcher_round.png | 0
app/src/main/res/layout/layout_widget_distance.xml | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xConstants.java | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetMediumToStrengthThresholdRequest.java | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerMorpheuz.java | 185
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileDownloadService2C.java | 223
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceCandidate.java | 248
app/src/main/res/xml/devicesettings_root_date_time.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSkinTempRaw.java | 32
app/src/main/assets/fossil_hr/widgetSpO2.bin | 0
app/src/main/res/layout/sort_list_array_dialog_preference.xml | 17
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/EmojiConverterTest.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSpo2.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetHiChainRequest.java | 256
app/src/main/res/xml/devicesettings_text_bitmaps.xml | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitAsyncProcessor.java | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/parameter/SensorState.java | 48
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/MockSonyCoordinator.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProCoordinator.java | 191
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch2/RedmiWatch2Coordinator.java | 49
app/src/main/res/layout/fragment_respiratory_rate_period.xml | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/AbstractZeppOsService.java | 130
app/src/main/res/xml/devicesettings_garmin_agps.xml | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/GpxParseException.java | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/util/UIntBitReader.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gbx100/CasioGBX100DeviceSupport.java | 727
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitDeveloperData.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyLinkBudsSCoordinator.java | 72
app/src/main/res/drawable/ic_device_watchxplus_disabled.xml | 39
app/src/nightly/res/mipmap-hdpi/ic_launcher_foreground.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsProDeviceCoordinator.java | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractGattCallback.java | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/no1f1/No1F1Support.java | 602
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/HuamiFetchDataType.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/hplus/HPlusDataRecordDaySummary.java | 117
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/DatalogSessionHealthOverlayData.java | 127
app/src/main/res/values/ic_launcher_background.xml | 4
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/MiscTest.java | 14
app/src/main/assets/fossil_hr/image_layout.json | 1
app/src/main/res/xml/devicesettings_huami2021_control_center.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenListAdapter.java | 124
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt2e/HuaweiWatchGT2eCoordinator.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CameraActivity.java | 218
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java | 1260
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8pro/MiBand8ProCoordinator.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/marstek/MarstekB2500DeviceCoordinator.java | 102
app/src/main/res/layout/fragment_womp_workbench.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/WithingsMessageType.java | 68
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbip3/AmazfitBip3FirmwareInfo.java | 82
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppPacketV1.java | 331
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/SingleAlarmSetting.java | 64
app/src/main/assets/womp/recycle.html | 152
app/src/main/res/xml/devicesettings_wifi_hotspot.xml | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleProvider.java | 399
app/src/main/res/drawable/ic_font_size.xml | 5
app/src/main/assets/fossil_hr/icWthStormy.rle | 2
app/src/main/res/layout/item_file_manager.xml | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsd02Coordinator.java | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/mijia_lywsd/MijiaLywsdSupport.java | 329
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceSettingsActivity.java | 207
app/src/main/java/org/bouncycastle/shaded/crypto/SkippingCipher.java | 31
app/src/main/java/com/mobeta/android/dslv/DragSortItemViewCheckable.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetWorkoutDataRequest.java | 150
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/widget/WidgetsPutRequest.java | 55
gradle/wrapper/gradle-wrapper.properties | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractActivitySample.java | 127
app/src/main/res/drawable/ic_battery_20.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/CasioConstants.java | 154
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionHrTimeInZone.java | 10
app/src/main/res/xml/devicesettings_timeformat.xml | 12
app/src/main/res/drawable/ic_activity_ice_skating.xml | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunConstants.java | 86
app/src/main/res/xml/devicesettings_inactivity_sheduled.xml | 74
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendFactoryResetRequest.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/AppsManagementActivity.java | 162
app/src/main/res/layout/activity_alarm_details.xml | 170
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteCoordinator.java | 98
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusWeatherCode.java | 197
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetSleepDataCommand.java | 100
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java | 300
app/src/main/assets/fossil_hr/widgetSpO2_preview.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetContactsCount.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiDictTypes.java | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteFWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdSettingsCustomizer.java | 95
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutConstants.java | 113
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java | 146
app/src/main/res/drawable/ic_device_galaxy_buds_pro_disabled.xml | 36
src/mainline/fastlane/metadata/android/en-US/changelogs/195.txt | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCaloriesActiveGoalWidget.java | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gb6900/CasioGB6900DeviceSupport.java | 724
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsCoordinator.java | 629
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/battery/BatteryInfoProfile.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusDeviceCoordinator.java | 319
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureWorldClocks.java | 220
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsAgpsInstallHandler.java | 138
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/TimePicker.java | 297
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppEFWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/CameraRemote.java | 110
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsAppsService.java | 225
app/src/main/res/xml/devicesettings_heartrate_sleep.xml | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipCoordinator.java | 142
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/Cmd25Command.java | 85
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/motion300/SoundcoreMotion300DeviceSupport.java | 43
app/src/main/res/layout/activity_dashboard_calendar.xml | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Calls.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsCalibrationActivity.java | 159
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Alarm.java | 64
app/src/main/res/drawable/ic_delete_forever.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityAmounts.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WorldClock.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cycling_sensor/support/CyclingSensorBaseSupport.java | 26
app/src/main/res/layout/activity_main_app_bar.xml | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgtr2/AmazfitGTR2eFirmwareInfo.java | 120
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/SetDeviceStateAction.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsNotificationService.java | 600
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexFWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiManualSampleProvider.java | 125
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceSpecificSettingsCustomizer.java | 134
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115Coordinator.java | 179
app/src/main/assets/ic_device_unknown_disabled.svg | 197
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgtr/AmazfitGTRFirmwareInfo.java | 120
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/NestedFragmentAdapter.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR09Coordinator.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiAuthService.java | 395
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SleepAlarmWidgetConfigurationActivity.java | 154
app/src/main/res/xml/devicesettings_sony_headphones_device_info.xml | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBAccess.java | 71
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/SetHydrationReminderIntervalRequest.java | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/LiveWorkoutEnd.java | 50
app/src/main/res/drawable/ic_show_chart.xml | 11
app/src/test/resources/gpx-exporter-test-SampleTrack.gpx | 73
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2SCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractGBX100ActivitySample.java | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBTarFile.java | 79
src/mainline/fastlane/metadata/android/en-US/changelogs/107.txt | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ChunkedDecoder.java | 148
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiLESupport.java | 228
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRFWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/hama/fit6900/HamaFit6900DeviceSupport.java | 633
app/src/main/res/drawable/ic_activity_trail_run.xml | 10
app/src/main/res/layout/sport_activity_filter.xml | 244
app/src/main/res/drawable/ic_warning_gray.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorwatchgspro/HonorWatchGSProCoordinator.java | 51
app/src/main/res/drawable/ic_menu.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateFragment.java | 155
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5Coordinator.java | 18
app/src/main/res/drawable/ic_device_pebble.xml | 42
app/src/main/res/drawable/ic_activity_unknown_small.xml | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/CasioDeviceCoordinator.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBand2SampleProvider.java | 101
app/src/main/assets/ic_activity_rope_jump.svg | 90
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleIoThread.java | 767
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsRemindersService.java | 379
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/deviceevents/SonyHeadphonesEnqueueRequestEvent.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahround/AmazfitCheetahRoundCoordinator.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java | 959
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBuds2DeviceCoordinator.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_80.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractStressSample.java | 45
app/src/main/res/drawable-xhdpi/ic_launcher.png | 0
app/src/nightly/res/mipmap-xhdpi/ic_launcher.png | 0
app/src/main/assets/ic_device_galaxy_buds_live_disabled.svg | 218
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/.gitignore | 2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/SetProfileRequest.java | 72
app/src/main/res/xml/devicesettings_buttonactions_upper_long.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/protobuf/messagefields/VarintMessageField.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSleepStageSampleProvider.java | 56
app/src/main/res/xml/devicesettings_liftwrist_display_no_on.xml | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsWeather.java | 813
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiConstants.java | 99
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendAccountRequest.java | 56
app/src/main/res/xml/loyalty_cards.xml | 89
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiFWHelper.java | 368
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/VivitarHrBpMonitorActivityTrackerCoordinator.java | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfDataUploader.java | 198
app/src/main/res/layout/activity_appmanager.xml | 14
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporterTest.java | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/IncomingMessageHandler.java | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/workout/CmfWorkoutSummaryParser.java | 74
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/ExpectedResponse.java | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/FlattenToAsciiTransliterator.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/AncsConstants.java | 45
app/src/main/java/org/bouncycastle/shaded/crypto/modes/AEADCipher.java | 139
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetWorkModeRequest.java | 56
app/src/nightly/res/values/strings.xml | 8
app/src/main/assets/ic_galaxy_buds_live_r.svg | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlCapabilitiesMessage.java | 48
app/src/main/res/xml/devicesettings_power_mode.xml | 11
app/src/main/assets/fossil_hr/widgetCalories_preview.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitWorkout.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/NotificationStrategy.java | 46
app/src/main/res/drawable/ic_calendar_sync.xml | 14
app/src/main/aidl/net/osmand/aidlapi/search/SearchResult.aidl | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTimeSample.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/notification/defines/VibrationOptions.java | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetPauseWhenRemovedFromEarRequest.java | 31
app/src/main/res/drawable/ic_sony_wf_800n_case.xml | 17
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBFragment.java | 74
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/ProtobufTest.java | 129
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureReminders.java | 210
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionGoalType.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/activity/WithingsActivityType.java | 168
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/GreekTransliterator.java | 36
app/src/main/res/color/alarm_dow.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7ProCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/redmibuds5pro/protocol/Authentication.java | 181
app/src/main/java/com/mobeta/android/dslv/SimpleDragSortCursorAdapter.java | 427
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/waspos/WaspOSDeviceSupport.java | 406
app/src/main/res/xml/notifications_preferences.xml | 213
app/src/main/assets/fossil_hr/icWthSnowy.rle | 1
app/src/main/lint.xml | 6
app/src/main/res/drawable-mdpi/ic_notification_full_battery.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivitySync.java | 668
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/hplus/HPlusDataRecordDaySlot.java | 122
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/GetSleepDataRequest.java | 85
src/banglejs/fastlane/metadata/android/en-US/images/icon.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/AlarmName.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/DeviceInfo.java | 155
GBDaoGenerator/.gitignore | 2
app/src/main/res/xml/devicesettings_overwrite_settings_on_connection.xml | 10
app/src/main/assets/ic_activity_eliptical.svg | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband3/MiBand3FirmwareInfo.java | 127
app/src/main/res/drawable-xhdpi/ic_watchapp.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerSquare.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProFWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8/MiBand8Coordinator.java | 49
app/src/main/res/xml/devicesettings_sony_headphones_notifications_voice_guide.xml | 9
app/src/main/res/mipmap-xxhdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Reminder.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgts2/AmazfitGTS2MiniSupport.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/DailyDetailsParser.java | 198
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/SonyHeadphonesSupport.java | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/FactoryResetRequest.java | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminBodyEnergySampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ProbeReply.java | 111
app/src/main/res/layout/fossil_hr_row_commute_action.xml | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProCoordinator.java | 377
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/defines/StatusPageId.java | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/RtlUtils.java | 612
app/src/main/res/layout/activity_charts.xml | 115
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingChartFragment.java | 244
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsLogsService.java | 224
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRSpo2SampleProvider.java | 56
app/src/main/res/layout/activity_app_specific_notification_settings.xml | 25
app/src/main/res/drawable/ic_folder.xml | 10
app/src/main/res/layout/activity_find_phone.xml | 14
app/src/main/res/drawable/ic_sensor_calibration.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSilentMode.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitHrvSummary.java | 63
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband6/HuaweiBand6Coordinator.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/connection/SetConnectionParametersRequest.java | 53
app/src/nightly/res/mipmap-mdpi/ic_launcher.png | 0
src/mainline/fastlane/metadata/android/en-US/changelogs/137.txt | 10
app/src/main/res/drawable/ic_vibration.xml | 10
src/mainline/fastlane/metadata/android/en-US/changelogs/222.txt | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/galaxy_buds/GalaxyBudsProtocol.java | 548
app/src/main/res/xml/devicesettings_pebble_developer.xml | 60
.gitmodules | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyFragment.java | 305
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeConstants.java | 156
app/src/main/res/values-hu/strings.xml | 2710
app/src/main/res/xml/devicesettings_header_other.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband7/AmazfitBand7Coordinator.java | 68
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/DebugRequest.java | 241
app/src/main/res/drawable/ic_arrow_circle_left.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutValueFormatter.java | 159
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/v1/SonyProtocolImplV1.java | 1340
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/DisconnectNotificationSetting.java | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_30.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_83.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionGoalSource.java | 48
app/src/main/res/drawable-xhdpi/ic_languagepack.png | 0
app/src/main/res/layout/fragment_body_energy.xml | 92
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbipu/AmazfitBipUFirmwareInfo.java | 85
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/providers/MockLocationProvider.java | 97
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweitalkbandb6/HuaweiTalkBandB6Coordinator.java | 46
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/FileUtilsTest.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/SleepAsAndroidSender.java | 573
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/AutoPowerOffSettings.java | 40
app/src/main/res/layout/item_world_clock.xml | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/status/StatusRequestType.java | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TrailingActivitySample.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/constants/ReportState.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/EqualizerCustomBands.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigSide.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/Locale.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3SettingsCustomizer.java | 80
app/src/main/res/xml/devicesettings_xiaomi_displayitems.xml | 13
src/mainline/fastlane/metadata/android/en-US/changelogs/121.txt | 1
src/mainline/fastlane/metadata/android/en-US/changelogs/164.txt | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/Mi1AFirmwareInfo.java | 52
app/src/main/res/drawable/ic_devices_other.xml | 10
app/src/main/res/drawable/ic_galaxy_buds_live_l.xml | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/domyos/DomyosT540Support.java | 314
app/src/main/res/drawable/ic_device_flipper.xml | 238
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/redmibuds5pro/protocol/Message.java | 124
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/calendar/CalendarEvent.java | 189
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/NotificationSubscriptionStatusMessage.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/LockHandler.java | 116
app/src/main/res/xml/widget_info.xml | 15
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXFWInstallHandler.java | 50
app/src/main/res/drawable/ic_create_new_folder.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitHrvValue.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/HeartRateHandler.java | 89
app/src/main/res/xml/devicesettings_fossilhybridhr_workout_detection.xml | 125
app/src/main/res/xml/devicesettings_galaxy_buds_pro.xml | 247
app/src/main/res/xml/devicesettings_gps_agps.xml | 71
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/parameter/ResultCode.java | 33
app/src/main/res/values-he/strings.xml | 3093
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband9pro/MiBand9ProCoordinator.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/image/ImageConverter.java | 170
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sonyswr12/SonySWR12SampleProvider.java | 99
src/mainline/fastlane/metadata/android/it-IT/full_description.txt | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/control/ControlPoint.java | 33
src/mainline/fastlane/metadata/android/en-US/changelogs/217.txt | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiWorkoutUtils.java | 48
src/mainline/fastlane/metadata/android/en-US/changelogs/160.txt | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationProviderType.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsHttpService.java | 139
app/src/main/res/drawable/ic_device_sony_overhead_disabled.xml | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/MakibesF68Coordinator.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppProtocolV1.java | 120
app/src/main/res/xml/devicesettings_lowlatency_fwupdate.xml | 10
app/src/main/assets/tws_bud.svg | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java | 279
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSConstants.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/SamplePacketParser.java | 98
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ActivitySummaryUtils.java | 87
app/src/main/res/xml/devicesettings_sony_headphones_anc_optimizer.xml | 31
app/src/main/res/drawable/ic_folder_delete.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetConfigurationActivity.java | 147
app/src/main/res/drawable-xhdpi/ic_notification_disconnected.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BodyEnergySample.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java | 89
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/TimeZoneSetting.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ProtobufMessage.java | 95
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/SetGeneralSettingsRequest.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DoNotDisturb.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/OppoCommand.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSportsActivityType.java | 151
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/Mi1FirmwareInfo.java | 52
app/src/main/res/drawable-hdpi/ic_notification_full_battery.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64Coordinator.java | 151
app/src/main/res/drawable-mdpi/ic_notification_low_battery.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendWeatherSupportRequest.java | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/status/Weather.java | 135
app/src/main/res/xml/devicesettings_sound_and_vibration.xml | 51
app/src/main/assets/fossil_hr/widget_bg_error.rle | 15
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/RequestHandControlRequest.java | 52
src/mainline/fastlane/metadata/android/en-US/changelogs/152.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchCalibrationActivity.java | 124
src/mainline/fastlane/metadata/android/en-US/changelogs/208.txt | 16
app/src/main/assets/ic_heartrate.svg | 85
.woodpecker/can_master_build.yml | 19
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/PermissionsActivity.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_15.java | 42
app/src/main/assets/ic_device_galaxy_buds_disabled.svg | 242
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5PlusCoordinator.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgtr/AmazfitGTRSupport.java | 69
app/src/main/res/layout/activity_list_item.xml | 289
app/src/main/res/android-logger.properties | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vesc/VescCoordinator.java | 184
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/parameter/ReportStatus.java | 34
app/src/main/res/drawable/ic_add.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java | 353
app/src/main/res/drawable/ic_nothing_ear_r.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/notification/NotificationImage.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/Casio2C2DSupport.java | 997
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitWatchfaceSettings.java | 32
app/src/main/res/drawable/ic_activity_rock_climbing.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/MoveHandsRequest.java | 136
src/mainline/fastlane/metadata/android/en-US/changelogs/98.txt | 9
app/build.gradle | 303
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/divoom/PixooSupport.java | 69
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/TestNotifications.java | 195
app/src/main/java/com/mobeta/android/dslv/DragSortListPreference.java | 185
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/authentication/AuthenticationRequest.java | 28
src/mainline/fastlane/metadata/android/en-US/changelogs/234.txt | 4
app/src/main/res/drawable/ic_heartrate.xml | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleMorpheuzActivitySample.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/operations/ZeppOsGpxRouteUploadOperation.java | 106
app/src/main/assets/womp/css.css | 34
app/src/main/res/drawable/ic_settings_applications.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/AbstractConversation.java | 107
app/src/main/res/layout/steps_streak_average.xml | 38
app/src/main/res/xml/devicesettings_screen_timeout.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/femometer/FemometerVinca2DeviceCoordinator.java | 140
app/src/main/java/org/bouncycastle/shaded/crypto/MultiBlockCipher.java | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java | 818
app/src/main/res/drawable/ic_donate.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/AbstractResponseHandler.java | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepAnalysis.java | 260
src/mainline/fastlane/metadata/android/en-US/changelogs/199.txt | 6
app/src/main/proto/garmin/gdi_core.proto | 112
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/EarStickCoordinator.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/HeadphoneHelper.java | 161
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceMusic.java | 33
app/src/main/res/values/arrays.xml | 4490
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractColmiActivitySample.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryItems.java | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/VibrationSetting.java | 40
app/src/main/assets/ic_device_sony_overhead_disabled.svg | 192
app/src/main/res/xml/devicesettings_nightmode.xml | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSCoordinator.java | 163
app/src/nopebble/res/mipmap-xxhdpi/ic_launcher_foreground.png | 0
settings.gradle | 1
app/src/main/res/xml/devicesettings_light_duration_longer.xml | 9
app/src/main/res/drawable/ic_map.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DefaultRestingMetabolicRateProvider.java | 190
app/src/main/res/drawable/ic_volume_up.xml | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miwatchcolorsport/MiWatchColorSportCoordinator.java | 50
app/src/main/aidl/lineageos/weather/IWeatherServiceProviderChangeListener.aidl | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestSampleProvider.java | 262
src/mainline/fastlane/metadata/android/en-US/changelogs/128.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RespiratoryRateSample.java | 24
app/src/main/res/xml/devicesettings_banglejs_activity.xml | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractGaugeWidget.java | 164
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/RequestedNotificationAttributeTest.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/FossilRequest.java | 34
app/src/main/res/xml/devicesettings_buttonactions.xml | 93
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetTimeRequest.java | 64
app/src/main/res/values-pt-rBR/strings.xml | 2542
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetWorkoutCountRequest.java | 90
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/defines/DisplayDesign.java | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiSystemService.java | 1005
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/activity/SleepActivitySampleHelper.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java | 217
src/mainline/fastlane/metadata/android/en-US/changelogs/134.txt | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsConfigService.java | 1850
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsFwHelper.java | 538
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryValue.java | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendEphemerisDataRequestResponse.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProSettingsCustomizer.java | 82
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceFactory.java | 408
app/src/main/res/xml/devicesettings_goal_secondary.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_72.java | 43
app/src/main/res/drawable/ic_activity_exercise.xml | 10
app/src/main/res/drawable/ic_battery_charging_full.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsCannedMessagesService.java | 240
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateUtils.java | 69
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr4/AmazfitGTR4Coordinator.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FindPhone.java | 62
app/src/main/res/xml/devicesettings_automatic_power_off_by_time.xml | 11
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationServiceTestCase.java | 135
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityParserTest.java | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/LineageOsWeatherReceiver.java | 229
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/jyou/RealtimeSamplesSupport.java | 111
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5XPlusCoordinator.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SapphireCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/AbstractHuamiOperation.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/init/InitOperation.java | 181
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nothing/Ear1Support.java | 58
app/src/main/res/xml/devicesettings_huawei_account.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoFWHelper.java | 41
app/src/main/res/values-ka/strings.xml | 190
app/src/test/resources/ActivityDetailsDump1.txt | 861
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/domyos/DomyosT540Coordinator.java | 155
app/src/main/res/drawable/ic_filter_alt_plus.xml | 4
app/src/banglejs/res/drawable/ic_launcher_foreground.xml | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuSqCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2FWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/files/FileManagerActivity.java | 135
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner55Coordinator.java | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesSettingsCustomizer.java | 152
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractRespiratoryRateSample.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivitySampleCalories2.java | 25
app/src/test/resources/gpx-parser-test-order.gpx | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/SG2Coordinator.java | 69
app/src/main/res/xml/devicesettings_donotdisturb_lift_wrist.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/alarm/AlarmsSetRequest.java | 88
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/AbortTransactionAction.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/DatalogSessionHealthSteps.java | 164
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/UploadRequestStatusMessage.java | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionDayOfWeek.java | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppe/ZeppESupport.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/SyncRequestHandler.java | 38
app/src/main/res/drawable/ic_share.xml | 10
app/src/main/res/xml/devicesettings_spo_automatic_enable.xml | 10
app/src/nightly/res/drawable-xhdpi/ic_launcher.png | 0
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/AbstractMessageTest.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/ProtobufStatusMessage.java | 141
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/50-StepsPerDay.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusConstants.java | 191
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitGpsMetadata.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/heartrate/HeartRateProfile.java | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/status/BatteryLevelInfo.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/UkranianTransliterator.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/StartPpgSensingCommand.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceFolder.java | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundcore/motion300/SoundcoreMotion300Protocol.java | 385
src/mainline/fastlane/metadata/android/en-US/changelogs/119.txt | 4
app/src/main/res/xml/devicesettings_header_apps.xml | 6
app/src/main/res/layout/activity_battery_info.xml | 95
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/CheckInitializedAction.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/notification/NotificationType.java | 37
app/src/main/res/layout/activity_qhybrid_app_choser.xml | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractPreferenceFragment.java | 259
app/src/main/res/xml/devicesettings_reconnect_bl_classic.xml | 12
app/src/main/assets/sony_800n.svg | 76
app/src/main/res/xml/devicesettings_header_notifications.xml | 6
src/mainline/fastlane/metadata/android/en-US/changelogs/147.txt | 6
app/src/main/res/drawable/side_nav_bar.xml | 9
app/src/main/res/xml/devicesettings_amazfittrexpro.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GpxRouteFileConverter.java | 196
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/roidmi/RoidmiProtocol.java | 93
app/src/main/res/xml/devicesettings_heartrate_sleep_activity.xml | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/GetEnabledFeaturesRequest.java | 55
app/src/main/res/drawable/ic_device_unknown_disabled.xml | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/defines/HomeIconId.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbip/AmazfitBipTextNotificationStrategy.java | 65
app/src/main/res/drawable/ic_wifi.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ValidByDate.java | 24
app/src/main/res/xml/sleepasandroid_preferences.xml | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitFileId.java | 63
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner265SCoordinator.java | 19
src/mainline/fastlane/metadata/android/en-US/changelogs/226.txt | 5
app/src/main/res/drawable/ic_archive.xml | 10
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/41-SleepPerWeek.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitPhysiologicalMetrics.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceMusicData.java | 15
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WriteAction.java | 82
app/src/main/res/drawable/ic_volume_off.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeActivity.java | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/HebrewTransliterator.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiExtendedSampleProvider.java | 144
app/src/main/res/xml/devicesettings_wearlocation.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/file/FileEncryptedInterface.java | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/XiaomiSimpleActivityParser.java | 253
GBDaoGenerator/build.gradle | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/DatalogSessionPebbleHealth.java | 48
app/src/main/res/drawable/ic_focus.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/RussianTransliterator.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeDouble.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/GBChromeClient.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WidgetPreferenceStorage.java | 181
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/update/UpdateFirmwareOperationNew.java | 100
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/GlyphId.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceSettingsReceiver.java | 110
app/src/main/res/xml/devicesettings_inactivity_dnd.xml | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/WorkoutSummaryParser.java | 879
app/src/main/java/lineageos/os/Concierge.java | 153
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitneo/AmazfitNeoSupport.java | 184
app/src/main/res/layout/activity_list_total_dashboard.xml | 87
app/src/main/res/layout/fragment_temperaturechart.xml | 19
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/music/MusicControlRequest.java | 92
app/src/main/res/drawable/ic_device_lovetoy_disabled.xml | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband9/HuaweiBand9Coordinator.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/activity/CyclingLiveDataActivity.java | 170
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchStressAutoOperation.java | 119
app/src/main/res/xml/devicesettings_widgets.xml | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/CheckAuthenticationNeededAction.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorCoordinator.java | 152
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchStatisticsOperation.java | 57
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2FWHelper.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventMusicControl.java | 35
app/src/main/res/drawable/ic_activity_archery.xml | 15
app/src/main/res/drawable/ic_device_miband.xml | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/SleepDetailsParser.java | 391
app/src/main/res/xml/devicesettings_temperature_scale_cf.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/OppoHeadphonesSupport.java | 44
app/src/main/res/values/wpi_attrs.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/SettingsFilePutRequest.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/um25/Data/MeasurementData.java | 103
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityFileId.java | 276
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentCache.java | 51
app/src/main/assets/fossil_hr/widgetBattery.bin | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitWeather.java | 121
app/src/main/res/drawable/ic_stop.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceSettings.java | 145
app/src/main/java/org/bouncycastle/shaded/util/Objects.java | 14
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminHttpResponse.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetNotificationRequest.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SingleEntryValueAnimator.java | 70
app/src/main/res/xml/devicesettings_sleep_mode_schedule.xml | 27
app/src/main/assets/buds_pro_right.svg | 73
app/src/main/res/layout/fragment_respiratory_rate.xml | 114
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgts/AmazfitGTSSupport.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsActivity.java | 76
app/src/main/res/xml/devicesettings_bandw_pseries.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyLinkBudsCoordinator.java | 73
src/mainline/fastlane/metadata/android/en-US/changelogs/230.txt | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/DatalogSessionHealthHR.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/freebuds5i/HuaweiFreebuds5iCoordinator.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HRConfigActivity.java | 426
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetDisconnectNotification.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ReminderDetails.java | 232
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/notification/NotificationImagePutRequest.java | 66
app/src/main/res/drawable/ic_filter_b_and_w.xml | 10
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/TestHuaweiTruSleepParser.java | 229
.gitea/issue_template/config.yml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionMeasurementSystem.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/protobuf/messagefields/RootMessageField.java | 39
app/src/main/res/xml/devicesettings_um25.xml | 14
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerObsidian.java | 176
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPartSubtype.java | 48
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java | 374
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendUploadMusicFileInfoResponse.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/liveview/LiveviewConstants.java | 126
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_62.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/preferences/PreferenceCategoryMultiline.java | 35
app/src/main/res/drawable/ic_hourglass_empty.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/configuration/ConfigurationGetRequest.java | 87
app/src/main/res/layout/activity_vibration.xml | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix8Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/AbstractMijiaLywsdCoordinator.java | 80
app/src/main/res/xml/devicesettings_fetch_unknown_files.xml | 10
app/src/main/res/drawable/ic_activity_stacked.xml | 6
app/src/main/res/drawable/ic_remove_image.xml | 10
src/mainline/fastlane/metadata/android/en-US/changelogs/156.txt | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCalendarActivity.java | 374
app/src/main/java/org/bouncycastle/shaded/crypto/modes/CBCBlockCipher.java | 266
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutKey.java | 265
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/MessageType.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminGpxRouteInstallHandler.java | 139
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/moondrop/MoondropSpaceTravelIOThread.java | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SurroundMode.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/protobuf/messagefields/MessageField.java | 74
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs3/XiaomiWatchS3Coordinator.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivitySampleMovement.java | 69
app/src/main/res/xml/devicesettings_nothing_ear1.xml | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GB.java | 700
app/src/main/res/anim/bounceright.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/FileRequest.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/GetCountdownSettingsRequest.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/device_info/GetDeviceInfoRequest.java | 93
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/prefs/Configuration.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityFileFetcher.java | 218
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/DeviceButtonActionSetting.java | 43
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/TestDeviceService.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/colacao/ColaCao23Coordinator.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingInterface.java | 54
src/mainline/fastlane/metadata/android/en-US/changelogs/213.txt | 47
src/mainline/fastlane/metadata/android/it-IT/short_description.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/healthThermometer/HealthThermometerProfile.java | 140
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiPaiSampleProvider.java | 154
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSCoordinator.java | 153
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandFWInstallHandler.java | 46
app/src/main/res/xml/devicesettings_sync_calendar.xml | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/NotificationCollectorMonitorService.java | 95
app/src/main/res/xml/devicesettings_huami2021_fetch_operation_time_unit.xml | 10
app/src/main/java/org/bouncycastle/shaded/crypto/DataLengthException.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/ManualSamplesParser.java | 132
app/src/main/res/layout/fragment_hrv_status.xml | 183
app/src/main/res/values/strings.xml | 3580
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/deviceevents/CapabilitiesDeviceEvent.java | 14
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/FindMyPhoneCancelMessage.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsCalendarService.java | 258
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/widget/CustomTextWidgetElement.java | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ProbeOsVersion.java | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1pro/XiaomiWatchS1ProCoordinator.java | 66
app/src/main/res/drawable/ic_pending_actions.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/authentication/VerifyPrivateKeyRequest.java | 135
app/src/main/res/layout/fragment_sleepchart.xml | 250
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java | 777
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetLinkParamsRequest.java | 89
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/RotaryControl.java | 201
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ChallengeTest.java | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/notification/PlayTextNotificationRequest.java | 30
src/mainline/fastlane/take_app_screenshots.sh | 646
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsActivity.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetNotificationConstraintsRequest.java | 63
app/src/main/res/layout/activity_womp_scan.xml | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetLanguageSettingRequest.java | 81
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/dialogs/MaterialMultiSelectListPreferenceDialogFragment.java | 136
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_22.java | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionAlarm.java | 31
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryDataTest.java | 122
app/src/main/res/layout/activity_external_pebble_js.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/TransactionBuilder.java | 167
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/roidmi/RoidmiSupport.java | 146
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventScreenshot.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/notification/defines/NotificationKind.java | 22
app/src/main/res/layout-land/fragment_weeksteps_chart.xml | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseType.java | 100
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch2lite/RedmiWatch2LiteCoordinator.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetActivityReminderRequest.java | 86
app/src/main/res/drawable/ic_device_h30_h10_disabled.xml | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miscale/MiSmartScaleDeviceSupport.java | 266
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitx/AmazfitXFirmwareInfo.java | 85
app/src/main/res/xml/devicesettings_developer_add_test_activities.xml | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/TimeChangeReceiver.java | 165
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/GpsCapability.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/KoreanTransliterator.java | 344
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandConst.java | 95
app/src/main/res/drawable/ic_device_folder_disabled.xml | 32
app/src/main/java/net/osmand/aidlapi/AidlParams.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxAnyWidget.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt/HuaweiWatchGTCoordinator.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/v2/SonyProtocolImplV2.java | 1090
app/src/main/res/layout/activity_data_management.xml | 252
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendMenstrualModifyTimeRequest.java | 51
app/src/main/res/xml/devicesettings_cycling_sensor.xml | 21
src/mainline/fastlane/metadata/android/en-US/changelogs/100.txt | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/MessageHandler.java | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/ContactsHandler.java | 111
app/src/main/assets/ic_intensity_hollow.svg | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/AbstractMi1FirmwareInfo.java | 126
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/EXRIZUK8Coordinator.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestFeature.java | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/FindDeviceRequest.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetWatchfacesList.java | 57
app/src/main/res/layout/activity_mi_band_pairing.xml | 33
app/src/main/res/xml/devicesettings_autoremove_notifications.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts4/AmazfitGTS4Coordinator.java | 91
app/src/main/res/drawable/ic_device_zetime_disabled.xml | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/providers/PhoneLocationProvider.java | 78
app/src/main/java/org/bouncycastle/shaded/crypto/StreamBlockCipher.java | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/DataStructureFactory.java | 168
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/V2NotificationStrategy.java | 83
app/src/main/res/drawable/ic_arrow_circle_right.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/defines/FontSize.java | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/BondAction.java | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerGBPebble.java | 72
app/src/main/res/xml/devicesettings_vesc.xml | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendFitnessGoalRequest.java | 68
src/mainline/fastlane/metadata/android/en-US/changelogs/143.txt | 10
src/mainline/fastlane/metadata/android/en-US/changelogs/186.txt | 8
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/activity/ActivitySleep.java | 28
app/src/main/res/drawable/ic_activity_yoga.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/notification/defines/VibrationKind.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/NotificationHRConfiguration.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetAutomaticSpoRequest.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/PlainAction.java | 39
app/src/main/res/drawable/ic_galaxy_buds_l.xml | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipFWInstallHandler.java | 49
app/src/main/res/drawable/ic_sony_wf_800n_right.xml | 39
gradle/wrapper/gradle-wrapper.jar | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/AppSpecificNotificationSettingsRepository.java | 85
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/jyou/TeclastH30/TeclastH30Support.java | 120
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/Ear2Coordinator.java | 43
app/src/main/res/xml/devicesettings_wena3.xml | 222
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/alertnotification/Command.java | 40
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WithingsTestStructure.java | 30
app/src/main/java/lineageos/app/LineageContextConstants.java | 31
app/src/main/res/xml/devicesettings_offline_voice.xml | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgtrunner/HuaweiWatchGTRunnerCoordinator.java | 47
app/src/main/res/xml/devicesettings_disconnectnotification_noshed.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/NotificationRequestHandler.java | 101
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/time/BandTimeZone.java | 77
app/src/main/res/xml/devicesettings_phone_silent_mode.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiDeviceEvent.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WearingState.java | 23
src/mainline/fastlane/metadata/android/en-US/changelogs/115.txt | 3
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/DataStructureFactoryTest.java | 133
app/src/main/res/xml/devicesettings_screen_brightness.xml | 9
src/mainline/fastlane/metadata/android/en-US/changelogs/200.txt | 7
app/src/main/res/drawable/ic_tws_case.xml | 15
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/Q8Coordinator.java | 51
app/src/main/res/layout/layout_widget_active_steps.xml | 47
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/20-Activity.png | 0
app/src/main/assets/buds_pro_case.svg | 51
app/src/main/res/drawable/ic_activity_tracks.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ImageEditActivity.java | 276
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ChunkedEncoder.java | 163
app/src/main/res/xml/devicesettings_root_connection.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/alertnotification/SupportedNewAlertCategory.java | 74
app/src/main/assets/fossil_hr/widget_bg_thin_circle.png | 0
app/src/main/res/xml/devicesettings_workout_send_gps_to_band.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/WorkoutVo2MaxSampleProvider.java | 273
app/src/main/res/drawable/ic_temperature.xml | 12
app/src/main/res/xml/devicesettings_miband6_new_protocol.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigValue.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/oppo/commands/TouchConfigType.java | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsSettingsCustomizer.java | 282
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/XiaomiActivityFileIdTest.java | 90
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEvent.java | 53
src/mainline/fastlane/metadata/android/en-US/changelogs/183.txt | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1Constants.java | 52
app/src/main/res/xml/devicesettings_header_system.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Coordinator.java | 297
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBTextToSpeech.java | 77
app/src/main/res/drawable/ic_device_miband2.xml | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/WeatherNotificationReceiver.java | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/activity/EventWithActivity.java | 68
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/SetTripleTapEnabledRequest.java | 26
app/src/main/res/xml/devicesettings_amazfitvergel.xml | 13
app/src/main/res/drawable/ic_health.xml | 10
app/src/main/res/xml/devicesettings_vibrations_enable.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestSpo2SampleProvider.java | 89
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/PebbleReceiver.java | 100
app/src/main/res/layout/layout_widget_activities_count.xml | 47
app/src/main/res/xml/devicesettings_header_developer.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/status/WeatherReport.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/womp/WompSession.java | 107
app/src/main/res/drawable/ic_battery_charging_20.xml | 13
app/src/main/assets/ic_device_miband6.svg | 150
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/preferences/GBSimpleSummaryProvider.java | 40
src/mainline/fastlane/metadata/android/en-US/changelogs/120.txt | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/GBWebClient.java | 245
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/bandwpseries/BandWPSeriesDeviceCoordinator.java | 63
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/util/ByteArrayWriter.java | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM4Coordinator.java | 74
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/ActivitySyncPacketProcessor.java | 115
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/healthThermometer/TemperatureInfo.java | 84
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband9/MiBand9Coordinator.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardWidget.java | 136
app/src/main/res/raw/ping_tone.rtttl | 1
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/TestDeviceConfig.java | 505
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GridAutoFitLayoutManager.java | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFmFrequency.java | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SetFileFlagsMessage.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteFWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CRC32C.java | 633
app/src/main/res/layout/item_with_details_small.xml | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/EphemerisFileUpload.java | 269
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/WeatherNotificationConfig.java | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/FrenchTransliterator.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/SuperCarsCoordinator.java | 157
app/src/main/res/layout/activity_configure_contacts.xml | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWHelper.java | 155
app/src/main/assets/devintro.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreensListActivity.java | 183
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex3/AmazfitTRex3Coordinator.java | 92
app/src/main/assets/ic_device_sony_overhead.svg | 140
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEvent.java | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileGetRawRequest.java | 151
app/src/main/res/layout/activity_summary_details.xml | 111
app/src/main/res/drawable/ic_switch_left.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiUtil.java | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/HydrationReminderIntervalCommand.java | 85
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/dialogs/MaterialEditTextPreferenceDialogFragment.java | 119
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/AbstractMiBand1Operation.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3SampleProvider.java | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetWatchfacesNames.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/Version.java | 100
app/src/main/res/xml/devicesettings_workmode.xml | 13
app/src/main/res/layout/activity_camera.xml | 11
app/src/main/assets/fossil_hr/default_background.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/StepsFragmentAdapter.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/message/GetSensorRequest.java | 32
.woodpecker/run_tests.yml | 19
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCrypto.java | 273
app/src/main/res/xml/devicesettings_inactivity.xml | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CryptoUtils.java | 142
app/src/main/res/anim/slidefromtop.xml | 11
app/src/main/res/drawable/ic_device_folder.xml | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/AbstractMiBandOperation.java | 82
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaXmwsdj04Coordinator.java | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Weather.java | 1213
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/roidmi/Roidmi1Protocol.java | 148
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BcdUtil.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PermissionsUtils.java | 328
app/src/main/res/drawable/ic_music_item.xml | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch3active/RedmiWatch3ActiveCoordinator.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerPebStyle.java | 137
app/src/main/java/com/mobeta/android/dslv/ResourceDragSortCursorAdapter.java | 136
app/src/main/assets/ic_galaxy_buds_r.svg | 82
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTLV.java | 452
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Spo2Sample.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/UserInfo.java | 119
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetMusicPlaylist.java | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/NotificationProvider.java | 184
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/alarm/BandAlarm.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/Roidmi1Coordinator.java | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSleepStateDetection.java | 30
app/src/nightly/res/mipmap-xxhdpi/ic_launcher_round.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiWorkoutTrackActivityType.java | 85
app/src/main/res/drawable/ic_dashboard.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/womp/WompLocation.java | 145
app/src/main/res/drawable/ic_device_amazfit_bip_disabled.xml | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java | 2156
app/src/main/res/xml/devicesettings_miband6.xml | 33
.gitea/issue_template/bug_report.yml | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetFitnessTotalsRequest.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/JSInterface.java | 254
app/src/main/java/org/bouncycastle/shaded/crypto/modes/CTRModeCipher.java | 16
app/src/main/res/drawable/ic_device_watchxplus.xml | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SolTacCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/AsynchronousResponse.java | 728
app/src/nightly/res/drawable-mdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendP2PCommand.java | 59
app/src/main/res/drawable/ic_filter_1.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/UserSecret.java | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/GattDescriptor.java | 41
app/src/main/assets/fossil_hr/widgetWeather.bin | 0
src/mainline/fastlane/metadata/android/en-US/changelogs/169.txt | 6
app/src/main/res/drawable-xxhdpi/ic_notification_disconnected.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetDndLiftWristTypeRequest.java | 68
src/mainline/fastlane/metadata/android/en-US/changelogs/149.txt | 2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CallSpec.java | 41
app/src/main/res/color/blacklist_checkboxes.xml | 5
app/src/main/res/xml/devicesettings_casio_alert.xml | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetDeviceStateAction.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/Accumulator.java | 47
app/src/main/res/xml/devicesettings_hydration_reminder_dnd.xml | 41
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/language/LanguageUtilsTest.java | 305
app/src/main/res/xml/fossil_hr_watchface_settings.xml | 114
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/FileTransferDataStatusMessage.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java | 260
src/mainline/fastlane/metadata/android/en-US/changelogs/111.txt | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HeartRateSample.java | 24
app/src/main/res/xml/devicesettings_language_generic.xml | 11
app/src/main/res/xml/devicesettings_heartrate_sleep_alert_activity_stress_spo2.xml | 132
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiActivityDetailsParser.java | 295
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeightSample.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/GetBatteryLevelRequest.java | 53
app/src/main/assets/gps_edit.svg | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java | 315
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255SCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/ListFilesRequest.java | 58
app/src/main/res/drawable/ic_activity_stairs.xml | 14
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventDisplayMessage.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProFWInstallHandler.java | 49
app/src/main/res/drawable/ic_watch_bw.xml | 10
app/src/main/assets/fossil_hr/widgetCustom_preview.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppManagement.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSpo2SampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsFragment.java | 260
src/mainline/fastlane/metadata/android/en-US/changelogs/203.txt | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/RequestMtuAction.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysis.java | 153
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WorkoutScreenData.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2FWHelper.java | 40
app/src/main/res/drawable-mdpi/ic_watchface.png | 0
app/src/main/assets/ic_device_galaxy_buds.svg | 189
src/mainline/fastlane/metadata/android/en-US/changelogs/212.txt | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlMessage.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/BaseCommand.java | 235
app/src/main/res/drawable/ic_device_roidmi.xml | 38
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupportTest.java | 71
app/src/main/res/xml/devicesettings_root_battery.xml | 9
app/src/main/assets/ic_distance_total.svg | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/AbstractZipBackupJob.java | 100
app/src/nopebble/res/mipmap-mdpi/ic_launcher_round.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive4SCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/NotificationConfiguration.java | 131
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetManager.java | 70
app/src/main/java/net/osmand/aidlapi/navigation/ANavigationUpdateParams.java | 60
app/src/main/assets/ic_galaxy_buds_live.svg | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/NotificationManagementActivity.java | 149
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/file/FileDeleteRequest.java | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteCoordinator.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsHost.java | 51
app/src/main/java/lineageos/providers/WeatherContract.java | 245
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminStressSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/redmibuds5pro/protocol/AuthData.java | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/NoNotificationStrategy.java | 47
app/src/main/java/net/osmand/aidlapi/navigation/ADirectionInfo.java | 73
app/src/main/res/xml/devicesettings_amazfitbipupro.xml | 13
app/src/main/res/xml/devicesettings_banglejs_location.xml | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/buttonconfig/ConfigFileBuilder.java | 98
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2Coordinator.java | 18
src/mainline/fastlane/metadata/android/en-US/changelogs/174.txt | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendWatchfaceOperation.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsFwInstallHandler.java | 177
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/JsonBackupPreferences.java | 203
app/src/nopebble/res/mipmap-xxxhdpi/ic_launcher_round.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/backup/ZipBackupImportJob.java | 243
app/src/main/assets/fossil_hr/icTrophy.rle | 1
app/src/main/res/drawable-xxhdpi/ic_notification.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/redmibuds5pro/protocol/Opcode.java | 33
app/src/main/assets/fossil_hr/widgetHR_preview.png | 0
app/src/main/res/xml/devicesettings_idasen.xml | 27
app/src/main/res/xml/devicesettings_watchxplus.xml | 112
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitGoals.java | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/GenericWeatherReceiver.java | 216
src/mainline/fastlane/metadata/android/en-US/changelogs/104.txt | 1
app/src/main/res/mipmap-xhdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfActivitySampleProvider.java | 187
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900Constants.java | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/authentication/CheckDevicePairingRequest.java | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractBodyEnergySample.java | 36
app/src/main/res/drawable/ic_device_galaxy_buds.xml | 40
app/src/main/res/xml/devicesettings_header_time.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_84.java | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/id115/AbstractID115Operation.java | 96
app/src/main/res/drawable-hdpi/ic_launcher.png | 0
src/mainline/fastlane/metadata/android/en-US/title.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/ecbs100/CasioECBS100DeviceSupport.java | 63
app/src/main/res/layout/activity_appinstaller.xml | 102
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/device_info/SupportedFileVersionsInfo.java | 52
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiBitmapUtils.java | 362
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetAudioModeRequest.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/RangeMap.java | 87
app/src/main/res/xml/devicesettings_reject_call_method.xml | 12
CONTRIBUTORS.rst | 553
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900DeviceCoordinator.java | 105
app/src/main/res/xml/devicesettings_sleep_time.xml | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/alertnotification/AlertNotificationControl.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgtr2/AmazfitGTR2Support.java | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/womp/WompWebActivity.java | 152
app/src/main/res/layout/item_reminder.xml | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfittrex/AmazfitTRexFirmwareInfo.java | 116
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetPincodeRequest.java | 56
app/src/main/res/xml/devicesettings_amazfitgts2mini.xml | 13
GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java | 1591
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/model/GpxTrack.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/moondrop/MoondropSpaceTravelDeviceSupport.java | 43
app/src/nopebble/res/mipmap-xhdpi/ic_launcher.png | 0
app/src/main/res/xml/devicesettings_huawei_debug.xml | 83
app/src/main/res/xml/devicesettings_banglejs_apploader.xml | 9
app/src/main/assets/fossil_hr/widgetSteps.bin | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/AbstractGattListenerWriteAction.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Vo2MaxSample.java | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/V1NotificationStrategy.java | 127
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiDataUploadService.java | 191
app/src/main/res/drawable/ic_bolt.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepDataInfo.java | 52
app/src/main/res/xml/devicesettings_heartrate_automatic_enable.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitcor/AmazfitCorSupport.java | 63
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java | 246
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/hplus/HPlusSupport.java | 929
app/src/main/res/xml/devicesettings_sony_headphones_adaptive_volume_control.xml | 15
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/alarm/AlarmsGetRequest.java | 56
app/src/main/res/layout/activity_gpx_receiver.xml | 69
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2Coordinator.java | 153
src/mainline/fastlane/metadata/android/en-US/changelogs/221.txt | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/marstek/SolarEquipmentStatusActivity.java | 190
app/src/main/res/layout/layout_widget_total_steps.xml | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java | 787
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/51-StepsPerWeek.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendWeatherSunMoonSupportRequest.java | 58
src/mainline/fastlane/metadata/android/en-US/changelogs/124.txt | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitvergel/AmazfitVergeLSupport.java | 68
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleIconID.java | 125
src/mainline/fastlane/metadata/android/en-US/changelogs/108.txt | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestHrvSummarySampleProvider.java | 115
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/AncsStatus.java | 45
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRAction.java | 64
app/src/main/res/xml/devicesettings_swipeunlock.xml | 10
app/src/main/res/drawable-mdpi/ic_systemapp.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/notification/defines/VibrationCount.java | 49
app/src/main/java/org/bouncycastle/shaded/util/Pack.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/GFDIStatusMessage.java | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerTrekVolle.java | 112
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/ArabicTransliterator.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CannedMessagesSpec.java | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/TrackStatistics.java | 130
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/v1/params/AudioCodec.java | 46
app/src/main/res/layout/activity_qhybrid_image_edit.xml | 35
app/src/main/res/drawable/ic_device_nothingear_disabled.xml | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FindPhoneCommand.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsData.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FitnessData.java | 850
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/translation/TranslationsPutRequest.java | 57
app/src/main/res/layout/fragment_weeksleep_chart.xml | 190
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiWatchfaceService.java | 279
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/SetDeviceStateRequest.java | 41
app/src/main/res/values/attrs.xml | 25
app/src/main/java/com/mobeta/android/dslv/DragSortController.java | 474
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiAppManager.java | 118
app/src/main/res/xml/devicesettings_liftwrist_display.xml | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/DatalogSessionHealthSleep.java | 130
app/src/main/res/xml/devicesettings_fossilhybridhr_pre_fw220.xml | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HeartRateDailyFragment.java | 322
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetLayout.java | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Measurement.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/NotifyAction.java | 136
app/src/main/res/drawable/ic_activity_pingpong.xml | 90
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiInstallHandler.java | 272
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/alexa/AlexaMessageSetRequest.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/calendar/CalendarManager.java | 326
app/src/main/res/xml/devicesettings_pebble_calendar.xml | 16
src/mainline/fastlane/metadata/android/en-US/changelogs/133.txt | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miscale/WeightMeasurement.java | 87
app/src/main/res/drawable/ic_phone.xml | 5
app/src/main/proto/huami.proto | 111
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeInt.java | 57
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java | 588
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreferenceFragment.java | 80
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgtr2/AmazfitGTR2eSupport.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9PairingActivity.java | 166
app/src/main/res/xml/devicesettings_femometer.xml | 11
src/mainline/fastlane/metadata/android/en-US/changelogs/178.txt | 8
app/src/main/res/values/rc_attrs.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/AbstractXiaomiService.java | 72
src/banglejs/fastlane/metadata/android/cz-CZ/full_description.txt | 6
app/src/main/res/xml/devicesettings_rotatewrist_cycleinfo.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/notification/NotificationArrival.java | 106
app/src/main/res/xml/devicesettings_moondrop_space_travel_touch.xml | 115
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lenovo/watchxplus/WatchXPlusDeviceSupport.java | 2135
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/GBDeviceIoThread.java | 53
app/src/main/res/drawable/ic_speaker.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/ReadAction.java | 49
app/src/main/res/values-ar/strings.xml | 3548
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/AuthNegotiationStatusMessage.java | 83
app/src/main/res/layout/activity_notification_per_app_setting_detail.xml | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiSmartScaleCoordinator.java | 131
app/src/main/assets/devintro.xmi | 175
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AndroidPairingActivity.java | 30
CHANGELOG.md | 2480
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/FitDataStatusMessage.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/AcceptAgreementsRequest.java | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/DataTransferHandler.java | 178
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/constants/ParameterId.java | 46
app/src/main/res/xml/devicesettings_settings_third_party_apps.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestRespiratoryRateSampleProvider.java | 71
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM5Coordinator.java | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/AlarmStatus.java | 51
app/src/main/res/values-lt/strings.xml | 562
app/src/main/res/drawable/ic_device_h30_h10.xml | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/AbstractHuamiFirmwareInfo.java | 127
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/baseTypes/BaseTypeInterface.java | 13
app/src/main/res/layout/activity_debug.xml | 332
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/NotificationControlMessage.java | 150
app/src/main/res/xml/devicesettings_camera_remote.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PebbleUtils.java | 187
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/UserUnit.java | 52
app/src/main/java/com/mobeta/android/dslv/DragSortListPreferenceFragment.java | 147
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/72-SportActivitiesSummary.png | 0
app/src/main/res/xml/devicesettings_amazfitneo_sound.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractTimeSampleProvider.java | 199
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitgtr/AmazfitGTRLiteFirmwareInfo.java | 85
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/GBZipFileTest.java | 185
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbips/AmazfitBipSLiteSupport.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexCoordinator.java | 143
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesPreferences.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsMorningUpdatesService.java | 231
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivityTarget.java | 51
app/src/main/res/xml/devicesettings_oppo_headphones_touch_options.xml | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Widget.java | 258
src/mainline/fastlane/metadata/android/en-US/images/featureGraphic.jpg | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivitySampleRun.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/UploadRequestMessage.java | 45
app/src/main/res/xml/devicesettings_root_authentication.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtrmini/AmazfitGTRMiniCoordinator.java | 87
app/src/main/res/drawable/ic_device_smartring.xml | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEOperation.java | 218
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/PredefinedLocalMessage.java | 58
app/src/main/java/com/mobeta/android/dslv/SimpleFloatViewManager.java | 85
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/divoom/PixooCoordinator.java | 109
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingAdapter.java | 286
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendFileUploadAck.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendGpsStatusRequest.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiWatchfaceManager.java | 304
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/TestWorkMode.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiStressSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWIC100Coordinator.java | 59
app/src/main/java/com/mobeta/android/dslv/DragSortListView.java | 2999
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/StartPpgRequest.java | 62
app/src/nopebble/res/mipmap-hdpi/ic_launcher_foreground.png | 0
src/mainline/fastlane/metadata/android/en-US/changelogs/207.txt | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/RoidmiCoordinator.java | 147
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch5lite/RedmiWatch5LiteCoordinator.java | 50
app/src/main/res/xml/devicesettings_amazfitbip.xml | 23
app/src/main/assets/fossil_hr/icWthClearDay.rle | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProDeviceCoordinator.java | 180
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/status/MusicInfo.java | 44
app/src/main/res/drawable/ic_device_miwatch.xml | 52
app/src/main/res/drawable/ic_distance.xml | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/OTAEnterRequest.java | 36
app/src/main/res/values-th/strings.xml | 138
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateManualSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip5unity/AmazfitBip5UnityCoordinator.java | 83
app/src/main/res/drawable/ic_activity_sup.xml | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/BatteryValues.java | 73
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateDailyFragment.java | 208
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/CMWeatherReceiver.java | 224
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java | 135
src/mainline/fastlane/metadata/android/en-US/changelogs/235.txt | 57
app/proguard-rules.pro | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchSpo2SleepOperation.java | 96
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendSetContactsRequest.java | 49
app/src/main/res/drawable/ic_date_range.xml | 3
app/src/main/assets/calendar_sync.svg | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsGpxRouteInstallHandler.java | 135
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/db/CyclingSampleProvider.java | 41
app/src/main/res/values/styles.xml | 224
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsActivitySummaryParser.java | 212
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/Request.java | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsMusicService.java | 137
app/src/main/java/lineageos/weatherservice/WeatherProviderService.java | 181
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/coordinator/CyclingSensorCoordinator.java | 121
app/src/main/res/drawable/ic_activitytracker.xml | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWInstallHandler.java | 158
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu3SCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetBatteryLevelCommand.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfNotificationIcon.java | 75
app/src/main/res/xml/devicesettings_power_saving.xml | 9
app/src/main/res/xml/devicesettings_password.xml | 25
src/mainline/fastlane/metadata/android/en-US/changelogs/165.txt | 5
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/LimitedQueue.java | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetWearLocationRequest.java | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiFreebudsCoordinator.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/deviceevents/NotificationSubscriptionDeviceEvent.java | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/UserUnitConstants.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CommuteActionsActivity.java | 213
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/womp/WompApi.java | 85
app/src/main/assets/galaxy_buds.svg | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSActivityTrack.java | 437
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbip3pro/AmazfitBip3ProSupport.java | 86
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/SonyHeadphonesIoThread.java | 157
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/binary_sensor/coordinator/BinarySensorCoordinator.java | 188
app/src/main/res/xml/devicesettings_huami2021_alexa.xml | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/OTAEraseRequest.java | 61
app/src/main/res/drawable-xxhdpi/ic_systemapp.png | 0
app/src/main/res/drawable/ic_activity_pilates.xml | 14
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil/FossilWatchAdapter.java | 911
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/enums/GarminSportTest.java | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BatteryConfig.java | 89
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteFWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DateTimeUtils.java | 322
src/mainline/fastlane/metadata/android/en-US/changelogs/192.txt | 7
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySession.java | 127
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchSampleProvider.java | 80
app/src/main/res/layout/activity_configure_alarms.xml | 17
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java | 154
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/CalendarNotificationEnableSetting.java | 38
app/src/main/assets/fossil_hr/icWthWindy.rle | 2
app/src/main/res/xml/devicesettings_trusleep.xml | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/NotificationUpdateMessage.java | 140
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/receivers/GBAutoFetchReceiver.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/liveview/LiveviewSupport.java | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/womp/WompConfig.java | 19
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsDailyFragment.java | 243
app/src/main/res/drawable/ic_intensity.xml | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/quickreply/QuickReplyConfigurationPutRequest.java | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2FWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/microapp/MicroAppCommand.java | 176
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/CommonSymbolsTransliterator.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitMonitoring.java | 121
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/UIHHContainer.java | 234
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/LoyaltyCard.java | 153
app/src/main/res/layout/activity_device_settings.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityListItem.java | 171
app/src/main/res/xml/devicesettings_autolight.xml | 9
app/src/main/res/xml/devicesettings_fossilqhybrid_legacy.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/DeviceInformationMessage.java | 113
app/src/main/assets/fossil_hr/widgetChanceOfRain_preview.png | 0
app/src/main/res/drawable/ic_device_zetime.xml | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/SleepAlarmWidget.java | 159
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothStateChangeReceiver.java | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendDndDeleteRequest.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetWatchfaceParams.java | 57
app/src/main/res/xml/devicesettings_colmi_r0x.xml | 32
app/src/main/res/menu/activity_hybridhr_watchface_designer_actionbar.xml | 17
app/src/main/res/layout/fragment_dashboard.xml | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitMonitoringInfo.java | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband5/MiBand5FirmwareInfo.java | 97
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepCollectionFragment.java | 44
app/src/main/res/xml/devicesettings_sony_headphones_ambient_sound_control_wind_noise_reduction.xml | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetScreen.java | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/AbortTransactionAction.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchStressManualOperation.java | 123
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/button/ButtonConfigurationGetRequest.java | 63
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureContacts.java | 173
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SSapphireCoordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/JavaExtensions.java | 31
app/src/main/res/xml/devicesettings_sony_headphones_wide_area_tap.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/activity/SleepLevel.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java | 196
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/AlarmCommand.java | 173
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/RemoteCameraTriggeredCommand.java | 34
app/src/main/assets/ic_device_galaxy_buds_pro_disabled.svg | 200
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetFileParametersRequest.java | 63
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusCoordinator.java | 273
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/swim/GarminSwim2Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendEphemerisFileStatusRequest.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctSolarCoordinator.java | 24
app/src/nightly/res/drawable-xxhdpi/ic_launcher.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java | 163
app/src/main/res/drawable/ic_activity_dumbbell.xml | 33
app/src/main/res/layout/activity_contact_details.xml | 96
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/11-Devices.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SetLanguageCommand.java | 52
app/src/nopebble/res/mipmap-xxxhdpi/ic_launcher.png | 0
app/src/main/res/layout/spinner_with_image_layout.xml | 21
app/src/main/res/layout/steps_streak_days.xml | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/constants/SensorType.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/unknown/UnknownDeviceSupport.java | 35
app/src/main/res/xml/devicesettings_soundcore_headphones.xml | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsWifiService.java | 174
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/ActivityTrackExporter.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/AbstractSonyProtocolImpl.java | 145
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/bandwpseries/BandWMessageType.java | 27
app/src/main/res/xml/devicesettings_notification_wake_on_open.xml | 10
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSWorkoutParserTest.java | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_89.java | 93
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java | 287
app/src/main/res/layout/fragment_weeksteps_chart.xml | 44
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/60-Stress.png | 0
app/src/main/res/drawable/arrow_autofit_height.xml | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/EraseFileRequest.java | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AbstractItemAdapter.java | 135
app/src/main/res/layout/fragment_calories.xml | 102
app/src/main/res/menu/activity_list_context_menu.xml | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BLEScanService.java | 430
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiActivitySampleProvider.java | 191
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSettingsCustomizer.java | 164
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/device_info/DeviceInfo.java | 21
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/weather/WeatherData.java | 1059
app/src/main/res/xml/devicesettings_donotdisturb_allday_liftwirst_notwear.xml | 78
app/src/main/assets/ic_shoe.svg | 20
app/src/main/res/drawable/ic_activity_stretching.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AlarmClockReceiver.java | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/womp/WompWorkbenchFragment.java | 189
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchSportsSummaryOperation.java | 119
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/RequestMtuRequest.java | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationListener.java | 90
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1/XiaomiWatchS1Coordinator.java | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SleepAsAndroidPreferencesActivity.java | 138
app/src/main/res/layout/activity_qhybrid_settings.xml | 132
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerMarioTime.java | 71
app/src/main/res/xml/devicesettings_screen_brightness_withauto.xml | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/Wena3Packetable.java | 21
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHrvSummarySample.java | 50
src/mainline/fastlane/metadata/android/en-US/changelogs/225.txt | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandSampleProvider.java | 73
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureAlarms.java | 194
app/src/main/res/layout/fragment_statschart.xml | 39
app/src/main/res/xml/devicesettings_hydration_reminder.xml | 21
src/mainline/fastlane/metadata/android/en-US/changelogs/97.txt | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/ScandinavianTransliterator.java | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRestingMetabolicRateSampleProvider.java | 56
app/src/main/res/values-gl/strings.xml | 533
LICENSE.artwork | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionSleepStage.java | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java | 854
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SupportedFileTypesMessage.java | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/SleepStagesParser.java | 176
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenCoordinator.java | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeJFConstants.java | 66
app/src/main/res/layout/fragment_stresschart.xml | 148
app/src/main/res/drawable/ic_device_scannable.xml | 28
app/src/main/res/values-it/strings.xml | 3674
app/src/main/res/layout/activity_vesc_control.xml | 71
app/src/main/res/drawable/ic_device_miband_disabled.xml | 28
app/src/main/res/drawable/ic_shoe.xml | 11
gradlew.bat | 94
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/defines/DeviceButtonActionId.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/femometer/FemometerVinca2DeviceSupport.java | 309
src/mainline/fastlane/metadata/android/en-US/changelogs/127.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/womp/WompScanActivity.java | 149
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SettingsCommand.java | 116
src/mainline/fastlane/metadata/android/en-US/changelogs/161.txt | 3
app/src/main/res/menu/activity_list_menu.xml | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/FitDefinitionStatusMessage.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSpo2SampleProvider.java | 66
app/src/main/res/drawable/ic_activity_unknown.xml | 10
app/src/main/res/layout/activity_musicmanager.xml | 151
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/activity/ActivityHeartRate.java | 29
app/src/main/res/drawable/ic_device_set_reminders.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TemperatureChartFragment.java | 245
app/src/main/res/values-ja/strings.xml | 2707
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetPhoneInfoRequest.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/LiveHeartrateHandler.java | 90
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/test/RtlUtilsTest.java | 369
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_23.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/GpsAndTime.java | 238
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRespiratoryRateSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSession.java | 212
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt2/HuaweiWatchGT2Coordinator.java | 41
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityKindTest.java | 20
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband2/Mi2NotificationStrategy.java | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/amazfitbip/BipActivitySummary.java | 219
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/Challenge.java | 80
app/src/main/res/xml/devicesettings_reconnect_ble.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHeartRateSampleProvider.java | 56
app/src/main/res/layout/activity_file_manager.xml | 25
app/src/main/res/xml/devicesettings_root_developer.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/WatchAdapterFactory.java | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfInstallHandler.java | 90
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/menu/SetCommuteMenuMessage.java | 47
app/src/main/assets/ic_device_galaxy_buds_pro.svg | 147
app/src/main/res/xml/devicesettings_workout_start_on_phone.xml | 10
app/src/main/AndroidManifest.xml | 947
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/file/AssetFile.java | 58
app/src/main/res/drawable/ic_watch.xml | 10
app/src/main/res/xml/devicesettings_vibrationpatterns.xml | 323
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3Service.java | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentDocsSource.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/SetDeviceBusyAction.java | 53
src/mainline/fastlane/metadata/android/en-US/changelogs/151.txt | 4
app/src/main/res/drawable/ic_shoe_print.xml | 10
app/src/main/res/layout/activity_watch9_calibration.xml | 124
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/database/SampleProviderTest.java | 216
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/GoalTrackingGetRequest.java | 44
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryTableRowEntry.java | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/MessageBuilder.java | 89
app/src/main/res/drawable/baseline_bloodtype_24.xml | 9
app/src/main/res/layout/fragment_welcome_permission_row.xml | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/Conversation.java | 28
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/encoder/RLEEncoder.java | 50
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchActivityOperation.java | 194
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/BengaliTransliterator.java | 322
app/src/main/res/drawable/ic_monochrome_photos.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitUserProfile.java | 139
app/src/main/res/xml/devicesettings_dateformat_2.xml | 11
src/mainline/fastlane/metadata/android/en-US/changelogs/118.txt | 2
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/parameter/Parameter.java | 65
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/dialogs/MaterialPreferenceDialogFragment.java | 269
app/src/main/res/xml/devicesettings_autoremove_message.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/microapp/PlayCrazyShitRequest.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CommuteActionsListAdapter.java | 111
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSet.java | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDevice.java | 754
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_24.java | 35
app/src/main/res/xml/devicesettings_root_health.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteFWHelper.java | 43
app/src/main/res/layout/fragment_welcome_overview.xml | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractTransaction.java | 46
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitpop/AmazfitPopFirmwareInfo.java | 81
app/src/main/assets/shortcut_black.svg | 1
app/src/main/res/drawable/ic_sony_wf_800n_left.xml | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/preferences/DevicePrefs.java | 118
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetAutomaticHeartrateRequest.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/galaxy_buds/GalaxyBudsDeviceSupport.java | 58
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerSimplyLight.java | 139
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksController.java | 154
app/src/main/res/drawable/ic_activity_not_measured.xml | 10
app/src/main/res/drawable/ic_screen_rotation.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiFreebudsSupport.java | 135
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendMusicOperation.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/PutSettingsFileRequest.java | 26
app/src/main/res/drawable/ic_battery_80.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmismartbandpro/RedmiSmartBandProCoordinator.java | 68
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/activity/ActivityEntry.java | 101
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR10Coordinator.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityKind.java | 360
app/src/main/res/xml/devicesettings_miband6_new_auth_protocol_explanation.xml | 15
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitpoppro/AmazfitPopProFirmwareInfo.java | 81
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/ICommunicator.java | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiFirmwareInfo.java | 194
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/alertnotification/AlertStatus.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil/alarm/Alarm.java | 121
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProFWHelper.java | 40
app/src/main/res/drawable/ic_shoe_prints_many.xml | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/bandwpseries/BandWPSeriesResponse.java | 84
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusConstants.java | 103
app/src/main/res/drawable/ic_action_manage_apps.xml | 10
src/mainline/fastlane/metadata/android/en-US/changelogs/231.txt | 28
app/src/main/java/org/bouncycastle/shaded/crypto/params/ParametersWithIV.java | 39
app/src/main/res/values-nb-rNO/strings.xml | 1701
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java | 225
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/EmojiConverter.java | 131
app/src/main/res/xml/devicesettings_device_actions_without_not_wear.xml | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/actions/StopNotificationAction.java | 44
app/src/main/project.properties | 15
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopCoordinator.java | 64
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenSettingsCustomizer.java | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2FWHelper.java | 40
app/src/main/res/drawable-xxhdpi/ic_launcher.png | 0
gradlew | 252
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexFWHelper.java | 40
app/src/main/proto/garmin/gdi_http_service.proto | 53
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eCoordinator.java | 158
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/scannable/ScannableDeviceCoordinator.java | 68
app/src/main/res/drawable/ic_signal_cellular_connected_no_internet_4_bar.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/notifications/GoogleMapsNotificationHandler.java | 1301
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxCyclingWidget.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractCollectionFragment.java | 120
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsConst.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/init/InitOperation2021.java | 209
app/src/main/res/drawable/ic_edit.xml | 10
app/src/main/res/menu/appmanager_context.xml | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/p2p/HuaweiP2PDataDictionarySyncService.java | 312
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicExporter.java | 149
app/src/main/res/xml/devicesettings_huami2021_watchface.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/FetchActivityOperation.java | 460
app/src/main/res/drawable/ic_activity_walking.xml | 10
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSActivityPointTest.java | 143
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchTemperatureOperation.java | 70
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png | 0
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/TestAlarms.java | 167
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsAlexaService.java | 391
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/theme/SelectedThemePutRequest.java | 48
app/src/main/java/net/osmand/aidlapi/navigation/ANavigationVoiceRouterMessageParams.java | 60
app/src/main/res/layout/widget_alarms_activity_list.xml | 103
app/src/main/res/drawable/ic_rotate_left.xml | 10
app/src/main/res/layout/activity_womp_web.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenConstants.java | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitTimeInZone.java | 77
app/src/main/res/drawable/ic_refresh.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/image/ImagesSetRequest.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiRunPaceConfig.java | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetSettingRelatedRequest.java | 61
src/mainline/fastlane/metadata/android/en-US/changelogs/196.txt | 6
app/src/main/res/layout/qhybrid_package_settings_item.xml | 29
app/src/nightly/res/mipmap-xxxhdpi/ic_launcher_foreground.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractWeightSample.java | 35
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractChartFragment.java | 403
app/src/main/java/org/bouncycastle/shaded/crypto/RuntimeCryptoException.java | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPart.java | 97
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUFWHelper.java | 40
app/src/main/res/drawable-hdpi/ic_notification_qhybrid.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsDetailActivity.java | 184
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchDebugLogsOperation.java | 109
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4FWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/workout/WorkoutRequestHandler.java | 84
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FindPhoneActivity.java | 218
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommandTest.java | 39
app/src/main/res/drawable-xhdpi/ic_notification.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLFWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/Vo2MaxPacketParser.java | 93
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/AbstractXiaomiSppProtocol.java | 49
app/src/main/res/drawable/ic_calendar_today.xml | 10
app/src/main/res/xml/discovery_pairing_preferences.xml | 30
app/src/main/res/drawable/ic_device_itag.xml | 40
app/src/main/res/drawable/ic_device_miband6.xml | 27
src/mainline/fastlane/metadata/android/en-US/changelogs/101.txt | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64Constants.java | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/SimpleHexToByteMessage.java | 71
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/NotificationSource.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/DownloadRequestStatusMessage.java | 60
src/mainline/fastlane/metadata/android/en-US/changelogs/187.txt | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSkinTempOvernight.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiScaleSampleProvider.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTruSleepParser.java | 474
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/status/CreateFileStatusMessage.java | 72
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/JoystickView.java | 126
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBActivity.java | 144
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/realme/RealmeBudsT110Coordinator.java | 73
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qc35/QC35IOThread.java | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/BasicCasio2C2DSupport.java | 114
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/moondrop/GaiaPacket.java | 136
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitneo/AmazfitNeoFirmwareInfo.java | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendWeatherExtendedSupportRequest.java | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetMusicPlaylistMusics.java | 44
app/src/nightly/res/mipmap-mdpi/ic_launcher_round.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Constants.java | 64
app/src/main/res/xml/devicesettings_redmibuds5pro_gestures.xml | 123
app/src/main/res/drawable/ic_calculate.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/AbstractMessage.java | 98
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatch4pro/HuaweiWatch4ProCoordinator.java | 52
app/src/main/res/xml/network_security_config.xml | 4
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/protocol/message/SetSensorRequest.java | 34
app/src/main/res/drawable/ic_switch_right.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/id115/ID115Support.java | 241
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/activity/impl/DailySummaryParser.java | 126
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunFeatureSupport.java | 59
app/src/main/res/xml/devicesettings_sleep_mode.xml | 25
app/src/main/res/layout/fragment_temperature.xml | 101
src/mainline/fastlane/metadata/android/en-US/changelogs/229.txt | 23
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitConnectivity.java | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbip/AmazfitBipFirmwareInfo.java | 186
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/ProfileCommand.java | 129
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/um25/Data/CaptureGroup.java | 55
app/src/main/res/drawable/ic_open_in_browser.xml | 10
app/src/main/res/drawable/ic_report.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsActivity.java | 87
.idea/dictionaries/t.xml | 189
app/src/main/res/drawable/ic_battery.xml | 12
app/src/main/res/layout/steps_streak_maximum_line_layout.xml | 52
app/src/main/java/com/mobeta/android/dslv/CheckableLinearLayout.java | 38
app/src/main/res/drawable/ic_person.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunDeviceCoordinator.java | 166
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/divoom/PixooProtocol.java | 492
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerZalewszczak.java | 99
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/DayStartHourSetting.java | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java | 828
app/src/main/assets/fossil_hr/widgetUV.bin | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/file/AssetFilePutRequest.java | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband6/MiBand6FirmwareInfo.java | 82
app/src/main/res/drawable/ic_reply.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiMusicService.java | 163
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/MapUtils.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSportHRZones.java | 131
app/src/main/res/layout/activity_watchxplus_calibration.xml | 124
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppSpecificNotificationSettingsAppListAdapter.java | 252
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHrvValueSampleProvider.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/MusicPlaybackReceiver.java | 117
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ActivitySampleHandler.java | 269
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionWeatherAqi.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ConversationObserver.java | 21
app/src/main/proto/garmin/gdi_device_status.proto | 44
app/src/main/assets/fossil_hr/icBattEmpty.rle | 0
app/src/main/res/drawable/ic_activity_stair_stepper.xml | 21
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/gpx/model/GpxTrackPoint.java | 115
app/src/main/res/drawable-v21/splash.xml | 11
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Alarms.java | 257
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsHandler.java | 83
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java | 912
app/src/main/res/drawable-mdpi/ic_languagepack.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TimeStamped.java | 24
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/codegen/FitCodeGen.java | 364
app/src/main/res/drawable/ic_thermometer.xml | 10
app/src/main/aidl/lineageos/weatherservice/IWeatherProviderService.aidl | 28
app/src/main/res/drawable/ic_device_nothingear.xml | 34
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivitySampleCalories.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsCustomizer.java | 52
app/src/main/res/drawable-mdpi/ic_watchapp.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/GattServerCallback.java | 76
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesCoordinator.java | 101
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/image/AssetImageFactory.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SoundPosition.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetWearStatusRequest.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9Constants.java | 92
app/src/main/res/xml/devicesettings_fossilhybridhr_vibration.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_51.java | 38
src/mainline/fastlane/metadata/android/en-US/changelogs/155.txt | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/BohemicSmartBraceletDeviceCoordinator.java | 63
app/src/main/res/xml/devicesettings_chartstabs.xml | 14
app/src/main/res/xml/devicesettings_header_health.xml | 6
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_29.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/authentication/CheckDeviceNeedsConfirmationRequest.java | 50
app/src/main/res/layout/activity_appblacklist.xml | 25
app/src/main/res/values-ru/strings.xml | 3680
app/src/main/res/xml/devicesettings_qc35.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityFileParser.java | 246
app/src/main/res/drawable/ic_device_galaxy_buds_pro.xml | 31
app/src/main/res/xml/devicesettings_donotdisturb_no_auto.xml | 31
app/src/main/res/drawable/ic_wena_icon_right.xml | 24
app/src/main/res/xml/devicesettings_amazfitcor.xml | 13
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/defines/VibrationStrength.java | 29
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/MessageFactory.java | 55
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/v1/SonyProtocolImplV1Test.java | 538
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWeatherConditions.java | 186
app/src/main/res/drawable/ic_done.xml | 10
app/src/banglejsNightly/res/values/strings.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexultra/AmazfitTRexUltraCoordinator.java | 81
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiMusicManager.java | 377
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXFWHelper.java | 40
app/src/main/java/lineageos/os/Build.java | 32
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/TestWorkout.java | 445
app/src/main/res/values-ro/strings.xml | 1181
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java | 478
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSFWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleActiveAppTracker.java | 56
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java | 162
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/RedmiBuds5ProSettingsCustomizer.java | 161
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetNotificationCapabilitiesRequest.java | 61
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_71.java | 38
app/src/main/res/xml/devicesettings_high_mtu.xml | 10
app/src/main/res/layout/dashboard_widget_today.xml | 35
app/src/main/res/values-bg/strings.xml | 601
app/src/main/res/drawable/ic_access_time.xml | 10
app/src/main/res/layout/activity_about.xml | 175
app/src/main/res/xml/devicesettings_sony_headphones_touch_sensor_single.xml | 9
app/src/main/java/org/bouncycastle/shaded/crypto/StreamCipher.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt5/HuaweiWatchGT5Coordinator.java | 47
src/mainline/fastlane/metadata/android/en-US/changelogs/142.txt | 1
app/src/main/res/layout/layout_widget_intensity_total.xml | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2FWInstallHandler.java | 122
app/src/main/res/values-sv/strings.xml | 371
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DateTimeDisplay.java | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CalibrationActivity.java | 158
app/src/main/res/layout/qhybrid_element_popup_view.xml | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/bandwpseries/BandWPSeriesRequest.java | 80
app/src/main/assets/fossil_hr/widget2ndTZ_preview.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/translation/TranslationData.java | 61
app/src/main/assets/fossil_hr/widgetActiveMins_preview.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSSampleProvider.java | 127
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner235Coordinator.java | 18
app/src/main/assets/fossil_hr/icCalories.rle | 2
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/TimeTest.java | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/NotificationDataMessage.java | 39
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/BFH16DeviceCoordinator.java | 202
app/src/main/res/drawable/ic_activity_boating.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiSppProtocolV2.java | 150
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFSP800NCoordinator.java | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/HungarianTransliterator.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ActivitySampleDuration.java | 51
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/amazfitbip3/AmazfitBip3Support.java | 79
app/src/main/res/drawable/ic_device_miband2_disabled.xml | 32
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniFWHelper.java | 40
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5Coordinator.java | 204
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/SMSReceiver.java | 114
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentPermissions.java | 173
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex2/AmazfitTRex2Coordinator.java | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/InfiniTimeDFUPackage.java | 59
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/BarcodeFormat.java | 36
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7Coordinator.java | 87
app/src/main/assets/app_config/js/Uri.js | 458
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/entities/activity/EventFactory.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/User.java | 102
app/src/main/res/layout/main_drawer_header.xml | 31
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CalBlacklistActivity.java | 171
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionFileType.java | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/defines/Language.java | 22
src/mainline/fastlane/metadata/android/en-US/changelogs/114.txt | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gb6900/CasioGB6900HandlerThread.java | 109
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6Coordinator.java | 226
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/sleepasandroid/SleepAsAndroidAction.java | 16
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/binary_sensor/BinarySensorBaseSupport.java | 42
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CalendarEventSpec.java | 38
app/src/main/res/drawable/ic_device_smartring_disabled.xml | 22
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/NotificationUtils.java | 162
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BatteryInfoActivity.java | 241
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSFWHelper.java | 40
app/src/main/proto/garmin/gdi_find_my_watch.proto | 33
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesCoordinator.java | 232
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/fetch/FetchSportsDetailsOperation.java | 217
app/src/main/res/xml/devicesettings_sonyswr12.xml | 25
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetExpandCapabilityRequest.java | 67
app/src/main/res/xml/devicesettings_hourly_chime_enable.xml | 9
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/WeatherMessage.java | 37
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminEventSampleProvider.java | 101
app/src/main/res/layout/dialog_frequency_picker.xml | 79
app/src/test/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactoryTest.java | 70
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2Coordinator.java | 141
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/PolishTransliterator.java | 30
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/protobuf/messagefields/BytesMessageField.java | 34
app/src/main/res/layout/steps_streaks_dashboard.xml | 78
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SummaryOfDay.java | 28
app/src/main/res/layout/item_appmanager_watchapp.xml | 101
app/src/main/assets/sony_800n_case.svg | 72
app/src/main/res/values-es/strings.xml | 3693
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband6/MiBand6Support.java | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaMhoC303Coordinator.java | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiPhoneGpsStatus.java | 47
app/src/main/res/drawable/ic_earbuds_battery.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ImportExportSharedPreferences.java | 186
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TemperatureDailyFragment.java | 234
src/mainline/fastlane/metadata/android/en-US/changelogs/216.txt | 34
app/src/main/proto/smaq2oss.proto | 60
app/src/main/res/xml/devicesettings_pebble_activity.xml | 43
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppBlacklistAdapter.java | 307
app/src/main/res/drawable-hdpi/ic_notification.png | 0
app/src/main/res/drawable/ic_device_miscale_disabled.xml | 41
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband2/Mi2FirmwareInfo.java | 107
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryEntries.java | 183
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendFileUploadInfo.java | 55
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleCoordinator.java | 292
app/src/main/res/drawable/ic_buds_pro_case.xml | 3
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner965Coordinator.java | 18
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/settings/defines/GenderSetting.java | 22
app/src/main/res/drawable/ic_device_supercars_disabled.xml | 27
app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/TestHuaweiSupportProvider.java | 71
app/src/main/res/xml/devicesettings_heartrate_sleep_alert_activity_stress.xml | 66
app/src/main/java/org/bouncycastle/shaded/crypto/modes/CCMBlockCipher.java | 480
app/src/main/res/drawable/ic_add_gray.xml | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateRestingSampleProvider.java | 56
app/src/main/res/drawable/ic_activity_climbing.xml | 10
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java | 843
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/misfit/AnimationRequest.java | 26
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xwatch/XWatchSupport.java | 491
app/src/main/res/xml/devicesettings_miband_vibrationpatterns.xml | 248
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SendEphemerisParameterConsultRequest.java | 38
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProFWInstallHandler.java | 49
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FieldInterface.java | 12
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/QHybridSupport.java | 854
app/src/main/java/org/bouncycastle/shaded/crypto/paddings/BlockCipherPadding.java | 48
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/IntentApiReceiver.java | 302
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceCoordinator.java | 697
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/files/FileManagerAdapter.java | 206
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/hplus/HPlusDataRecordRealtime.java | 137
app/src/main/res/layout/item_cal_blacklist.xml | 60
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4Coordinator.java | 185
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java | 2096
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java | 414
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java | 472
app/src/main/res/xml/garmin_realtime_settings.xml | 5
src/mainline/fastlane/metadata/android/en-US/changelogs/170.txt | 7
app/src/main/assets/fossil_hr/icWthCloudy.rle | 1
app/src/main/res/layout/activity_hybridhr_watchface_designer.xml | 164
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentInstalledWatchfaces.java | 62
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WakeActivity.java | 66
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryPairingPreferenceActivity.java | 54
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/widget/CustomBackgroundWidgetElement.java | 27
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/language/impl/GermanTransliterator.java | 31
app/src/main/res/xml/devicesettings_fossilhybridhr_pre_fw300.xml | 67
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/CasioTimeZone.java | 410
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandSampleProvider.java | 79
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java | 269
app/src/main/assets/filter_alt_plus.svg | 77
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.java | 47
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3Coordinator.java | 175
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FwAppInstallerActivity.java | 351
app/src/main/res/drawable/ic_language.xml | 10
src/mainline/fastlane/metadata/android/de-DE/short_description.txt | 1
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/GetFileInfoRequest.java | 81
src/mainline/fastlane/metadata/android/en-US/images/phoneScreenshots/70-LiveActivity.png | 0
app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeSettingsCustomizer.java | 111
3,954 files changed, 530,654 insertions(+), 0 deletions(-)
diff --git a/.artifacts/5fb7b17c-2744-457a-b1a3-a3b9fbd1ae6d/implementation_plan.artifact.md b/.artifacts/5fb7b17c-2744-457a-b1a3-a3b9fbd1ae6d/implementation_plan.artifact.md
new file mode 100644
index 0000000..86f5df8
--- /dev/null
+++ b/.artifacts/5fb7b17c-2744-457a-b1a3-a3b9fbd1ae6d/implementation_plan.artifact.md
@@ -0,0 +1,24 @@
+# Fix missing BaseActivitySummary symbol
+
+The build error `閿欒: 鎵句笉鍒扮鍙� 绫� BaseActivitySummary` occurs because the greenDAO generated entity `BaseActivitySummary` is missing from the project's source tree. These entities are generated by the `GBDaoGenerator` module, which is currently not configured to run automatically during the build process.
+
+## Proposed Changes
+
+### Build Configuration
+
+#### [MODIFY] [settings.gradle](file:///E:/mfa/jingshen-womp/cool-admin-shop-v8.0.0/cool-admin-android/settings.gradle)
+Include the `:GBDaoGenerator` module so it can be used to generate the necessary entities. (Already partially completed, will verify).
+
+#### [MODIFY] [app/build.gradle](file:///E:/mfa/jingshen-womp/cool-admin-shop-v8.0.0/cool-admin-android/app/build.gradle)
+Uncomment the task dependency to ensure that the DAO generator runs before the app is built.
+
+```gradle
+preBuild.dependsOn(":GBDaoGenerator:genSources")
+```
+
+## Verification Plan
+
+### Automated Tests
+- Run `./gradlew :GBDaoGenerator:genSources` to manually trigger source generation.
+- Verify the existence of `app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BaseActivitySummary.java`.
+- Run `./gradlew :app:compileMainlineDebugJavaWithJavac` to verify that the compilation error is resolved.
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..42736ee
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,17 @@
+# Set the default behavior, in case people don't have core.autocrlf set.
+* text=auto
+
+# Explicitly declare text files you want to always be normalized and converted
+# to native line endings on checkout.
+*.java text
+*.gradle text
+*.md text
+*.properties text
+*.xml text
+
+# Declare files that will always have CRLF line endings on checkout.
+#*.sln text eol=crlf
+
+# Denote all files that are truly binary and should not be modified.
+#*.png binary
+#*.jpg binary
diff --git a/.gitea/issue_template/bug_report.yml b/.gitea/issue_template/bug_report.yml
new file mode 100644
index 0000000..a4c186a
--- /dev/null
+++ b/.gitea/issue_template/bug_report.yml
@@ -0,0 +1,77 @@
+name: Bug report
+about: Create a report to help us improve.
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to make Gadgetbridge better!
+
+ If you just have a question, please ask first in the user chatroom in Matrix at [`#gadgetbridge:matrix.org`](https://matrix.to/#/#gadgetbridge:matrix.org)
+ - type: checkboxes
+ attributes:
+ label: Please confirm that;
+ options:
+ - label: I have checked the [website](https://gadgetbridge.org), and I didn't find a solution to my problem / an answer to my question.
+ required: true
+ - label: I have searched the [issues](https://codeberg.org/Freeyourgadget/Gadgetbridge/issues), and I didn't find a solution to my problem / an answer to my question.
+ required: true
+ - label: I have read and understood the [Codeberg Terms of Use](https://codeberg.org/Codeberg/org/src/branch/main/TermsOfUse.md) for images or other type of content that I included here.
+ required: true
+ - type: dropdown
+ id: source
+ attributes:
+ label: Where did you get Gadgetbridge from?
+ options:
+ - F-Droid
+ - Gadgetbridge Nightly F-Droid repository
+ - Bangle.js Gadgetbridge from the Play Store
+ - I built it myself from source code
+ - I previously used Gadgetbridge from other sources and then updated to F-Droid version
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: What is your Gadgetbridge version?
+ description: |
+ This can be found in "Menu > About > Version" in Gadgetbridge.
+ Also include tag / commit SHA if you built Gadgetbridge from the source.
+ placeholder: e.g. "0.77.0" or "0.77.0-2618adac1" to include commit
+ validations:
+ required: true
+ - type: textarea
+ id: content
+ attributes:
+ label: What happened?
+ description: >
+ Please note that new issues about already solved/documented topics
+ **could be closed without further comments.** Same for too generic or incomplete reports.
+ placeholder: If you want to include logs, don't include it here. Use the next text field for that.
+ validations:
+ required: true
+ - type: textarea
+ id: logs
+ attributes:
+ label: Do you have logs?
+ description: >
+ If possible, please attach [logs](https://gadgetbridge.org/internals/topics/logs/)
+ that might help identifying the problem. This will be automatically formatted into
+ code, so no need for backticks.
+ render: shell
+ - type: input
+ id: gadget
+ attributes:
+ label: What gadget do you use?
+ description: >
+ Please specify model and firmware version if possible. Leave blank if you believe the
+ issue is not specific to the gadget that you currently use with Gadgetbridge.
+ placeholder: e.g. ExampleWatch A1 with 0.1 firmware
+ - type: input
+ id: android
+ attributes:
+ label: What is your Android version/manufacturer flavor?
+ description: >
+ Android phone manufacturers may customise the Android source code as they wish, so
+ if you are using a phone that running a vendor-exclusive system (like MIUI) or if
+ you use a custom ROM, make sure to also include the name of the OS/ROM.
+ placeholder: e.g. LineageOS 20 based on Android 13
\ No newline at end of file
diff --git a/.gitea/issue_template/config.yml b/.gitea/issue_template/config.yml
new file mode 100644
index 0000000..4abd84b
--- /dev/null
+++ b/.gitea/issue_template/config.yml
@@ -0,0 +1,11 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Ask a question
+ url: https://matrix.to/#/#gadgetbridge:matrix.org
+ about: If you just have a question, please ask in the user chatroom in Matrix.
+ - name: Website
+ url: https://gadgetbridge.org
+ about: Visit Gadgetbridge website for FAQ, getting started and common troubleshooting.
+ - name: See supported gadgets
+ url: https://gadgetbridge.org/gadgets/
+ about: List of all supported gadget vendors and models in Gadgetbridge.
\ No newline at end of file
diff --git a/.gitea/issue_template/device_request.yml b/.gitea/issue_template/device_request.yml
new file mode 100644
index 0000000..5d50f59
--- /dev/null
+++ b/.gitea/issue_template/device_request.yml
@@ -0,0 +1,79 @@
+name: Device request
+about: Request for a new device/gadget.
+labels:
+ - device request
+body:
+ - type: markdown
+ attributes:
+ value: >
+ You are trying to submit a request for a device implementation into Gadgetbridge.
+ **We cannot make any promise that the device will be implemented**, as adding an implementation
+ for a new device requires either an experienced or a willing to learn developer,
+ ideally with the device at hand. Without that, you may try to submit a device request
+ and see if anyone steps up and implements it and you are basically indicating that you
+ have the device and are willing to help with testing, should anyone try to add
+ this device to Gadgetbridge.
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to make Gadgetbridge better!
+
+ If you just have a question, please ask first in the user chatroom in Matrix at [`#gadgetbridge:matrix.org`](https://matrix.to/#/#gadgetbridge:matrix.org)
+ - type: checkboxes
+ attributes:
+ label: Please confirm that;
+ options:
+ - label: I have checked the [website](https://gadgetbridge.org) and [gadget list](https://gadgetbridge.org/gadgets/), and I didn't find this device mentioned there.
+ required: true
+ - label: I have searched the [issues](https://codeberg.org/Freeyourgadget/Gadgetbridge/issues), and I didn't find this device mentioned there.
+ required: true
+ - label: I have read and understood the [Codeberg Terms of Use](https://codeberg.org/Codeberg/org/src/branch/main/TermsOfUse.md) for images or other type of content that I included here.
+ required: true
+ - type: input
+ id: gadget
+ attributes:
+ label: Which gadget are you requesting support for?
+ description: Please specify name, manufacturer, model and firmware version if possible.
+ placeholder: e.g. ExampleWatch A1 with 0.1 firmware
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: Does this gadget have similarities to other gadgets?
+ description: >
+ If the gadget you request support for has similarities to other gadgets that we currently support,
+ it is more likely for us (still, not guaranteed) to add support for your gadget too.
+ - type: textarea
+ id: info
+ attributes:
+ label: Device information
+ description: >
+ Include all details about the gadget that will be useful to us when implementing a support for this gadget.
+
+ If possible, please include the exact bluetooth name, as displayed while searching for the device.
+
+ Ideally, use an Android Bluetooth scanner app like nRF Connect or BLExplorer and provide screenshots
+ of the scanned gadget from that app. This provides a name and some available UUIDs, which are needed
+ for implementation. You may want to blur MAC addresses for privacy reasons.
+ placeholder: If you want to include logs, don't include it here. Use the next text field for that.
+ validations:
+ required: true
+ - type: textarea
+ id: logs
+ attributes:
+ label: Do you have logs?
+ description: >
+ If possible, please attach [logs](https://gadgetbridge.org/internals/topics/logs/)
+ which might help to implement the device support into Gadgetbridge. This will be automatically
+ formatted into code, so no need for backticks.
+ render: shell
+ - type: dropdown
+ id: source
+ attributes:
+ label: Do you have the gadget and are you able to help us for testing?
+ options:
+ - Yes, I have the gadget and I'm willing to provide further feedback and help testing.
+ - No, I don't have the gadget or/and I'm not able to help during the development.
+ validations:
+ required: true
diff --git a/.gitea/issue_template/feature_request.yml b/.gitea/issue_template/feature_request.yml
new file mode 100644
index 0000000..cdabcef
--- /dev/null
+++ b/.gitea/issue_template/feature_request.yml
@@ -0,0 +1,76 @@
+name: Feature request
+about: Suggest a feature or an idea.
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to make Gadgetbridge better!
+
+ If you just have a question, please ask first in the user chatroom in Matrix at [`#gadgetbridge:matrix.org`](https://matrix.to/#/#gadgetbridge:matrix.org)
+ - type: checkboxes
+ attributes:
+ label: Please confirm that;
+ options:
+ - label: I have checked the [website](https://gadgetbridge.org), and I didn't find a solution to my problem / an answer to my question.
+ required: true
+ - label: I have searched the [issues](https://codeberg.org/Freeyourgadget/Gadgetbridge/issues), and I didn't find a solution to my problem / an answer to my question.
+ required: true
+ - label: I have read and understood the [Codeberg Terms of Use](https://codeberg.org/Codeberg/org/src/branch/main/TermsOfUse.md) for images or other type of content that I included here.
+ required: true
+ - type: dropdown
+ id: source
+ attributes:
+ label: Where did you get Gadgetbridge from?
+ options:
+ - F-Droid
+ - Gadgetbridge Nightly F-Droid repository
+ - Bangle.js Gadgetbridge from the Play Store
+ - I built it myself from source code
+ - I previously used Gadgetbridge from other sources and then updated to F-Droid version
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: What is your Gadgetbridge version?
+ description: |
+ This can be found in "Menu > About > Version" in Gadgetbridge.
+ Also include tag / commit SHA if you built Gadgetbridge from the source.
+ placeholder: e.g. "0.77.0" or "0.77.0-2618adac1" to include commit
+ validations:
+ required: true
+ - type: textarea
+ id: content
+ attributes:
+ label: What is your suggestion/idea?
+ description: >
+ Please note that new requests about already solved/documented topics
+ **could be closed without further comments.** Same for too generic or incomplete reports.
+ placeholder: If you want to include logs, don't include it here. Use the next text field for that.
+ validations:
+ required: true
+ - type: textarea
+ id: logs
+ attributes:
+ label: Do you have logs?
+ description: >
+ If possible, please attach [logs.](https://gadgetbridge.org/internals/topics/logs/)
+ This will be automatically formatted into code, so no need for backticks.
+ render: shell
+ - type: input
+ id: gadget
+ attributes:
+ label: What gadget do you use?
+ description: >
+ Please specify model and firmware version if possible. Leave blank if you believe the
+ issue is not specific to the gadget that you currently use with Gadgetbridge.
+ placeholder: e.g. ExampleWatch A1 with 0.1 firmware
+ - type: input
+ id: android
+ attributes:
+ label: What is your Android version/manufacturer flavor?
+ description: >
+ Android phone manufacturers may customise the Android source code as they wish, so
+ if you are using a phone that running a vendor-exclusive system (like MIUI) or if
+ you use a custom ROM, make sure to also include the name of the OS/ROM.
+ placeholder: e.g. LineageOS 20 based on Android 13
\ No newline at end of file
diff --git a/.gitea/pull_request_template.md b/.gitea/pull_request_template.md
new file mode 100644
index 0000000..021ab4b
--- /dev/null
+++ b/.gitea/pull_request_template.md
@@ -0,0 +1,16 @@
+<!--
+ Thank you for your contribution! 馃帀
+
+ Please make sure that you:
+
+ - Use `git rebase` to bring your branch up to date, do not use a merge commit for that.
+
+ - Do not add translations by editing the language variants of strings.xml as that
+ creates merge conflicts between Codeberg git repo and Weblate git repo,
+ use Weblate https://hosted.weblate.org/projects/freeyourgadget/gadgetbridge/
+
+ - Do not use `e.printStacktrace()`, use slf4j logger or `GB.toast` for logging
+ as per https://gadgetbridge.org/internals/development/project-overview/#logging
+
+ You can erase this comment from PR description, thank you!
+-->
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..89324dd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,43 @@
+# Built application files
+*.apk
+*.ap_
+
+# Files for the Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+.idea/*
+!.idea/icon.svg
+!.idea/dictionaries
+.idea/dictionaries/*
+!.idea/dictionaries/t.xml
+*.iml
+
+MPChartLib
+
+fw.dirs
+**/.project
+**/.settings
+**/.classpath
+app/release/output-metadata.json
+app/.cxx/
+.attach_pid*
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..cc9db98
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,6 @@
+[submodule "jerryscript"]
+ path = external/jerryscript
+ url = https://github.com/jerryscript-project/jerryscript
+[submodule "fossil-hr-gbapps"]
+ path = external/fossil-hr-gbapps
+ url = https://codeberg.org/Freeyourgadget/fossil-hr-gbapps
diff --git a/.idea/dictionaries/t.xml b/.idea/dictionaries/t.xml
new file mode 100644
index 0000000..c82ca81
--- /dev/null
+++ b/.idea/dictionaries/t.xml
@@ -0,0 +1,189 @@
+<component name="ProjectDictionaryState">
+ <dictionary name="t">
+ <words>
+ <w>abettenburg</w>
+ <w>adhikary</w>
+ <w>afanasev</w>
+ <w>affero</w>
+ <w>alaoui</w>
+ <w>alexey</w>
+ <w>alipay</w>
+ <w>amazfit</w>
+ <w>andrewlytvyn</w>
+ <w>aniruddha</w>
+ <w>aplite</w>
+ <w>apre</w>
+ <w>atkyritsis</w>
+ <w>autocrlf</w>
+ <w>avamander</w>
+ <w>baerts</w>
+ <w>baos</w>
+ <w>barraca</w>
+ <w>bedscastle</w>
+ <w>blo脽</w>
+ <w>boun</w>
+ <w>breathwork</w>
+ <w>b枚hler</w>
+ <w>carsten</w>
+ <w>chamorro</w>
+ <w>codeberg</w>
+ <w>criogenic</w>
+ <w>croisez</w>
+ <w>dakhno</w>
+ <w>dantas</w>
+ <w>dikay</w>
+ <w>diorite</w>
+ <w>dodgeball</w>
+ <w>dougal</w>
+ <w>dreamwalker</w>
+ <w>drobni膷</w>
+ <w>dulfo</w>
+ <w>elagin</w>
+ <w>eom盲e</w>
+ <w>ericsson</w>
+ <w>exrizu</w>
+ <w>findphone</w>
+ <w>fran莽ois</w>
+ <w>freeyourgadget</w>
+ <w>gadgetbridge</w>
+ <w>gateball</w>
+ <w>gbapplication</w>
+ <w>getpebble</w>
+ <w>gfdi</w>
+ <w>gide茫o</w>
+ <w>girolamo</w>
+ <w>gobbetti</w>
+ <w>greenberg</w>
+ <w>greenrobot</w>
+ <w>greffier</w>
+ <w>guedes</w>
+ <w>handcycling</w>
+ <w>hasants</w>
+ <w>hauck</w>
+ <w>hiit</w>
+ <w>hplus</w>
+ <w>huami</w>
+ <w>ieee</w>
+ <w>infini</w>
+ <w>inkscape</w>
+ <w>irul</w>
+ <w>itag</w>
+ <w>ivanovlev</w>
+ <w>jcrode</w>
+ <w>jes煤s</w>
+ <w>jianxin</w>
+ <w>jonnsoft</w>
+ <w>jos茅</w>
+ <w>jo茫o</w>
+ <w>julien</w>
+ <w>jumpmaster</w>
+ <w>junginger</w>
+ <w>jyou</w>
+ <w>kabaddi</w>
+ <w>kasha</w>
+ <w>kaushan</w>
+ <w>keeshii</w>
+ <w>kitesurfing</w>
+ <w>kompact</w>
+ <w>kranz</w>
+ <w>kromke</w>
+ <w>kronoz</w>
+ <w>lacross</w>
+ <w>ladbsoft</w>
+ <w>ladera</w>
+ <w>lenovo</w>
+ <w>liebergeld</w>
+ <w>lightforce</w>
+ <w>lineageos</w>
+ <w>liveview</w>
+ <w>lywsd</w>
+ <w>makibes</w>
+ <w>matej</w>
+ <w>maufl</w>
+ <w>maurer</w>
+ <w>maxirnilian</w>
+ <w>miband</w>
+ <w>mijia</w>
+ <w>morpheuz</w>
+ <w>mosenkovs</w>
+ <w>multisport</w>
+ <w>nephiel</w>
+ <w>nodomain</w>
+ <w>nordh酶y</w>
+ <w>normano</w>
+ <w>novotny</w>
+ <w>oraclejdk</w>
+ <w>paddleboarding</w>
+ <w>padel</w>
+ <w>pebblekit</w>
+ <w>pfeiffer</w>
+ <w>pickleball</w>
+ <w>pinetime</w>
+ <w>pivotto</w>
+ <w>postsorino</w>
+ <w>protomors</w>
+ <w>qhybrid</w>
+ <w>quallenauge</w>
+ <w>realme</w>
+ <w>rebelo</w>
+ <w>roidmi</w>
+ <w>romanization</w>
+ <w>rssi</w>
+ <w>sami</w>
+ <w>schrecker</w>
+ <w>sepak</w>
+ <w>sergey</w>
+ <w>sevostyanova</w>
+ <w>shahrabani</w>
+ <w>shimokawa</w>
+ <w>skype</w>
+ <w>slezak</w>
+ <w>sophanimus</w>
+ <w>spalding</w>
+ <w>spotify</w>
+ <w>stacktraces</w>
+ <w>stefanek</w>
+ <w>stringset</w>
+ <w>subsport</w>
+ <w>szymon</w>
+ <w>taavi</w>
+ <w>tablename</w>
+ <w>takraw</w>
+ <w>teclast</w>
+ <w>tiparega</w>
+ <w>toleda</w>
+ <w>tomasz</w>
+ <w>tomer</w>
+ <w>transifex</w>
+ <w>trekvolle</w>
+ <w>trofimov</w>
+ <w>tysiak</w>
+ <w>uart</w>
+ <w>uint</w>
+ <w>utsob</w>
+ <w>uuids</w>
+ <w>vadim</w>
+ <w>vanous</w>
+ <w>vebryn</w>
+ <w>veneziano</w>
+ <w>vibratissimo</w>
+ <w>wakeboarding</w>
+ <w>wakesurfing</w>
+ <w>walkjivefly</w>
+ <w>watchapp</w>
+ <w>watchapps</w>
+ <w>watchface</w>
+ <w>watchfaces</w>
+ <w>wechat</w>
+ <w>whatsapp</w>
+ <w>xiaomi</w>
+ <w>xplus</w>
+ <w>xwatch</w>
+ <w>yaron</w>
+ <w>zalewszczak</w>
+ <w>zepp</w>
+ <w>zetime</w>
+ <w>zhong</w>
+ </words>
+ </dictionary>
+</component>
\ No newline at end of file
diff --git a/.idea/icon.svg b/.idea/icon.svg
new file mode 100644
index 0000000..248c776
--- /dev/null
+++ b/.idea/icon.svg
@@ -0,0 +1,574 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="5.6444445mm"
+ height="5.6444445mm"
+ viewBox="-1 -1 22 22"
+ id="svg2"
+ version="1.1"
+ inkscape:version="0.92.1 r15371"
+ sodipodi:docname="template1.svg"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648">
+ <title
+ id="title4560">Gadgetbridge Icon</title>
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="11.2"
+ inkscape:cx="-40.722947"
+ inkscape:cy="18.815618"
+ inkscape:document-units="px"
+ inkscape:current-layer="layer1"
+ showgrid="false"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ inkscape:window-x="-8"
+ inkscape:window-y="-8"
+ inkscape:window-maximized="1"
+ inkscape:snap-grids="true"
+ inkscape:snap-to-guides="false"
+ inkscape:snap-page="true">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4138" />
+ <sodipodi:guide
+ position="10,18"
+ orientation="0,1"
+ id="guide4140"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,2"
+ orientation="0,1"
+ id="guide4142"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="2,10"
+ orientation="1,0"
+ id="guide4144"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="0,0"
+ orientation="0,20"
+ id="guide4146"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="20,0"
+ orientation="-20,0"
+ id="guide4148"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="20,20"
+ orientation="0,-20"
+ id="guide4150"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="0,20"
+ orientation="20,0"
+ id="guide4152"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="17.934153,10.890707"
+ orientation="1,0"
+ id="guide4154"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="8.5170198,7.5753346"
+ orientation="-0.70710678,0.70710678"
+ id="guide4156"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="4,10"
+ orientation="1,0"
+ id="guide4160"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="16,10"
+ orientation="1,0"
+ id="guide4162"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,4"
+ orientation="0,1"
+ id="guide4166"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,15"
+ orientation="0,1"
+ id="guide4168"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,5"
+ orientation="0,1"
+ id="guide4170"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="5,10"
+ orientation="1,0"
+ id="guide4172"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="15,10"
+ orientation="1,0"
+ id="guide4174"
+ inkscape:locked="false" />
+ </sodipodi:namedview>
+ <defs
+ id="defs4">
+ <linearGradient
+ inkscape:collect="always"
+ id="linearGradient13455">
+ <stop
+ style="stop-color:#000000;stop-opacity:0.2"
+ offset="0"
+ id="stop13457" />
+ <stop
+ style="stop-color:#000000;stop-opacity:0;"
+ offset="1"
+ id="stop13459" />
+ </linearGradient>
+ <clipPath
+ id="clipPath12478"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle12480"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5790">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5792"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath5055"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5057"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5119">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5121"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath5194"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5196"
+ style="opacity:1;fill:#cddc39;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath6175">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle6177"
+ style="opacity:1;fill:#f44336;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath7176"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle7178"
+ style="opacity:1;fill:#9c27b0;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath8165">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle8167"
+ style="opacity:1;fill:#ffc107;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath9162"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle9164"
+ style="opacity:1;fill:#ff9800;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5147">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5149"
+ style="opacity:1;fill:#ff9800;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath6144"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle6146"
+ style="opacity:1;fill:#e91e63;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath7143">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle7145"
+ style="opacity:1;fill:#ffeb3b;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath8148"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle8150"
+ style="opacity:1;fill:#9c27b0;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5332">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5334"
+ style="opacity:1;fill:#f44336;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath6341"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle6343"
+ style="opacity:1;fill:#4caf50;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath7354">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle7356"
+ style="opacity:1;fill:#f44336;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath8344"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle8346"
+ style="opacity:1;fill:#673ab7;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath9436">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle9438"
+ style="opacity:1;fill:#9e9e9e;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath10432"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle10434"
+ style="opacity:1;fill:#795548;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath11433">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle11435"
+ style="opacity:1;fill:#03a9f4;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath12452"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle12454"
+ style="opacity:1;fill:#ffc107;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient13455"
+ id="linearGradient13461"
+ x1="9.9553576"
+ y1="9.9553566"
+ x2="20"
+ y2="20"
+ gradientUnits="userSpaceOnUse" />
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath13465">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle13467"
+ style="opacity:1;fill:#e91e63;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ </defs>
+ <metadata
+ id="metadata7">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title>Gadgetbridge Icon</dc:title>
+ <cc:license
+ rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/" />
+ <dc:date>18/07/2017</dc:date>
+ <dc:creator>
+ <cc:Agent>
+ <dc:title>xphnx</dc:title>
+ </cc:Agent>
+ </dc:creator>
+ <dc:rights>
+ <cc:Agent>
+ <dc:title>CC (BY-SA)</dc:title>
+ </cc:Agent>
+ </dc:rights>
+ <dc:description>Launcher icon for Gadgetbridge. Based on Blue Minimal Template (GPLv3)</dc:description>
+ <dc:source>https://github.com/scoute-dich/Blue-Minimal/tree/master/IconTemplates</dc:source>
+ <dc:contributor>
+ <cc:Agent>
+ <dc:title>xphnx based on a work of scoute-dich</dc:title>
+ </cc:Agent>
+ </dc:contributor>
+ </cc:Work>
+ <cc:License
+ rdf:about="http://creativecommons.org/licenses/by-sa/4.0/">
+ <cc:permits
+ rdf:resource="http://creativecommons.org/ns#Reproduction" />
+ <cc:permits
+ rdf:resource="http://creativecommons.org/ns#Distribution" />
+ <cc:requires
+ rdf:resource="http://creativecommons.org/ns#Notice" />
+ <cc:requires
+ rdf:resource="http://creativecommons.org/ns#Attribution" />
+ <cc:permits
+ rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
+ <cc:requires
+ rdf:resource="http://creativecommons.org/ns#ShareAlike" />
+ </cc:License>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:label="Hintergrund"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(1058.1428,-861.93365)">
+ <circle
+ inkscape:export-ydpi="648"
+ inkscape:export-xdpi="648"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ style="opacity:1;fill:#0091ea;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="path4136"
+ cx="-1048.1428"
+ cy="871.93365"
+ r="10" />
+ <path
+ style="fill:url(#linearGradient13461);fill-opacity:1;stroke-width:0.2;stroke-linejoin:round"
+ d="M 6.134855,7.8575793 C 5.5887444,7.7737975 8.9732,11.36764 8.9732,11.91964 c 0.7720204,2.953627 -0.3190754,2.368775 -1.3911929,4.54893 0,0.314113 2.8336269,3.63544 3.0589809,3.818814 0.04391,0.05518 0.09008,0.103734 0.144531,0.148437 0.04494,0.0568 0.09237,0.106597 0.148438,0.152344 0.04462,0.05589 0.09299,0.10335 0.148437,0.148437 0.0433,0.05399 0.08913,0.10065 0.142578,0.144532 0.04401,0.05535 0.08991,0.103622 0.144532,0.148437 0.04462,0.05589 0.09299,0.10335 0.148437,0.148438 0.04281,0.05303 0.08799,0.09936 0.140625,0.142578 0.0433,0.05399 0.08913,0.100649 0.142578,0.144531 0.0429,0.05343 0.08778,0.101002 0.140625,0.144531 0.04305,0.0535 0.08955,0.09904 0.142578,0.142578 0.04259,0.05283 0.08633,0.09947 0.138672,0.142579 0.04241,0.05266 0.08853,0.09762 0.140625,0.140624 0.04231,0.05246 0.08674,0.09775 0.138672,0.140626 0.04224,0.05233 0.08685,0.09785 0.138672,0.140624 0.04196,0.05198 0.0853,0.09806 0.136719,0.140626 0.04204,0.05191 0.218042,0.230956 0.269531,0.273437 0.04036,0.0496 -0.04904,-0.04097 0,0 0.04207,0.05223 0.08706,0.09788 0.138672,0.140625 0.04161,0.05109 1.939454,1.964844 0.136719,0.136719 0.0402,0.04908 0.673987,-1.491056 0.722706,-1.450484 0.0407,0.05002 0.08329,0.09352 0.132812,0.134766 0.03972,0.04878 -0.511067,1.675676 -0.46294,1.716108 0.04056,0.05008 0.08344,0.09344 0.132812,0.134766 0.04069,0.04997 0.08332,0.09357 0.132813,0.134766 0.03936,0.0479 0.08141,0.08913 0.128906,0.128906 0.03997,0.04887 0.0805,0.09236 0.128907,0.132812 0.0394,0.04798 0.208293,0.219929 0.255859,0.259766 0.03933,0.04803 -0.0475,-0.0399 0,0 0.03941,0.04797 0.08133,0.08909 0.128907,0.128906 0.03805,0.04609 0.07742,0.08645 0.123046,0.125 0.03972,0.04853 0.08085,0.09063 0.128907,0.13086 0.03812,0.04614 0.07734,0.08643 0.123047,0.125 0.03891,0.04709 0.08015,0.08776 0.126953,0.126953 0.03709,0.04468 0.0749,0.08353 0.11914,0.121094 0.03888,0.04724 0.07819,0.08957 0.125,0.128906 0.03698,0.0444 0.0751,0.08376 0.119141,0.121094 0.03744,0.0452 0.07636,0.08512 0.121094,0.123046 0.939453,0.953125 0.07678,0.08344 0.121094,0.121094 0.03702,0.04459 0.075,0.0836 0.11914,0.121094 0.03695,0.04448 0.0751,0.08368 0.119141,0.121094 0.03643,0.04385 0.07387,0.08215 0.117187,0.11914 0.03701,0.04454 0.07503,0.08364 0.119141,0.121094 0.03593,0.0429 0.07267,0.08091 0.115234,0.117188 0.03646,0.04374 0.07386,0.08225 0.117188,0.11914 0.03472,0.04132 0.07043,0.07812 0.111328,0.113281 0.03672,0.04408 0.07545,0.08202 0.119141,0.119141 0.159451,0.01975 0.495139,0.310768 0.33789,0.34375 0.03367,0.03982 0.06799,0.0753 0.107422,0.109375 0.03567,0.04259 0.07302,0.07917 0.115234,0.115234 0.03376,0.03984 0.06789,0.07531 0.107422,0.109375 0.03482,0.04131 0.07033,0.07814 0.111328,0.113282 0.03318,0.03905 0.06673,0.07392 0.105469,0.107422 1.997761,0.694544 9.103709,2.908451 10.44526,2.898372 h 0.002 c 0.275418,0 0.52417,-0.112641 0.705078,-0.292969 C 28.886751,29.72516 29,29.4758 29,29.199219 L 27.879654,21.07464 h -0.002 l -0.002,-0.002 -0.002,-0.002 v -0.002 l -0.0039,-0.002 -0.0039,-0.0059 -0.0039,-0.0039 -0.0059,-0.0059 -0.0059,-0.0059 -0.0078,-0.0078 -0.0078,-0.0078 -0.0078,-0.0078 -0.0098,-0.0098 -0.0098,-0.0098 -0.01172,-0.01172 -0.01172,-0.01172 -0.01172,-0.01172 -0.01367,-0.01367 -0.01367,-0.01367 -0.01367,-0.01563 -0.01562,-0.01563 -0.01563,-0.01563 -0.01758,-0.01563 -0.01758,-0.01953 -0.01758,-0.01758 -0.01953,-0.01953 -0.01953,-0.01953 -0.01953,-0.01953 v -0.002 l -0.02148,-0.02148 -0.02148,-0.02148 -0.02344,-0.02344 -0.02344,-0.02344 -0.02344,-0.02344 -0.02539,-0.02539 -0.02539,-0.02734 -0.02539,-0.02539 -0.02734,-0.02734 -0.02734,-0.0293 -0.0293,-0.0293 -0.0293,-0.0293 -0.03125,-0.0293 -0.0293,-0.03125 -0.03125,-0.0332 -0.0332,-0.0332 -0.0332,-0.0332 -0.0332,-0.0332 -0.03516,-0.03516 -0.03516,-0.03516 -0.03516,-0.03711 -0.03711,-0.03711 -0.03711,-0.03906 -0.03906,-0.03711 -0.03906,-0.04102 -0.03906,-0.03906 -0.04102,-0.04102 -0.04102,-0.04297 -0.04297,-0.04102 -0.04102,-0.04102 v -0.002 -0.002 l -0.04492,-0.04297 -0.04297,-0.04492 -0.04492,-0.04492 -0.04687,-0.04687 -0.04492,-0.04687 -0.04883,-0.04883 -0.04687,-0.04687 -0.04883,-0.05078 -0.04883,-0.04883 -0.05078,-0.05078 -0.05078,-0.05273 -0.05078,-0.05078 -0.05273,-0.05469 -0.05273,-0.05273 -0.05469,-0.05469 -0.05469,-0.05469 -0.05469,-0.05664 -0.05664,-0.05664 -0.05664,-0.05859 -0.05859,-0.05859 -0.05664,-0.05859 -0.06055,-0.05859 -0.05859,-0.06055 -0.06055,-0.0625 -0.0625,-0.0625 -0.06055,-0.0625 -0.0625,-0.0625 -0.06445,-0.06445 -0.06445,-0.06641 -0.06445,-0.06445 -0.06641,-0.06641 -0.06641,-0.06836 -0.06641,-0.06836 -0.06836,-0.06836 -0.06836,-0.07031 -0.07031,-0.07031 -0.07031,-0.07031 -0.07031,-0.07227 -0.07227,-0.07227 -0.07227,-0.07422 -0.07227,-0.07422 -0.07422,-0.07422 -0.07422,-0.07617 -0.07617,-0.07617 -0.07617,-0.07617 -0.07617,-0.07813 -0.07813,-0.07813 -0.07813,-0.08008 -0.07813,-0.08008 -0.08008,-0.08008 -0.08203,-0.08203 -0.08008,-0.08203 -0.08203,-0.08398 -0.08398,-0.08398 -0.08203,-0.08398 -0.08398,-0.08594 -0.08594,-0.08594 -0.08594,-0.08789 -0.08594,-0.08594 -0.08789,-0.08984 -0.08789,-0.08789 -0.08789,-0.08984 -0.08984,-0.0918 -0.08984,-0.0918 -0.0918,-0.0918 -0.0918,-0.0918 -0.0918,-0.09375 -0.09375,-0.0957 -0.09375,-0.0957 -0.09375,-0.0957 -0.0957,-0.0957 -0.09766,-0.09766 -0.0957,-0.09766 -0.09766,-0.09961 -0.09961,-0.09961 -0.09766,-0.09961 -0.09961,-0.101562 L 21.012211,14.117126 20.910649,14.01361 20.809087,13.910094 20.705571,13.806579 20.602055,13.70111 20.49854,13.595641 20.393071,13.488219 20.287602,13.380797 20.18018,13.273376 20.072758,13.164 19.965337,13.054626 19.855961,12.94525 19.851219,12.854848 19.741843,12.74352 19.630515,12.630239 19.519187,12.516958 19.405906,12.403676 19.292625,12.288442 19.179343,12.173208 19.064109,12.05602 18.948875,11.940786 18.831687,11.821645 18.7145,11.704458 18.597312,11.585317 18.480125,11.46813 v -0.0039 l -0.119141,-0.121093 -0.121094,-0.121094 -0.121093,-0.123047 -0.121094,-0.123047 -0.121094,-0.123047 -0.123047,-0.125 -0.125,-0.125 -0.123047,-0.125 -0.125,-0.126953 -0.126953,-0.128906 -0.126953,-0.126953 -0.126953,-0.1289072 -0.126953,-0.126953 v -0.0039 L 16.744052,9.7049408 16.613192,9.5740803 16.484286,9.4432213 16.353427,9.3104083 16.220614,9.1756433 16.087802,9.0408773 15.954989,8.9061124 15.820224,8.7713464 15.685458,8.6346274 15.550692,8.4998624 v -0.0039 l -0.136718,-0.136725 -0.136719,-0.138672 -0.136719,-0.136719 v -0.0039 L 15.001864,7.9393154 14.861239,7.7986902 14.722567,7.6580652 14.581942,7.5174402 v -0.00391 L 14.439364,7.3709558 14.296786,7.2264245 14.154208,7.0818933 v 0 L 14.007009,6.9923893 13.395553,6.3374386 12.445852,5.7116899 11.682337,4.9970274 11.461514,4.8293121 10.294776,3.8349929 C 9.7457728,3.3921062 9.0255387,4.1644713 9.0255387,4.1644713 L 7.8983224,5.0493128 C 7.7279474,5.1830537 9.7681952,6.5418811 9.4189571,6.5258336 9.1783033,6.5147756 7.9346571,5.7850447 7.4996252,5.5958611 6.9046896,5.3371397 6.6803727,7.9412701 6.134855,7.8575793 Z"
+ transform="translate(-1058.1428,861.93365)"
+ id="path12581"
+ clip-path="url(#clipPath13465)"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="sccccccccccccccccccccccccccccccccccccccccccccccccccccccccccsccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccssss" />
+ <path
+ inkscape:connector-curvature="0"
+ id="path12456"
+ d="m -1050.6874,868.40686 0.1339,3.79465 0.2678,0.89285 1.1161,0.75893 1.5179,0.0446 1.6071,-0.40178 0.5357,-1.25 -0.1339,-3.83929 -4.9107,0 z"
+ style="fill:none;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+ <g
+ style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-opacity:0.5098038"
+ id="g6427"
+ transform="matrix(0.10900039,0,0,0.10900039,-1058.4564,854.81407)">
+ <g
+ transform="translate(-0.19198408,15.28344)"
+ id="g5859"
+ style="fill:#ff5000;fill-opacity:1">
+ <g
+ id="g4591"
+ transform="matrix(0.96469159,0,0,0.96469159,3.3476544,3.8272566)">
+ <g
+ style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-opacity:0.5098038"
+ id="g4600"
+ transform="matrix(2.0186569,0,0,2.0186569,394.57822,-108.82609)">
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke-width:0.26458332"
+ d="m -136.77732,155.22239 -0.52821,-12.65678 c -0.18542,-8.88992 7.31203,-16.43236 9.31124,-23.07234 -2.12299,2.02696 -6.52318,11.59925 -11.09114,10.79579 -2.54776,-0.85577 -3.35187,-2.05496 -4.38456,-4.23069 -3.42275,0.6668 -6.14301,-1.35553 -7.13076,-3.79658 l 0.19658,9.86107 -4.46061,-12.79348 c -3.48621,-1.12814 -6.68377,-6.4807 -3.47994,-5.86765 l 5.75274,1.10079 c 3.93852,0.82896 6.15619,-4.4975 2.63121,-7.26229 -0.7499,-0.53152 -8.27096,-4.04173 -9.68588,-4.44078 -1.26974,-0.3581 -2.78089,0.14533 -3.75024,1.24936 l -5.85918,10.88112 c -0.60845,0.8393 -0.61164,1.71753 -0.25253,2.49043 l 10.72151,21.51532 c 0.34668,0.65416 0.17252,2.20422 0.17823,2.73721 l -1.28045,13.4895 z m 8.9707,-38.19102 c 1.9434,-2.20392 -1.65635,-8.33602 -3.1421,-6.55824 l -9.61026,12.58549 c -2.81396,3.5023 1.43178,6.96399 4.448,4.80543 0.26564,-0.24803 0.41253,-0.37935 0.60397,-0.63064 z m -4.69458,-9.5376 c -1.42924,-1.48182 -4.34931,-3.63245 -4.73609,-3.48403 l -10.71091,14.46057 c -2.88854,2.81736 0.36005,6.17635 3.51209,5.84536 0.43402,-0.12765 0.58795,-0.21146 0.85842,-0.46309 l 11.1656,-14.42916 c 0.4913,-0.6349 0.32485,-1.41907 -0.0891,-1.92965 z m -6.36258,-4.6579 c -0.10642,-0.74107 -4.82207,-4.542483 -5.18836,-4.06928 l -5.44048,7.02851 c 6.87574,7.48663 -1.14912,12.14096 -5.91293,9.41949 0.59683,1.91529 4.16535,3.90113 5.27266,2.44458 z m -6.5656,-5.245518 c -1.03574,-2.192098 -5.14677,-3.858126 -7.00157,-3.15132 l -5.88314,7.251058 7.38969,3.05534 z"
+ id="path4606"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccscccccccccccccccccccccscccsccscccccc" />
+ </g>
+ <rect
+ ry="6.5778751"
+ y="175.19803"
+ x="69.227318"
+ height="23.844799"
+ width="52.825401"
+ id="rect4610"
+ style="opacity:1;fill:#ff5000;fill-opacity:1;stroke:none;stroke-width:16.03646088;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.5098038" />
+ <rect
+ ry="4.0110912"
+ y="179.85034"
+ x="82.062675"
+ height="14.540206"
+ width="27.154684"
+ id="rect4610-3"
+ style="opacity:1;fill:#1359ff;fill-opacity:1;stroke:none;stroke-width:8.97837925;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.5098038" />
+ </g>
+ </g>
+ </g>
+ </g>
+ <g
+ style="display:inline"
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Icon">
+ <path
+ d="m 3.9999777,4.0333382 12.0000003,0 0,11.9333328 -12.0000003,0 z"
+ id="path4"
+ inkscape:connector-curvature="0"
+ style="fill:none" />
+ <path
+ style="fill:none"
+ inkscape:connector-curvature="0"
+ id="path9956"
+ d="m 18.999978,19.033339 12,0 0,11.933333 -12,0 z" />
+ </g>
+</svg>
diff --git a/.woodpecker/can_master_build.yml b/.woodpecker/can_master_build.yml
new file mode 100644
index 0000000..fb239d3
--- /dev/null
+++ b/.woodpecker/can_master_build.yml
@@ -0,0 +1,19 @@
+steps:
+ build:
+ image: codeberg.org/freeyourgadget/android-fdroid-tools:33.0.0
+ commands:
+ - pwd #bump
+ - ./gradlew assembleDebug --stacktrace
+
+#when:
+#repo: Freeyourgadget/Gadgetbridge
+#branch: master
+#this doesn't work yet:
+#https://github.com/woodpecker-ci/woodpecker/issues/687
+
+when:
+ # Everything except cron (nightly) and deployment
+ - event: push
+ - event: pull_request
+ - event: tag
+ - event: manual
diff --git a/.woodpecker/nightly.yml b/.woodpecker/nightly.yml
new file mode 100644
index 0000000..2cd24f4
--- /dev/null
+++ b/.woodpecker/nightly.yml
@@ -0,0 +1,140 @@
+clone:
+ git:
+ image: woodpeckerci/plugin-git
+ settings:
+ partial: false
+
+steps:
+ check-if-released:
+ image: codeberg.org/freeyourgadget/android-fdroid-tools:33.0.0
+ secrets:
+ - hc_url_nightly_deploy
+ commands: |
+ curl -fsS -m 10 --retry 5 "$${HC_URL_NIGHTLY_DEPLOY}/start"
+
+ RELEASED_VERSION_CODE=$(curl https://freeyourgadget.codeberg.page/fdroid/repo/index-v1.json | jq '.packages."nodomain.freeyourgadget.gadgetbridge.nightly"[0].versionCode')
+ CURRENT_VERSION_CODE=$(git rev-list HEAD --count)
+
+ echo $CURRENT_VERSION_CODE
+ echo $RELEASED_VERSION_CODE
+ if [ "$RELEASED_VERSION_CODE" = "$CURRENT_VERSION_CODE" ]
+ then
+ echo "This version is already released, we quit!"
+ touch .do_not_build
+ curl -fsS -m 10 --retry 5 "$${HC_URL_NIGHTLY_DEPLOY}"
+ fi
+
+
+ build-apks:
+ image: codeberg.org/freeyourgadget/android-fdroid-tools:33.0.0
+ commands:
+ - if [ -f .do_not_build ]; then return 0; fi
+ # build the apks
+ - echo "$${SIGNING_KEY}" | base64 -d > app/keystore.p12
+ - ./gradlew assembleMainlineNightly -Dnightly_store_file="keystore.p12" -Dnightly_store_password="$${KEYSTOREPASS}" -Dnightly_key_alias="gadgetbridge" -Dnightly_key_password="$${KEYPASS}"
+ - ./gradlew assembleMainlineNopebble -Dnightly_store_file="keystore.p12" -Dnightly_store_password="$${KEYSTOREPASS}" -Dnightly_key_alias="gadgetbridge" -Dnightly_key_password="$${KEYPASS}"
+ - ./gradlew assembleBanglejsNightly -Dnightly_store_file="keystore.p12" -Dnightly_store_password="$${KEYSTOREPASS}" -Dnightly_key_alias="gadgetbridge" -Dnightly_key_password="$${KEYPASS}"
+ secrets: [ signing_key, keystorepass, keypass ]
+
+ pull-repo-config:
+ image: codeberg.org/freeyourgadget/android-fdroid-tools:33.0.0
+ commands:
+ - if [ -f .do_not_build ]; then return 0; fi
+ - cd ..
+ - pwd
+ - mkdir repoconfig
+ - cd repoconfig
+ - pwd
+ - git clone --depth 1 https://codeberg.org/Freeyourgadget/fdroid-repo-config.git
+ - ./fdroid-repo-config/repoconfig/copy_files_to_repo_config.sh
+ secrets: [ commit_token ]
+
+ pull-pages:
+ image: codeberg.org/freeyourgadget/android-fdroid-tools:33.0.0
+ commands:
+ - if [ -f .do_not_build ]; then return 0; fi
+ # prepare the pages repo, so fdroidclient can push files into it later
+ - cd ..
+ - pwd
+ - mkdir pages
+ - cd pages
+ - pwd
+ - git clone --depth 2 https://codeberg.org/Freeyourgadget/pages.git
+ - cd pages
+ - git config user.name "CODEBERG CI"
+ - git config user.email "noreply@nodomain.nodomain"
+ # reset and remove, later force push, to keep repo size small
+ - git reset --soft HEAD~1
+ - git status
+ #- rm -f fdroid/repo/*apk
+
+ update-fdroid-data:
+ image: codeberg.org/freeyourgadget/android-fdroid-tools:33.0.0
+ commands:
+ - if [ -f .do_not_build ]; then return 0; fi
+ - pwd
+ - ../repoconfig/fdroid-repo-config/repoconfig/prepare_changelog.sh
+ - cd ../repoconfig/fdroid-repo-config/repoconfig
+ # get secrets from storage
+ - echo "$${SIGNING_KEY}" | base64 -d > keystore.p12
+ - sed -i "s#KEYSTOREPASS_PLACEHOLDER#$${KEYSTOREPASS}#g" config.yml
+ - sed -i "s#KEYPASS_PLACEHOLDER#$${KEYPASS}#g" config.yml
+ - fdroid update
+ - cp ./repo/nodomain.freeyourgadget.gadgetbridge.nightly/en-US/icon.png ./repo/icons/icon.png
+ - python3 repo_listing.py > listing.html
+ - sed -i "s#</body>#LISTING\n</body>#" ./repo/index.html
+ - sed -i -e "/LISTING/r listing.html" -e '//d' ./repo/index.html
+ - fdroid deploy
+ - ls -laR repo
+ secrets: [ signing_key, keystorepass, keypass ]
+
+ push-pages-update-repo:
+ image: codeberg.org/freeyourgadget/android-fdroid-tools:33.0.0
+ commands:
+ - if [ -f .do_not_build ]; then return 0; fi
+ - cd ../pages/pages/
+ - git add -A
+ - git status
+ - git diff
+ - ls -lsR fdroid/repo
+ - git commit -m "autodeploy, $${DRONE_COMMIT_MESSAGE}"
+ - git remote remove origin
+ - git remote add origin https://"$${COMMIT_TOKEN}"@codeberg.org/Freeyourgadget/pages.git
+ # force push as we removed all data
+ - git push -f origin master
+ - cd ../../
+ - pwd
+ - ls -la
+ - ./repoconfig/fdroid-repo-config/repoconfig/hyperlink.sh https://freeyourgadget.codeberg.page/fdroid/repo/
+ # bump
+ secrets: [ commit_token ]
+
+ on-success:
+ image: codeberg.org/freeyourgadget/android-fdroid-tools:33.0.0
+ commands:
+ - curl -fsS -m 10 --retry 5 "$${HC_URL_NIGHTLY_DEPLOY}"
+ when:
+ status: success
+ secrets:
+ - hc_url_nightly_deploy
+
+ on-error:
+ image: codeberg.org/freeyourgadget/android-fdroid-tools:33.0.0
+ commands:
+ - curl -fsS -m 10 --retry 5 "$${HC_URL_NIGHTLY_DEPLOY}/fail"
+ - cd ..
+ - mkdir -p repoconfig
+ - cd repoconfig
+ - git clone --depth 1 https://codeberg.org/Freeyourgadget/fdroid-repo-config.git || true
+ - ./fdroid-repo-config/repoconfig/create_issue_comment.sh "CI failed"
+ when:
+ status: failure
+ secrets:
+ - hc_url_nightly_deploy
+ - commit_token
+
+when:
+ - event: cron
+ - event: deployment
+ branch: master
+ evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "nightly"'
diff --git a/.woodpecker/run_lint.yml b/.woodpecker/run_lint.yml
new file mode 100644
index 0000000..83a6501
--- /dev/null
+++ b/.woodpecker/run_lint.yml
@@ -0,0 +1,19 @@
+steps:
+ lint:
+ image: codeberg.org/freeyourgadget/android-fdroid-tools:33.0.0
+ commands:
+ - pwd #bump
+ - ./gradlew lint
+
+#when:
+#repo: Freeyourgadget/Gadgetbridge
+#branch: master
+#this doesn't work yet:
+#https://github.com/woodpecker-ci/woodpecker/issues/687
+
+when:
+ # Everything except cron (nightly) and deployment
+ - event: push
+ - event: pull_request
+ - event: tag
+ - event: manual
diff --git a/.woodpecker/run_tests.yml b/.woodpecker/run_tests.yml
new file mode 100644
index 0000000..cf5717f
--- /dev/null
+++ b/.woodpecker/run_tests.yml
@@ -0,0 +1,19 @@
+steps:
+ tests:
+ image: codeberg.org/freeyourgadget/android-fdroid-tools:33.0.0
+ commands:
+ - pwd #bump
+ - ./gradlew :app:testBangleDebugUnitTest :app:testMainDebugUnitTest
+
+#when:
+#repo: Freeyourgadget/Gadgetbridge
+#branch: master
+#this doesn't work yet:
+#https://github.com/woodpecker-ci/woodpecker/issues/687
+
+when:
+ # Everything except cron (nightly) and deployment
+ - event: push
+ - event: pull_request
+ - event: tag
+ - event: manual
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..bc6582c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,2480 @@
+### Changelog
+
+#### 0.83.0
+* Add first start onboarding screen
+* Initial support for Bowers and Wilkins P Series
+* Initial support for Casio ECB-S100
+* Initial support for Colmi R09
+* Initial support for Freebuds 5i
+* Initial support for Garmin Fenix 6S Pro / 7X, Forerunner 55 / 235 / 620, Instinct 2
+* Initial support for Huawei Band 2 / 2 Pro / 3 Pro
+* Initial support for Oppo Enco Air / Air2
+* Initial support for Realme Buds T110
+* Initial support for Redmi Buds 5 Pro
+* Initial support for Xiaomi Smart Band 9 Pro
+* Initial support for Marstek B2500
+* Add calories charts and widgets
+* Add more workout icons
+* About screen: Copy build details on tap
+* Amazfit Bip 3 Pro: Fix title and sender on some notifications
+* Bangle.js: Fix calendar sync
+* Bangle.js: Fix call notification in Turkish locale
+* Bangle.js: Support sending activity type from Bangle
+* Casio GBX-100: Fix notification title
+* Charts: Display HR measurement gaps
+* Colmi R0x: Fix occasional crash on disconnection
+* Fix crash in some chart pages
+* Fix heart rate charts average and maximum value
+* Fix imperial unit on steps charts
+* Fix notifications after a notification is received with a time in the future
+* Fossil/Skagen Hybrids: Add SpO2 support
+* Fossil/Skagen Hybrids: Fix erroneous watchface downgrade
+* Garmin: Add intensity minutes, respiratory rate, sleep score
+* Garmin: Display AGPS age
+* Garmin: Fix weather temperature conversion to celsius
+* Garmin: Persist sleep score and metabolic rate
+* Garmin: Send notification pictures
+* Huawei Band 3 pro: Fix notifications
+* Huawei: Add HR zones configuration for non-P2P devices
+* Huawei: Add temperature chart
+* Huawei: Allow more languages to be set on the watch
+* Huawei: Display active calories
+* Huawei: Display high-resolution heart rate
+* Huawei: Fix watchface upload for some watches
+* Huawei: Improve activity parsing
+* Huawei: Music management
+* Huawei: Send weather error if there is no data
+* Huawei: Sync blood pressure if supported
+* Huawei: Sync skin temperature
+* Restore sleep balance on weekly and monthly charts
+* Test device: Add dummy activities
+* Workout page: Add colors to HR zones
+* Xiaomi Smart Band 8 Active: Fix freestyle and walking workout parsing
+* Xiaomi Smart Band 9: Fix outdoor cycling parsing
+* Xiaomi SPPv2: Fix message processing getting stuck after exception
+* Xiaomi-protobuf: Add resting heart rate
+* Xiaomi-protobuf: Fix activity sync stuck on duplicated or invalid files
+* Xiaomi-protobuf: Improve workout parsing
+* Xiaomi-protobuf: Persist RR intervals during sleep
+* Zepp OS: Add sleep respiratory rate chart
+* Zepp OS: Send notification pictures
+
+#### 0.82.1
+* Huawei: Improve activity parsing
+* Huawei Watch GT: Fix connection failure
+* Withings: Fix crash on connection
+* Improve Armenian transliterator for mixed-case words
+
+#### 0.82.0
+* Initial support for Anker Soundcore Liberty 4 NC
+* Initial support for CMF Buds Pro 2 / Watch Pro 2
+* Initial support for Colmi R02/R03/R06/R10 smart rings
+* Initial support for Garmin Enduro 3, Fenix 5/5 Plus/5X Plus/6/6S Sapphire/7/8, Forerunner 165/255/255S Music/245 Music/265S/955/965, Venu/Venu Sq/Venu Sq 2/Venu 2S, Vivoactive 3, Vivomove Trend, Vivosport
+* Initial support for Huawei Watch 3 / 3 Pro / 4 Pro / D2 / GT 3 SE / GT 5 / GT 5 Pro / GT Cyber / GT Runner
+* Initial support for Honor Watch GS 3 / Watch GS Pro
+* Initial support for IKEA desk controller
+* Initial support for Moondrop Space Travel
+* Initial support for Mijia XMWSDJ04MMC
+* Initial support for Mi Smart Scale 2
+* Initial support for Sony WF-C500 / WF-C700N
+* Initial support for Soundcore Motion 300
+* Initial support for Vivitar HR & BP Monitor Activity Tracker
+* Experimental support for Amazfit T-Rex 3
+* Experimental support for Redmi Watch 5 Active
+* Experimental support for Xiaomi Smart Band 9
+* Experimental support for Xiaomi Watch S3
+* Add all languages supported in weblate
+* Add BLE intent API
+* Add configuration for calendar lookahead
+* Add month and day to date of birth
+* Add more activity types (CMF, Garmin, Huawei, Zepp OS)
+* Allow configuration of notification times
+* Allow syncing birthdays with calendar events
+* Amazfit GTR 2: Enable PAI support
+* AsteroidOS: Fix missing weather day and set-time on connection
+* Bangle.js: Add canned responses
+* Bangle.js: Fix calendar sync setting
+* Bangle.js: Fix distance in activity details
+* Bluetooth Intent API: Add disconnect action
+* Casio GW-B5600: Alarms, find phone, reminders, watch settings
+* Casio: Fix notifications on long messages
+* Change device icons to use theme colors
+* Charts: Add button to pick date
+* Charts: Add charts for HRV, body energy, heart rate, steps, VO2 max, weight
+* Charts: Fix heart rate charts when min is set to 0
+* Charts: Re-design sleep, stress, PAI, workout details
+* Charts: Use HR from workout track file if available
+* CMF Watch Pro 2: Negotiate authentication key
+* CMF Watch Pro: Fix activity transfer
+* Cycling sensor: added live data view
+* Cycling sensor: Improve cycling data display
+* Dashboard: Add new widgets for stress, HRV, body energy
+* Dashboard: Add option to show yesterday's data in Today widget
+* Dashboard: Improve widget gauge resolution
+* Data Management: Add file manager
+* Data Management: Allow browse folders, open and share files
+* Data Management: Allow full backup/restore from a zip file
+* Data Management: Fix import of some preference from a backup
+* Fix activity charts generation for devices that do not report intensity
+* Fix crash when companion pairing
+* Fix discovery of connected devices
+* Fix emoji when some connected device does not support them
+* Fix language not being respected in some situations
+* Fix media controls not working for some apps
+* Fix notification text not being sent for some apps
+* Fix reconnection when device connects back during BLE scan
+* Fossil HR: Fix crash on disconnection
+* Fossil HR: Minor watchface fixes
+* Garmin: Display awake time during sleep
+* Garmin: Display HRV and body energy
+* Garmin: Display resting heart rate
+* Garmin: Fetch SKIP_TEMP files
+* Garmin: Fix agps upload for some URLs
+* Garmin: Fix all-day events
+* Garmin: Fix auto-activity fetch on some devices
+* Garmin: Fix canned replies reset to defaults
+* Garmin: Fix crash on call with privacy mode on
+* Garmin: Fix crash on timezones without DST
+* Garmin: Fix daily weather missing current day
+* Garmin: Fix weather temperature and speed units
+* Garmin: Improve activity, sleep and workout parsing
+* Garmin: Infer sleep time for devices that do not send sleep stages
+* Garmin: Manual HR measurements and live activity
+* Garmin: Map some unknown realtime settings
+* Garmin: Parse workout physiological metrics, strength training workout sets
+* Garmin: Re-parse workout summary when opening details page
+* Garmin: Upload gpx and workout fit files to watch
+* Garmin: Use distance and calories provided by the watch
+* Garmin: View and share gpx files
+* Huami: Fetch workouts during normal sync
+* Huami: Migrate all device settings to sub-screens
+* Huawei Band 9: Improved support
+* Huawei: Add battery polling
+* Huawei: Basic support for the installation of the applications
+* Huawei: Calendar sync support
+* Huawei: Contacts uploading support
+* Huawei: Continuous skin temperature measurement switch
+* Huawei: Enable emoji for HarmonyOS watches
+* Huawei: Fix crash when notification has no text
+* Huawei: Fix initialization issues on some watches
+* Huawei: Fix notifications for Huawei Band 4e
+* Huawei: Fix some reconnection issues
+* Huawei: Fix watchface upload, activity sync, event alarms, weather for some devices
+* Huawei: Fix workout altitude, pace, workout re-parsing
+* Huawei: Improve device initialization
+* Huawei: Improve watchface install support
+* Huawei: Improve weather support
+* Huawei: Initial ephemeris update support
+* Huawei: Map more workout types
+* Huawei: Music upload support
+* Huawei: Provide an activity sample every minute
+* Huawei: Re-parse workout details when opening details
+* Huawei: Send default HR zones
+* Huawei: Workout GPS synchronization
+* Huawei: Simple TruSleep support
+* Huawei: Use distance and calories provided by the watch
+* Improve calendar change detection
+* Mi Band: Migrate global preferences to device-specific
+* Mi Composition Scale: Add alternative bluetooth name
+* Mi Composition Scale: Persist and display weight samples
+* Mijia LYWSD/XMWSDJ: Add comfort level preference
+* Pebble: Migrate global preferences to device-specific
+* Redmi Smart Band Pro: Fix crash on connection and activity sync issues
+* Sony Headphones: Enable read aloud incoming notifications and auto call pickup
+* UI: Add new activity icons
+* UI: Fix changelog on device rotation
+* UI: Fix HR samples displayed on wrong device
+* UI: Fix light navbar buttons on light themes for Android 8+
+* UI: Fix pull-down to refresh for some devices
+* UI: Improvements for large screen resolutions, font sizes, landscape
+* UI: Reduce stutters on device changes / data fetch / scrolling
+* UI: Refactor preferences screen
+* UM25C: Fix some disconnection issues
+* Use default system TTS language
+* Xiaomi Protobuf: Allow re-parse activity from storage
+* Xiaomi Protobuf: Enable watchface upload for all devices
+* Xiaomi Protobuf: Show watchface preview
+* Xiaomi Protobuf: Fix watchface install on some watches
+* Xiaomi Protobuf: Fix deleting first widget screen
+* Xiaomi Protobuf: Fix naps
+* Xiaomi Protobuf: Improve workout parsing for some devices
+* Zepp OS 3: Fix file transfer (notification icons, gpx upload, agps updates)
+* Zepp OS 3.5 / 4: Fix shortcuts, shortcut cards, menu items
+* Zepp OS: Add VO2 Max support
+* Zepp OS: Display resting heart rate
+* Zepp OS: Fix reminder creation in some cases
+* ZeTime: Migrate global preferences to device-specific
+
+#### 0.81.0
+* Experimental support for Amazfit Bip 5 Unity
+* Experimental support for Redmi Watch 4
+* Initial support for cycling sensor
+* Initial support for more Garmin watches
+* Initial support for Hama Fit6900
+* Initial support for Huawei Watch Fit 2, Watch Fit 3, Watch 4 Pro
+* Initial support for Soundcore Liberty 3 Pro
+* Introduce new Dashboard view
+* AsteroidOS: Added icons to the notifications
+* Bangle.js: Add Sleep as Android support
+* Bangle.js: Add screenshot support
+* Bangle.js: Add setting to disable notifications
+* Bangle.js: Allow wake phone when opening notification response from watch
+* Bangle.js: Fix activity intensity normalization
+* Bangle.js: Fix message reply
+* Bangle.js: Improve text rendering
+* Fossil/Skagen Hybrids: Update device settings to new structure
+* Galaxy Buds Live: Update device settings to new structure
+* Galaxy Buds 2: Fix recognition of some versions
+* HPlus: Migrate global preferences to device-specific
+* Huami: Fix reminder message encoding
+* Huawei: Add cycling workout type
+* Huawei: Add enable HeartRate and SpO2 force option
+* Huawei: Add huawei account support (pair without resetting watch)
+* Huawei: Add support for workout calories and cycling power
+* Huawei: Add remote camera shutter
+* Huawei: Ask pincode only on first connection
+* Huawei: Enable sleep detection
+* Huawei: File upload and watchface management
+* Huawei: Fix force DND support
+* Huawei: Fix long notification
+* Huawei: Fix TimeZone offset calculation
+* Huawei: Improve connection and reconnection
+* Huawei: Improve music controls
+* Huawei: Improve notification icons
+* Huawei: Improve weather, HR and SpO2 support
+* Huawei: Improve workout parsing
+* Huawei: Rework settings menu with sub-screens
+* Huawei: Send user info to device
+* Huawei: Support sending GPS to band
+* Huawei Watch GT4: Add HR and SpO support
+* Huawei Watch Ultimate: Add HR and SpO support
+* Intent API: Add broadcast on activity sync finish
+* Intent API: Added debug end call
+* Mi Band 6: Add menu items for NFC shortcuts
+* Nothing CMF Watch Pro: Add weather support
+* Nothing Earbuds: Add adjustable delay for auto-pick-up of calls
+* Nothing Earbuds: Add option to auto-reply to incoming phone calls
+* Nothing Earbuds: Add option to read aloud incoming notifications
+* Sony LinkBuds S: Enable some missing features
+* Xiaomi Smart Band 8 Active: Fix discovery
+* Xiaomi: Add swimming workout type
+* Xiaomi: Allow transliteration
+* Xiaomi: Fix barometer
+* Xiaomi: Fix notification for apps in work profile
+* Xiaomi: Fix some crashes
+* Xiaomi: Improve reconnection
+* Xiaomi: Improve sleep and activity parsing
+* Xiaomi: Improve weather support, add multiple locations
+* Xiaomi: Sync calendar event reminders
+* Zepp OS: Add support for Sleep as Android
+* Zepp OS: Sync calendar event reminders
+* Add Armenian and Serbian transliterators
+* Add GENERIC_PHONE and GENERIC_CALENDAR NotificationType handling
+* Add support for scannable-only devices
+* Fix crash when connecting on some phones
+* Fix crash when enabling bluetooth
+* Fix receiving shared gpx files
+* Fix text cutoff on all checkbox preferences
+* Format pace as mm:ss
+* Make battery threshold notifications configurable
+* Prevent some bluetooth events from starting Gadgetbridge
+* Recognize "Delta Chat" as generic chat
+* Remove deprecated general auto-reconnect preference
+* Refactor location service
+* Set navbar color to match theme
+* Simplify pairing of bonded and companion devices
+
+#### 0.80.0
+* Initial support for Amazfit Bip 3
+* Initial support for Huawei Band 8
+* Initial support for Huawei Watch GT 4
+* Initial support for Huawei Watch Ultimate
+* Initial support for Sony LinkBuds
+* Initial support for Xiaomi Smart Band 8 Active
+* Bangle.js: Allow saving files on phone from watch
+* Bangle.js: Fix crash when file save is cancelled
+* Bangle.js: Set filename on save file dialogs
+* Bangle.js: Improve communication stability
+* Bangle.js: Sync activity tracks
+* Bangle.js: remove unwanted charaters from calendar events
+* Femometer Vinca II: Add temperature charts
+* Fossil/Skagen Hybrids: Remove activity fetching toasts and add finished signal
+* Fossil/Skagen Hybrids: Use steps instead of calories for activity intensity
+* Fossil/Skagen Hybrids: Mark device busy and show transfer notification while syncing
+* Huami/Zepp OS: Fix activity sync getting stuck sometimes
+* Mi Band 1/2: Fix vibration settings preference screens
+* Huawei: Add cycling workout type
+* Huawei: Add smart wakeup interval
+* Pebble: Fix pairing with LE counterpart
+* Xiaomi Watch S1 Pro: Add temperature charts
+* Xiaomi: Fix sleep sync failing when sleep stages are not found
+* Xiaomi: Improve activity sync
+* Nothing CMF Watch Pro: Fix music playback status
+* Allow for device settings sub-screens
+* Device connection: Add support for scan before BLE connection
+* Misc UI improvements (alarms, chart settings)
+
+#### 0.79.1
+* Initial support for Huawei Watch Fit
+* Initial support for Xiaomi Redmi Watch 3
+* Fossil/Skagen Hybrids: Fix crash on multi-byte unicode characters in menu
+* Huawei: Add weather support
+* Bangle.js: Support higher MTU
+* Test Device: Add fake features and data
+* Periodically (around every 2 days) synchronize time on connected devices
+* Set alarm as used and enabled if time has changed
+
+#### 0.79.0
+* Initial support for Honor Magic Watch 2
+* Initial support for Mijia MHO-C303
+* Initial support for Nothing CMF Watch Pro
+* Initial support for Sony WI-SP600N
+* Experimental support for Redmi Watch 2
+* Experimental support for Xiaomi Smart Band 8 Pro
+* Experimental support for Xiaomi Watch S1 Pro
+* Experimental support for Xiaomi Watch S1
+* Experimental support for Xiaomi Watch S3
+* Galaxy Buds2 Pro: Fix recognition of some versions
+* Huawei Watch GT 2: Fix pairing
+* Redmi Smart Band Pro: Fix password digits
+* Pebble: Fix app configuration page
+* Pebble 2: Fix pairing issue
+* PineTime: Fix weather forecast on InfiniTime's new simple weather
+* Xiaomi: Fix sleep sometimes extending past the wakeup time
+* Xiaomi: Request battery level and charging state periodically
+* Xiaomi: Fix sleep stage parsing for some devices
+* Zepp OS: Improve device discovery
+* Zepp OS: Fix weather not working on some devices
+* Zepp OS: Prevent crash when installing large firmware updates
+* Fix sport activity summary group order
+* Fix reconnection to devices failing occasionally
+
+#### 0.78.0
+* Initial support for Honor Band 3,4,5,6
+* Initial support for Huawei Band 4, 4 Pro, 6, 7, 3e, 4e
+* Initial support for Huawei Talk Band B6
+* Initial support for Huawei Watch GT, GT 2
+* Initial support for Mijia LYWSD03MMC
+* Initial support for Nothing Ear (2)
+* Initial support for Nothing Ear (Stick)
+* Experimental support for Honor Band 7
+* Experimental support for Redmi Watch 2 Lite
+* Experimental support for Redmi Smart Band Pro
+* Casio GBX100: Add support for snooze alarm
+* Fossil/Skagen Hybrids: Update navigationApp to 1.1
+* Huami: Fetch SpO2 on devices that support it
+* Pebble: Attempt to fix app configuration webview
+* PineTime: Add support for InfiniTime's new simple weather
+* PineTime: Fix freeze and reboot when upgrading firmware
+* Pixoo: Enable sending images (non-persistent)
+* Pixoo: Get and send alarms
+* Pixoo: Set custom device name
+* Pixoo: support "clap hands to turn off screen" and "sleep after silence" settings
+* Xiaomi: Improve activity and workout parsing
+* Xiaomi: Improve stability and fix some crashes
+* Xiaomi: Improve weather
+* Xiaomi: Parse sleep stages
+* Add a notifications channel for connection status notifications
+* Improve automatic connection to all or previous devices
+* Fix devices sometimes staying stuck in a "Connecting" state
+* Map some missing Google Maps navigation actions
+
+#### 0.77.0
+* Initial support for Amazfit Balance
+* Initial support for Amazfit Active
+* Initial support for ColaCao 2021
+* Initial support for ColaCao 2023
+* Initial support for Femometer Vinca II
+* Initial support for Mijia LYWSD02MMC variant
+* Initial support for Sony Wena 3
+* Experimental support for Divoom Pixoo
+* Experimental support for Sony WF-1000XM5
+* Experimental support for Amazfit Active Edge
+* Experimental support for Mi Band 7 Pro (Xiaomi Smart Band 7 Pro)
+* Experimental support for Mi Band 8 (Xiaomi Smart Band 8)
+* Experimental support for Mi Watch Lite
+* Experimental support for Mi Watch Color Sport
+* Experimental support for Redmi Smart Band 2
+* Experimental support for Redmi Watch 3 Active
+* Experimental support for Xiaomi Watch S1 Active
+* Amazfit Band 7: Add alexa menu entries
+* Amazfit GTR 3 Pro: Fix firmware and watchface upload
+* Amazfit T-Rex: Fix activity summary parsing
+* Amazfit T-Rex Pro: Add activate display on lift sensitivity
+* AsteroidOS: Add more supported watch models
+* AsteroidOS: Fix media info
+* AsteroidOS: Fix notification dismissal
+* Bangle.js: Add loyalty cards integration with Catima
+* Bangle.js: Ensure SMS messages have src field set to "SMS Message"
+* Bangle.js: Fix GPS speed
+* Bangle.js: Improve handling of chinese characters
+* Bangle.js: Lower threshold for low battery warning
+* Bangle.js: Recover from device initialization failure
+* Casio GBX100/GBD-200: Fix first connect
+* Casio GB5600/6900/STB-1000: Fix pairing
+* Casio GDB-200: Fix notification timestamp
+* Casio GDB-200: Fixed notification categories and default category
+* Casio GDB-200: Allow preview of notification message alongside title
+* Casio GDB-200: Fixed find my phone feature
+* Intent API: Add debug action for test new function
+* Fossil/Skagen Hybrids: Add new navigation app
+* Fossil/Skagen Hybrids: Allow configuring call rejection method
+* Fossil/Skagen Hybrids: Fix some preference crashes on the nightly
+* Fossil/Skagen Hybrids: Reduce toasts on release builds
+* Fossil/Skagen Hybrids: Show device specific settings in more logical order
+* Huami: Toggle phone silent mode from band
+* Message privacy: Add mode Hide only body
+* Mijia LYWSD02: Add battery
+* Mijia LYWSD02: Add low battery notification
+* Mijia LYWSD02: Set temperature unit
+* Mijia LYWSD02: Fix battery drain while connected
+* PineTime: Display app name for VoIP app calls
+* PineTime: Honor Sync time setting on connect
+* PineTime: Improve notification handling
+* PineTime: Reduce weather memory usage
+* Withings Steel HR: Fix crash when calibrating hands on the nightly
+* Zepp OS: Add blood oxygen graph
+* Zepp OS: Add workout codes for hiking and outdoor swimming
+* Zepp OS: Allow disabling app notifications per device
+* Zepp OS: Attempt to fix activity fetch operation getting stuck
+* Zepp OS: Display swimming activity data
+* Zepp OS: Fix health settings on older Zepp OS versions
+* Zepp OS: Fix setting of unknown button press apps
+* Zepp OS: Fix sunrise and moon dates being off by local time + UTC offset
+* Zepp OS: Map hiking, outdoor swimming, climbing and table tennis activity types
+* Zepp OS: Toggle phone silent mode from band
+* Add transliteration for Latvian, Hungarian, Common Symbols
+* Allow multiple device actions to be triggered for the same event
+* Allow toggling DND through device actions
+* Autodetect OsmAnd package name and make it configurable
+* Improve ASCII transliterator
+* Make GMaps navigation handler follow the "navigation forwarding" setting
+* Support selecting enabled navigation apps
+* Allow ignore notifications from work profile apps
+* Display alias in low battery notification
+* Fix crash when pairing current device as companion
+* Fix emoji when a transliterator is enabled
+* Fix UV Index and rain probability for some weather apps
+* Improve device discovery stability and fix freezes
+* Improve Telegram and COL Reminder notifications
+* Replace old-style preference switch with Material 3 switch
+
+#### 0.76.1
+* Amazfit GTR Mini: Mark as not experimental
+* Bangle.js: Improve file downloads
+* Bangle.js: Fix app interfaces
+* Allow text to be shared to devices
+* Fix connection to some Amazfit devices
+
+#### 0.76.0
+* Upgrade UI to Material 3 and add dynamic colors theme
+* Initial support for Amazfit Bip 3 Pro
+* Initial support for Amazfit Cheetah Pro
+* Initial support for Bohemic Smart Bracelet
+* Initial support for Casio GW-B5600
+* Initial support for Garmin Vivomove HR
+* Initial support for Withings Steel HR
+* Experimental support for Amazfit Bip 5
+* Experimental support for Amazfit Falcon
+* Experimental support for Amazfit GTR Mini
+* Experimental support for Amazfit Cheetah (Round/Square)
+* Experimental support for Amazfit T-Rex Ultra
+* Amazfit GTS 2e: Add activate display on lift sensitivity
+* Amazfit GTR 3 / GTS 3: Enable AGPS Updates
+* Amazfit Neo: Enable PAI support
+* Bangle.js: Allow enable/disable of alarms
+* Bangle.js: Fetch activity data
+* Bangle.js: Fix GB integration when watch is not programmable
+* Bangle.js: Put JSON keys in quotes
+* Bangle.js: Reorganize device settings
+* Fossil/Skagen Hybrids: Embed custom menu in watchface, fixes lost menu on reset
+* Fossil/Skagen Hybrids: Fix unused alarms being pushed to the device
+* Fossil/Skagen Hybrids: Strip unicode characters that the watch can't display
+* Fossil/Skagen Hybrids: Sunrise/sunset follows weather location
+* Huami/Zepp OS: Add PAI charts
+* Huami/Zepp OS: Improve music info stability
+* Huami/Zepp OS: Improve reconnection and device initialization
+* Huami: Persist workout raw details even if gpx has no points
+* InfiniTime: Add heart rate measurement support
+* Mi Band 5: Fix activity fetch error toast when stress monitoring is enabled
+* Mi Band 6: Enable PAI and stress support
+* LeFun: Fix heart rate popup when measurement is triggered from phone
+* Sony WH-1000XM3/WF-SP800N: Add volume setting
+* Sony WH-1000XM5: Fix speak-to-chat enable/disable
+* Zepp OS: Add loyalty cards integration with Catima
+* Zepp OS: Enable AGPS updates for all devices
+* Zepp OS: Fix calendar sync on Zepp OS 2, send event location
+* Zepp OS: Fix reminder creation
+* Zepp OS: Fix shortcut cards setting on Zepp OS 2.1
+* Zepp OS: Fix weather, add hourly information
+* Zepp OS: Map barcode types for ITF, PDF_417 and DATA_MATRIX
+* Add preference to display changelog on startup
+* Add Termux RUN_COMMAND permission
+* Allow filtering notifications from work profile apps
+* Fix daylight saving time not being transmitted to the watch
+* Fix media button control for some applications
+* Fix notification filters by title if notification does not contain a body
+* Fix opening screenshots from notification on external apps
+* Fix reconnect delay reset after all devices are initialized
+* Fix some security error crashes when permissions are not granted
+* Fix transliteration of emoji
+* Fix transliteration of non-ASCII accented characters
+* Force gps speed calculation on some phones that do not report it correctly
+* Make application list sorting case-insensitive
+* Introduce native app shortcuts for android > 30
+* Update device settings action bar title depending on current screen
+
+#### 0.75.1
+* Fix Weather Notification integration
+
+#### 0.75.0
+* Bangle.js: Add message size limitation to Calendar and Messages
+* Bangle.js: Add switch to control if the GPS chip should be used to locate the location
+* Bangle.js: Send more weather data to watch
+* Bangle.js: Allow an activity sample to have a timestamp
+* Bangle.js: Send last received activity timestamp on connect (to allow sync of activity samples)
+* Bangle.js: Allow connecting HW keyboard without closing app loader
+* Bangle.js: Bump flavor target SDK version to 31
+* Bangle.js: Fix convertion of emoji/unicode to bitmap without width/height
+* Bangle.js: Fix location listener not being cleaned up when waiting for reconnect
+* Bangle.js: Fix memory leak from HTTP requests
+* Bangle.js: Fix orientation changes closing app loader
+* Bangle.js: Fix return to applications management activity after having opened another window
+* Bangle.js: Set default value for GPS event interval to 1 second
+* Bangle.js: Support navigation instructions
+* Bangle.js: Escape characters that fall in the Unicode codepoint area (for Espruino ~2v18.20 and later)
+* Bangle.js: HTTP request XPath can now return Arrays
+* Fossil/Skagen Hybrids: Add support for ultraviolet index and rain probability
+* Fossil/Skagen Hybrids: Add UV index and chance of rain widgets
+* Fossil/Skagen Hybrids: Allow launching the calibration activity on any Gadgetbridge variant
+* Fossil/Skagen Hybrids: Increase accuracy of workout distance calculation
+* Fossil/Skagen Hybrids: Fix weather icons day/night status
+* InfiniTime: Fix weather expiry time
+* InfiniTime: Support navigation instructions
+* Mi Band 6: Allow making device discoverable via Bluetooth when connected
+* Mi Band 7: Add preference to display call contact information
+* Zepp OS: Add gpx route file upload
+* Zepp OS: Add screenshot support
+* Zepp OS: Add stress charts
+* Zepp OS: Add watch app logs developer option
+* Zepp OS: Display watchface and app preview on install
+* Zepp OS: Fix update operations on Zepp OS 2.1+
+* Zepp OS: Manage contacts on watch
+* Zepp OS: Start new GPX segments on pause/resume
+* Zepp OS: Support flashing zab files
+* App Manager: Fix cached apps sorting
+* App Manager: Hide drag handle if app reorder is not supported
+* App Manager: Add confirmation before deleting app
+* Add menus to share GPX, raw summary, raw details
+* Debug Activity: Allow pairing current device as companion
+* Fix some null pointer exception crashes
+* Intent API: Add command to set device mac address
+* Intent API: Add dataTypes parameter for activity sync
+* Intent API: Add debug actions for notifications and incoming calls
+* OsmAnd: Add support for navigation instructions
+* Scrape navigation instructions from Google Maps notifications
+* Fix lag when a folder has a lot of devices
+* Fix transliteration returning non-ASCII characters
+* Enable "allow high MTU" setting by default
+* Make some hardcoded english strings translatable
+
+#### 0.74.0
+* Initial support for Amazfit GTR 3 Pro
+* Initial support for Sony WH-1000XM5
+* Amazfit Bip U: Remove alarm snooze option
+* Amazfit GTR 4 / GTS 4: Add watch Wi-Fi Hotspot and FTP Server
+* Amazfit GTR 4 / GTS 4: Perform and receive phone calls on watch
+* Amazfit GTS 2 Mini: Add missing alexa menu item
+* Bangle.js: Fix updating timezone in settings.json if the timezone is zero
+* Fossil/Skagen Hybrids: Pair watch to phone, fixes repeating confirmation request
+* Huami: Implement repeated activity fetching
+* Sony WH-1000XM4: Add speak-to-chat
+* Sony Headphones: Add button modes help
+* Zepp OS: Add shortcut cards preference
+* Zepp OS: Add support for morning updates
+* Zepp OS: Add preference to keep screen on during workout
+* Zepp OS: Add preference for camera remote
+* Zepp OS: Fix activate display upon lift wrist smart mode
+* Zepp OS: Fix Cards and MI AI display item and shortcuts
+* Zepp OS: Fix setting of control center
+* Zepp OS: Fix setting of unknown configuration values
+* Zepp OS: Set watchface from phone
+* Add Croatian transliterator
+* Fix restoring app notification/pebble blacklist preferences on import
+* Cache notifications while devices are out of range (opt-in)
+
+#### 0.73.0
+* Initial support for Amazfit T-Rex 2
+* Initial support for AsteroidOS watches
+* Initial support for Sony LinkBuds S
+* Initial support for Galaxy Buds2 Pro
+* Initial support for SoFlow S06(just for lock and unlock, needs key)
+* Fossil/Skagen Hybrids: Fix truncation of notifications
+* Fossil/Skagen Hybrids: Fix washed out colors in imported watchfaces
+* Fossil/Skagen Hybrids: Allow launching watch apps from app manager
+* Fossil/Skagen Hybrids: Fix activity parser
+* Fossil/Skagen Hybrids: Add app/watchface downloading from watch to app manager
+* Fossil/Skagen Hybrids: Fix crash on empty or multi-byte unicode alarm texts
+* Fossil/Skagen Hybrids: Implement inactivity warnings
+* Fossil/Skagen Hybrids: Remove obsolete debug message
+* Mi Band 6: Add NFC display item
+* Zepp OS: Fix Alipay and WeChat Pay display item and shortcuts
+* Amazfit GTR 4/GTS 4: Support for AGPS Updates
+* Bangle.js: Stop sending bitmaps for common characters that already have good enough equivalents on the watch
+* Bangle.js: Stop toast warning message appearing when starting the app loader
+* Bangle.js: Increase default realtime HRM/step interval to 10 seconds
+* Bangle.js: Support additional values for GPS event
+* Sony WF-1000XM4: Fix battery updates while connected
+* Sony WF-1000XM4: Fix audio codec
+* Add Georgian Transliteration
+
+
+#### 0.72.0
+* Initial support for Amazfit GTR 4/GTS 4/GTS 4 Mini
+* Initial support for Amazfit Band 7
+* Initial support for Galaxy Buds 2
+* Initial Support for Sony WH-1000XM2/WF-1000XM4
+* Sony headphones: Fix pause when taken off
+* Sony Headphones: Fix setting surround mode
+* Zepp OS: Map strength training, basketball and cricket activity types
+* Zepp OS: Add World Clocks
+* Zepp OS: Fix notification icons larger than 56x56px
+* Zepp OS: Fix notification icons for work profile apps
+* Zepp OS: Fix notification icon for SMS
+* Zepp OS: Fix app and watchface install
+* Zepp OS: Fix NPE when no weather data is available
+* Amazfit GTR 2: Fix activate display upon lift only working when scheduled
+* Bangle.js: Fix calendar sync
+* InfiniTime: Fix weather
+* InfiniTime: Add support for local time service
+* InfiniTime: Add world clock support
+* Skagen Gen 6 Hybrid HR: Add support for 38mm watches
+* Complete rewrite of new device discovery
+* Add Intent API to trigger activity sync and DB export
+* Allow media notifications to bypass app list
+* Debug Activity: Add confirmation dialog before removing device preferences
+
+#### 0.71.3
+* Fossil/Skagen Hybrids: Update known watch app versions
+* Skagen Hybrids: Allow firmware installation
+* Fossil Hybrid HR: Request menu config upon app connection
+* Amazfit GTS3: Fix crash when fetching workouts
+* Zepp OS: Fix fetching workouts shorter than 1 minute
+* Zepp OS: Decode workout elevation and altitude
+* Huami: Do not crash when failing to parse activity summary
+* Re-connect after update, especially useful for users of nightly releases
+* Make number of not scrollable sleep sessions lines configurable
+
+#### 0.71.2
+* Zepp OS: Display HR zones and Training Effect in Activity Details
+* Remove shortcut feature due to non-free dependencies (#2918)
+
+#### 0.71.1
+* Try to exclude non-free stuff from shortcuts library (#2918)
+* SuperCars: fix periodicDataSender, add tricks
+* Zepp OS: Fix crash when user attempts to disable Settings display item
+* Fix crash when opening Gadgetbridge from the notification
+
+#### 0.71.0
+* Remove KitKat support, Gadgetbridge now requires Android 5.0
+* Initial support for Amazfit GTR 3
+* Initial support for SuperCars (Shell Racing Cars)
+* Huami: Add preference to overwrite band settings on connection
+* Huami: Fix crash when selecting automatic Always On Display
+* Huami: Set OpenTracks track category and icon
+* Huami: Implement proper find device
+* Huami: Change default find band vibration pattern
+* Flipper Zero: added duration to Intent API
+* Flipper Zero: fixed crash due to unregistered boradcast receiver
+* Flipper Zero: fetch firmware version from flipper
+* Fossil Hybrid HR: Correctly initialize watchface after reset or crash
+* Fossil Hybrid HR: Set OpenTracks track category and icon to workout type selected on watch
+* Fossil Hybrid HR: Allow flick_away as custom event and add move_hands event
+* InfiniTime: Add weather support
+* Amazfit Neo: Fix world clock
+* Amazfit Neo: Fix long caller name display
+* Amazfit Neo: Remove activity tracks (unsupported)
+* Amazfit GTS 3: Fix battery drain due to unanswered weather requests
+* Mi Band 7: Fix Weather
+* Mi Band 6: Add support for workout activity types
+* Mi Band 6: Enable adding workout activity types to the "more" section
+* Amazfit GTR: Enable button actions
+* Zepp OS: Implement activity, sleep and workout fetching
+* Zepp OS: Improve firmware upgrades
+* Bangle.js: Add PATCH HTTP request type, and fix for VolleyError UnsupportedOperationException when supplying custom headers.
+* Bangle.js: Add ability to start services on the Android device via intents.
+* Bangle.js: Flags and multiple categories can now be specified for intents initiated on the watch.
+* Bangle.js: Add ability to wake and unlock the Android device via a special intent.
+* Allow 3rd party apps to set device settings
+* Re-implement C code in Java and remove Android NDK dependency entirely
+* Fix crashes on older Android versions when using some devices
+* Add support for REM sleep
+* App shortcuts support (long press on the launcher icon for directly connecting a device)
+
+#### 0.70.0
+* Initial support for Amazfit GTS 3
+* Initial support for Fossil Hybrid Gen6
+* Initial support for Flipper Zero
+* Huami: fix default vibration pattern
+* Huami: Enable vibration patterns for all compatible devices
+* Huami: Improve large firmware zip file handling
+* Bangle.js: Fix null pointer issue if headers not supplied for HTTP request
+* Bangle.js: Support calendar color and name
+* Mi Band 7: Fix crash if reminder, calendar or canned messages contain non-ascii characters
+* Mi Band 7: Fix NPE when acknowledging that icon was sent
+* Mi Band 7: Fix and enable firmware upgrades
+* Mi Band 7: Support for watchapps
+* Amazfit Neo: remove 1 hour heartrate interval (not supported)
+* Fossil Hybrid HR: Fix watchface redraw after powersave and after wrist flick
+* Fossil Hybrid HR: Enable configuring middle long press on FW 3.0 and newer
+* InfiniTime: Fix firmware update
+* Make heart rate measurement intervals configurable per-device
+* Add option to ignore low priority notifications
+* Fix Skype notifications
+* Prefer long notification text by default
+* Prefer big text when scraping gmail notifications
+* Do not remove newline and whitespace characters from notification content
+* Debug: Add companion device list to debug activity
+
+#### 0.69.0
+* Initial Support for Mi Band 7
+* Initial support for devices using Binary Sensor Service
+* Mi Band 4: Enable heartrate activity monitoring support
+* Mi Band 4: Enable activate display on lift sensitivity setting
+* Mi Band 6: Enable password support
+* Mi Band 4/6, Amazfit Bip U: Enable sending GPS coordinates to band during workout
+* Mi Band 4/6, Amazfit Bip U: Enable start fitness tracking on phone when workout starts on band setting
+* Amazfit Neo: Support hourly chime which was added in firmware 1.1.2.46
+* Amazfit Neo: Fix daily steps goal and notification
+* Amazfit Neo: Fix heartrate sleep detection setting
+* Amazfit Neo: Enable heartrate activity monitoring support
+* Amazfit Neo: Fix alarms setting to "unused" on connect.
+* Bangle.js: Make text as bitmaps have transparent background, and allow font size to be specified
+* Bangle.js: Allow starting Activties on the Android device
+* Fossil Hybrid HR: Add support for Hybrid HR 38mm watches
+* Fossil Hybrid HR: Add optional circle backgrounds to widgets
+* Fossil Hybrid HR: Add toggling of widgets with physical button event
+* Fossil Hybrid HR: Add missing physical button options
+* Fossil Q Hybrid: Fix config activity after multi-device merge
+* InfiniTine: Fixes for steps count sync
+* Add steps/sleep streaks screen
+* Add French transliteration
+* Refactor file logging initialization logic
+* Add alert to Log Sharing if logging has not been enabled yet
+* Fix crash on some phones for find android device (add fallback tone)
+* Fix regression since 0.68.0 with active auto connect
+
+#### 0.68.0
+* Multi device support (experimental), allows connecting to multiple devices simultaneously
+* Fossil Hybrid HR: Allow installation of newer watch apps
+* Fossil Hybrid HR: Allow workout app the be added as a shortcut
+* Fossil Hybrid HR: Generate watchface preview image and show it in the app manager
+* Fossil Hybrid HR: Request custom menu config on watchface initialization
+* Fossil Hybrid HR: Invert widgets color when the background image is inverted
+* Fossil Hybrid HR: Show app versions in app manager
+* Fossil Hybrid HR: Make 2nd TZ widget clock duration configurable and fix wrong offset
+* Fossil Hybrid HR: Add option to share a cached watchface/app to another app
+* Fossil Hybrid HR: Allow switching already uploaded watchfaces with Intent
+* Mi Band 6: Fix night mode on latest firmware
+* Mi Band 6: add sleep menu item (also to shortcuts)
+* Mi Band 5: Send GPS location to band during workout
+* Mi Band 5: Start fitness tracking on phone when workout starts on band
+* Mi Band 5: Fix missing Portuguese language
+* Mi Band 5: Add missing breathing shortcut
+* Mi Band 4: Add password support
+* Huami: Fix setting heart rate measurement interval on connection
+* Huami: Fix track name being replaced by album
+* Huami: Display native alarm notification
+* Huami: Fix MTU update on device connection
+* Roidmi 3: Recognize "Roidmi C BLE" as Roidmi 3
+* Bangle.js fix message REPLY option
+* Bangle.js: Keep a log of data sent from the watch, and allow it to be saved with from the debug menu
+* Bangle.js: Support for color dithered bitmaps, and converting emoji->bitmaps
+* Bangle.js: Adding built-in app-loader view. (Only available on internet-enabled builds)
+* Bangle.js: fix null pointer issue for debug messages
+* Bangle.js: Enable calendar sync for bangle
+* Bangle.js: Add icon
+* Pebble: fix configuration of some watchfaces (might break other again)
+* FitPro: add MTU based chunking, add more device names (Sunset 6, Watch7)
+* UM25: fix missing firmware version
+* Support for incoming call notification delay
+* Make calendar blacklist configurable per device
+* Support folders in device list
+* Separate device settings which are specific to the application into Set preferences in device card
+* When pairing devices with auth key requirements, only show Auth key menu related items on long press
+* Provide access to the FW/App Installer via Set preferences in device card
+* Animate card movement in device list
+* Make transliteration configurable per-language
+* Widget: do not show sleep if not recorded
+* Pop up a dialog asking about Location permissions
+* Fix sharing log files on newer android versions
+* Allow to set Bluetooth discovery scanning level to prevent freezing
+* Various UI tweaks and fixes
+* Add monochrome themed icon
+* Add device menu item to get to the FW/App Installer via an explanation activity
+
+#### 0.67.1
+* Huami: Fix long music track names not displaying
+* Amazfit Bip U/Pro/Band 5: Enable extended HR/stress monitoring setting
+* Pebble: Fix calendar blacklist, view and storage
+* FitPro: Fix crash, inactivity warning preference to string
+
+#### 0.67.0
+* Initial Support for Sony WF-1000XM3
+* Initial Support for Galaxy Buds Pro
+* Huami: Add Toggle function for Open Tracks tracking to button actions
+* Huami: Move inactivity warnings, goal notification and HR monitoring to device-specific settings
+* Mi Band 6: set time on connect
+* Mi Band 5/6, Amazfit Bip S/U/Pro: Add world clock configuration
+* Mi Band 5/6: support sensitivity setting for lift wrist configuration
+* Mi Band 5: Add support for configuring workout menu on device
+* Mi Band 4/5/6, Amazfit Bip U/Pro: Add support for vibration patterns
+* Mi Band 5: Increase number of reminder slots to 50
+* Mi Band 5/6: Add setting for HR activity monitoring, HR alerts, stress monitoring
+* Amazfit Neo: Allow to disable beeps for email notifications
+* Bangle.js: Fix incoming calls in release builds
+* Bangle.js build: Add option for enabling/disabling internet access
+* Bangle.js: Add ability to receive intents to com.banglejs.uart.tx
+* Fossil Hybrid HR: Support flexible custom menu on watch
+* Fossil Hybrid HR: Add support for native DND Call/SMS functionality
+* VESC: added battery indicator
+* UM25: Add reset option to current accumulation
+* UM25: Add notification on below current threshold
+* Fix crash when calendar is accessed but permission is denied
+* Add com.asus.asusincallui and com.samsung.android.incallui to blacklist
+* New icons for Sony overhead headphones, Sony WF 800n and Mi Band 6
+* When Gadgetbridge needs permissions, pop up a dialog asking nicely and explaining why
+
+#### 0.66.0
+* Add basic support for Casio GBD-H1000
+* Add support for Hama Fit Track 1900 - via FitPro device support
+* Add OpenTracksController for interactions with OpenTracks
+* Fossil Hybrid HR: Start/stop track in OpenTracks from GPS workout on watch
+* Fossil Hybrid HR: Try guessing new widget position
+* Fossil Hybrid HR: Allow assigning no function to a button
+* Add Huami button/device action to control fitness tracking via OpenTracksController
+* Mi Band 6: Sync alarms set on the watch like on Amazfit Bip U and others
+* Bangle.js: Handle battery charging status and fix battery chart.
+* Bangle.js: Prevent exception in case UART RX line is empty
+* Bangle.js: Add repetitions in alarm JSON
+* WaspOS: Fix battery chart.
+* WaspOS: Add condition code to weather JSON
+* XWatch: Add notifications and calls support
+* UM-25: Make cumulative values resettable
+* VESC: Fixed crash when loading a saved value
+* Allow to open Android notification settings from Notification settings
+* AutoExporter changes for better operation and troubleshooting
+* Change Nightly icons background color
+
+#### 0.65.0
+* Amazfit Pop/Pro: Initial Support (probably the same as Bip U but has a different firmware)
+* Sony WH-1000XM4: Initial Support
+* Sony WH-1000XM3: Disable equalizer, surround and sound position while in SBC codec
+* Sony Headphones: Improve initialization on connection
+* Sony Headphones: Implement Noise Cancelling Optimizer
+* Casio: Fix accidentally disabled time synchronization and pairing of new Casio GBX/GBD-series watches
+* Fossil Hybrid HR: Improve Device Applications List handling
+* Fossil Hybrid HR: Added ability to change activity recognition settings on the watch
+* Fossil Hybrid HR: Make width of custom widget configurable
+* Fossil Hybrid HR: Disable non-configurable buttons preferences
+* Amazfip Bip U: Read alarm from the watch on connect and update in Gadgetbrige when changing alarms on the watch (might work on other Huami devices)
+* Add icon for VESC devices
+* Add commit id into About screen
+* Make debug activity notification test to persist text while switching apps
+* Add Portuguese to the list of language options
+* Update configuration button icon in app notification settings
+
+#### 0.64.0
+* Initial support for VESC NRF/HM10 devices
+* Initial support vor Bose QC35
+* Initial support for Sony WF-SP800N
+* Fossil Hybrid HR: Fix on-device confirmation for older firmwares
+* Sony WH-1000XM3: Fix Ambient Sound Control commands, potentially improving ANC quality
+* Sony WH-1000XM3: Read configuration from device
+* InfiniTime: Remove debug Toast and subscription to motion raw XYZ values characteristic
+* Roidmi: Fix frequency configuration on some non-english languages
+* Roidmi 3: Add support for Mojietu 3 rebrand
+* Huami: Support hiking and climbing activities, decode some more activity details
+* Amazfit GTS 2 mini: Fix notification title not appearing for non-chat apps
+* Amazfit Bip U/Pro: Disable event reminder feature, it is not supported by the device.
+* Amazfit Bip U/Pro: Allow enabling Todo List menu (feature still not supported)
+* Bangle.js: send weather condition code to device
+* Allow adding test device directly from the discovery screen
+* Keep device info on the correct device during reordering
+
+#### 0.63.1
+* Huami: Support native reminders
+* InfiniTime: Initial support for step counting (currently very limited by the device firmware)
+* Bangle.js: Fix Gadgetbridge crashes when playing music with some players
+* Fossil Hybrid HR: Add support for on-device paring confirmation, for watches that are in a state which makes this neccessary
+* Fossil Hybrid HR: Fix widget configuration bug
+* Mi Band 3: Support lift wrist during DND setting
+* Amazfit GTS 2 Mini: Fix language setting
+* Amazfit GTS 2 Mini: Fix setting menu items on the watch
+* Activity card: Open specific Charts tab for each activity
+* Activity Card: React to User settings, unify step length
+* Activity card: simplify the layout and only show each chart if there is data (Cleaner layout for users with multiple devices)
+
+#### 0.63.0
+* Galaxy Buds Live: Initial Support
+* Sony WH-1000XM3: Initial Support
+* Add support for Casio GBD-200 and untested support for GBD-100
+* Casio: Fix alarm handling on all devices
+* Fossil Hybrid HR: Add button for removing the watchface background image
+* Fossil Hybrid HR: Support multiple 2nd-TZ and Custom widgets
+* Fossil Hybrid HR: Add support for native DND functionality
+* Nothing Ear (1): Add multiple batteries support
+* Galaxy Buds: Add multiple batteries support
+* Roidmi: New FM Frequency selector with presets
+* Mi Band 6: Try to add Alipay to menu settings, untested
+* FitPro: Support more bands with different bluetooth names
+* Add activity info to device cards
+* Add Nekogram X to Telegram notifications
+* Move location settings out of pebble menu
+* Sort devices by alias, if available
+
+#### 0.62.0
+* Iniital support for Galaxy Buds 2019
+* Huami: Fix syncing of data in non-DST time
+* InfiniTime: Fix wrong time zone being sent in non-DST mode
+* Amazfit Bip U Pro: Support flashing AGPS updates
+* FitPro: recognize LH716 devices
+* Add support for Casio STB-1000. Limitations of GB-5600B/GB-6900B apply
+* Prevent crash when receiving broken weather data from TinyWeatherForecastGermany
+
+#### 0.61.0
+* Initial support for Nothing Ear(1)
+* Amazfit Bip U/Pro: Fix flashing firmware and watchfaces
+* Amazfit Bip U/Pro: Fix language setting
+* Amazfit Bip U/Pro: Allow unicode emoji
+* Huami: fix supported languages list on many devices
+* Fossil Hybrid HR: Support rotation of backgrounds in watchface editor
+* UM25: Show more measured data
+* Improved notification management including blacklist or whitelist apps settings or discrete notifications with removed text
+* Fix default daily target distance from 5 to 5000 meters
+
+#### 0.60.0
+* Initial support for FitPro bands
+* Mi Band 6: really fix weather on new firmware
+* Casio GBX-100: Fix connection
+* Fossil Hybrid HR: Lower battery level warning threshold to 10%
+* Add ringtine preference setting for find your phone feature
+* Fix a bug where GB_LOGFILES_DIR_IS_UNDEFINED is used as a logfile directory
+* Remove per-device preferences upon device removal (backup your keys if you do that)
+* Exclude not worn sleep times from sleep sessions
+* Add Icelandic and Czech transliteration
+
+#### 0.59.3
+* Mi Band 6: Properly support firmware 1.0.4.38
+* Mi Band 6: Add Flashlight to menu items
+* ZeTime: Fix corruption on long notifications
+
+#### 0.59.2
+* Mi Band 6: Support firmware 1.0.4.38 (experimental, still missing features)
+* InfiniTime: Fix null being displayed as notification title
+
+#### 0.59.1
+* Fossil Hybrid HR: Add power saving feature and many new widgets for the official Gadgetbridge watchface (battery, calories, 2nd TZ, chance of rain)
+* Fossil Hybrid HR: Support setting metric/imperial mode
+* Amazfit T-Rex Pro: Try to fix some menu items
+* Huami: Re-enable setting the timezone correctly with included DST (fixes world time)
+* Bangle.js: Add functions for pushing bitmaps
+* Bangle.js: Aadd configurable MTU
+* Add Heart Rate measurement screen, accessible via heart icon in device action icons
+
+#### 0.59.0
+* Initial support for SMA Q2 OSS firmware (Emeryth)
+* Fix broken UM25 support
+* Fossil Hybrid HR: Add watchface designer
+* use '_' instead ':' in exported and imported file names to fix problems with some Android versions
+* Fix applying theme as set by the system
+* Try to improve behavior when quitting Gadgetbridge
+
+#### 0.58.2
+* InfiniTime: Support notification for battery level
+* Allow importing GPX tracks via Android Intent/Share system
+* Add option for black background to dark theme
+* BangleJS, WaspOS: Support for transliteration
+* Add missing icons to settings items
+
+#### 0.58.1
+* Pebble: Fix broken app manager (regression from 0.57.1)
+* Bangle.js: Try to fix crash when attempting to load activity chart
+* Amazfit T-Rex Pro: Add barometer to menu items
+* Remove battery data when device is being removed
+* Add transliteration to PineTime
+* Debug: Allow adding fake testing devices manually
+* Reduce margin of icons in device_item to keep one row for typical amount of icons
+
+#### 0.58.0
+* Initial experimental support for Amazfit T-Rex Pro
+* InfiniTime: Try to fix firmware upgrade by fiddling with optimization rules
+* Huami: Fix lost samples bug with timezones that are have a 30 minute offset
+* Fossil Hybrid HR: Block dangerous intents by default and move some settings to developer settings submenu
+* Improved logging in bonding and tried to recover from weirdness
+* Use a separate notification channel for low battery warnings
+
+#### 0.57.1
+* Mi Band 6: Add Pomodoro to menu items
+* Mi Band 6: Support flashing firmware, res and watchfaces
+* Mi Band 6: Enable Unicode Emoji support
+* Fossil Hybrid HR: Move commute actions to device specific settings
+* Fossil Hybrid HR: Use Gadgetbridge App Manager (same as Pebble), allowing quick switching of watchfaces and caching apps for later re-(installation)
+* Huami: Send changed weight, birthday and height data to watch immediately
+* Use flexbox layout for icons in device cards, allowing line breaks for small phones
+
+#### 0.57.0
+* Initial limited support for Mi Band 6
+* Amazfit GTR2/GTS2: Fix for flashing watchfaces
+* Amazfit GTR/GTS/GTR2/GTS2/Bip S: Allow flashing AGPS bundles
+* Amazfit Neo: Add lift wrist setting
+* Fossil Hybrid HR: Restructure settings menus for calibration, file management and physical buttons
+* Fossil Hybrid HR: Hide old settings on newer firmwares
+* Improved PineTime/InfiniTime firmware DFU metadata parsing and checks
+
+#### 0.56.2
+* Amazfit GTR2: Improve firmware update support (still partly broken)
+* Amazfit GTR2/GTS2: Prevent emoji transliterating (Seems to support emoji)
+* Amazfit GTS2/GTR2: Fixes for setting menu items (Probably still partly broken)
+* Amazfit GTS2/GTR2: Send wind speed and sunrise/sunset
+* Fossil Hybrid HR: Send actual application icons for notification on the watch
+* Fossil Hybrid HR: Support dismissing incoming calls with a quick SMS reply
+* Huami: Remove unused calendar slots
+* Huami: Send wind speed in Beaufort for some devices
+* InfiniTime: Add support for battery info
+* InfiniTime: Support title for notifications on firmware 0.15
+* InfiniTime: Implement find device by simulating a phone call
+* Adjust Steps Charts steps values offset to account for large phone screen sizes
+* Make Activity Charts dates move as calendar months and to go to now if jumping past today
+* Make Battery Info time span jump as calendar months and not as days
+
+#### 0.56.1
+* Fossil Hybrid HR: Fix compatibility for oder firmware revisions
+* Amazfit Neo: Implement firmware update
+* Amazfit Neo: Support setting all menu items and fix menu cycling bug
+* Amazfit Neo: Fix notifications
+* Amazfit Neo: Support sound settings
+* Allow OpenTracks and FitoTrack to send ongoing notifications
+* Make transliteration a per-device setting
+
+#### 0.56.0
+* Initial support for UM25 voltage meters
+* Pebble: Remove read/dismissed notifications on watch (can be disabled)
+* Fossil Hybrid HR: Remove read/dismissed notifications on watch (can be disabled)
+* Fossil Hybrid HR: Fixes for running firmware DN1.0.2.20 or newer (disable widget features, currently not supported)
+* Fossil Hybrid HR: Fix up/back navigation and add titles to Fossil specific menus
+* Huami: Fix displaying title for calendar events
+* Use requestLegacyExternalStorage in manifest to fix file access problems in some Android 10 roms
+* Add chart to each item in activity list
+* Add scroll view to activity detail to handle landscape view
+* Add header title to activity detail
+
+#### 0.55.0
+* InfiniTime: Fix music control for newer firmware releases
+* InfiniTime: Support call control and notification
+* Sony SWR12: Fix broken support since 0.53.0
+* Wasp-OS: Fix crash on Android 7 and lower
+* Add activity list dashboard summary calculations
+* Add battery level logging and graph activity
+* Use distinct icons for total step and distance values in widget
+* Flip and scale GPX canvas
+* Try to fix call notification on outgoing VoIP calls
+
+#### 0.54.1
+* Amazfit GTS2e: Really fix broken support
+* Amazfit Bip S Lite: Fix broken support (probably)
+
+#### 0.54.0
+* Initial support for Amazfit X
+* Fix missing menu items for GTS 2 Mini (some improvements also for other GTR2/GTS2 models)
+* Amazfit GTS2e: Fix broken support
+
+#### 0.53.0
+* Initial support for wasp-os on nRF52 devices
+* Initial support for Zepp E
+* Initial support for Amazfit GTS 2 Mini
+* Initial support for Amazfit Neo
+* Initial support for Amazfit GTR/GTS 2e
+* Fossil Hybrid HR: Fix bug with unknown data
+* Fossil Hybrid HR: allow app management on watch from GB
+* Fossil Hybrid HR: enumerate apps on watch on every connect
+* Fossil Hybrid HR: Do not configure buttons in unauthenticated mode
+* Fossil Classic: Fix unknown wearing state
+* Fossil Classic: Allow synchronizing activity data
+* Amazfit Bip U: Fix sports activity summary
+* Huami: Add Strength Training activity type
+* Honor Imperial units settings in widget, Activity list and workouts
+* Show all eligible devices in Widget Configuration Activity
+* Also include step of not-worn samples in weekly step statistics
+
+#### 0.52.0
+* Amazfit Bip U Pro: Initial support
+* Amazfit GTS2: fix pairing
+* Amazfit GTS/GTR2: Fix incoming call display
+* Fossil Hybrid HR: avoid unnecessary widget rendering
+* A lot of Data(base) Management screen clarifications and improvements
+* Fix a crash when when forecastConditionType length is 0 in weather notification data
+* Change Do Not Disturb support to allow priority notifications
+* Fix problems when pairing some devices which require a pin to be entered
+
+#### 0.51.0
+* Amazfit Bip U: Initial support
+* Amazfit Verge Lite: Initial Support
+* Amazfit T-Rex: Add missing menu items, remove non-existent
+* Amazfit Bip S: Fix crash with notifications with only a title (GitNex does this)
+* Casio GBX-100: Add step count data and more device settings
+* Fossil Hybrid Q: Support firmware upgrade
+* Bangle.js: Support for HRM and steps activity recording
+* Huami: Add new option to properly distinguish connected advertising and 3rd party hr exposure
+* Huami: Use blue icon instead of rainbow color icon for Signal (the rainbow icon was blue in early days)
+* Complete overhaul of the daily stats widget
+* Better error message for invalid authentication keys
+
+#### 0.50.0
+* Initial support for Casio GBX-100
+* Mi/Amazfit Band 5: Support watchface installation
+* Mi Band 5: Add missing NFC menu item
+* Casio GB-5600B/GB-6900B: Add configurable disconnect notification
+* Casio: Add support for synchronizing profile settings
+* Fossil Hybrid HR: Keep widget values after widget reload
+* Allow sorting and disabling charts/statistics tabs in per-device settings
+* Improve Sports Activities dashboard and Filter
+* Bug fixes for notification removal
+* Allow to clear activity labels
+
+#### 0.49.0
+* Initial support for Amazfit Bip S Lite
+* Initial support for Amazfit GTR/GTS 2
+* Huami: allow sorting of shortcuts and menus (all except Mi Band 2)
+* Amazfit Band 5: Allow enabling SpO2 menu
+* Mi/Amazfit Band 5: Support shortcuts (right/left swipe)
+* Amazfit GTS: Fix firmware flashing on Firmware >=0.1.1.16
+* Amazfit GTR: Fix firmware flashing on Firmware >=1.3.7.16 or >=1.7.0.10
+* Amazfit GTR/GTS: Add missing settings menu item in preferences
+* Fossil Hybrid HR: Remove Android notifications when deleting them from the watch
+* Fossil Hybrid HR: Enable rejecting calls on newer firmwares
+* Fossil Hybrid HR: Support hands calibration
+* Fossil Hybrid HR: Support factory reset
+* InfiniTime: Improve notification support for firmware >=0.9
+* Add version to About screen
+* Show GPS track in Sport Activity detail screen
+* Add Activity List Dashboard/Summary view to charts
+* Add heart rate average to Activity and Sleep charts
+* Add intensity to Sleep charts
+* Recognize Wire messenger as a chat application
+* Add confirmation dialog for find device button
+
+#### 0.48.0
+* Initial support for Sony SWR12
+* Initial support for Lefun Smart Bands
+* Initial support for Nut devices
+* InfiniTime: Improved music support for latest firmware
+* Fossil Hybrid HR: Fixes and better support for newer firmwares
+* Fossil Hybrid HR: Debug activity for dumping and sending resources to the watch
+* Huami: Improve style of sports activity lists
+* Add sport activity list tab in charts
+* Allow sharing of sports activity summaries as image (full scroll view)
+* Weather: Fix wind speed and direction not being passed properly
+* Fix find your phone feature on Android 10 (need companion device pairing)
+
+#### 0.47.2
+* Amazfit Bip S: Send sunrise and sunset on latest firmware if enabled
+* Huami: Support new firmware update protocol (fixes firmware flashing with firmware 2.1.1.50/4.1.5.55 on Amazfit Bip S)
+* Huami: Allow flashing latest GPS firmware
+* InfiniTime: Add support for music control
+* Pebble: Fix steps on home screen widget
+* Bangle.js: Fix issue where call state reporting was corrupted
+* Add charts to sport activity summary view
+* Add missing icons for new sport activity types
+
+#### 0.47.1
+* Huami: Add new activity types found in recent Bip S firmware
+* Huami: Many improvements to the activity summary view, including a global view for all devices, filtering per activity type and much more
+* Huami: Prevent generating broken elevation data when they are not sent by the device
+* Amazfit Bip S: Allow flashing more font files and GPS almanac (only cep worked before)
+* Pinetime-JF: Recognize device if it announces itself as InfiniTime
+* ZeTime: Fix weather forecast icons on older firmwares, try to send weather even if no firmware version was detected
+* HPlus: Improve Unicode, notification lenth and weather support
+* Fix warnings and colors for AboutScreen
+
+#### 0.47.0
+* Initial experimental support for Pinetime-JF (not yet usable)
+* HPlus: Recognize Lemfo SG2
+* Huami: Support events forwarding via intents or direct triggering of certain actions (eg. stop music when fall asleep)
+* Huami: Add Sports Activity Detail screen from decoded sports summary values
+* Huami: Recogize and decode lot more activity (workout) types
+* Amazfit Cor/Cor2: Allow workout syncing
+* Add Sports Activity Summary filtering and statistics
+* Many icons have been re-drawn as vectors, also several new device and sports activity icons added
+* Many improvements to the Bluetooth discovery and scanning
+* Fix crash when opening GPX files
+
+#### 0.46.0
+* Initial support for Mi Band 5
+* Initial support for TLW64
+* Amazfit GTR/GTS: Fix broken activity data on newer firmwares
+* Big refactoring of the device discovery activity (See PR #1927 description for details)
+* Add about screen
+* New icon for Amazfit Bip
+* Avoid duplicated entries in preferred media player selection
+* Avoid a lot of crashes and improve error handling in various places
+
+#### 0.45.1
+* Amazfit GTR/GTS: Fix connection issue with latest firmwares (probably other Huami devices also affected)
+* Add experimental support for TinyWeatherForecastGermany
+
+#### 0.45.0
+* Initial support for Amazfit T-Rex
+* Amazfit Bip S: Support installation of latest .res
+* Amazfit Bip S: Support longer notification messages
+* Huami: Limit weather forecast to 7 days to fix problems with "Weather Notification" 0.3.11
+* Huami: Improve music playback information
+* Huami: Ensure cutting strings on UTF-8 border
+* Stop incoming call notification when VoIP calls are missed
+* Fix a crash when with Farsi translation
+
+#### 0.44.2
+* Huami: Support flashing newer GPS firmware and GPS ALM
+* Amazfit Bip S: Support music control
+* Amazfit Bip S: Support flashing firmware, res, gps firmware, watchfaces, fonts and GPS CEP
+* Amazfit Bip S: Allow setting high MTU (much faster firmware installation, default off since it does not work for some)
+* Amazfit Bip S: remove disconnect notification and button action settings (they do not work)
+* Mi Band 4 (possibly others): Fix detected RES version being always 69 for non-whitelisted res files
+* Fossil Hybrid HR: Add last notification widget
+* Try to fix vanishing incoming call information when VoIP call support is enabled
+* Allow setting device aliases (useful if you manage multiple ones of the same type)
+
+#### Version 0.44.1
+* Amazfit Bip S: Support setting shortcuts
+* Amazfit Bip S: Fix setting display items
+* Amazfit Bip S: Fix incoming call notification
+* Huami: Fix menu items vanishing from the device when they were never configured through Gadgetbridge
+* Lenovo Watch9: Fix launch of wrong calibration activity
+* Reduce calls to onSetMusicInfo/onSetMusicState when playing music
+
+#### Version 0.44.0
+* Initial support for WatchX(Plus)
+* Add support for Amazfit GTR Lite (untested and incomplete)
+* Fossil Hybrid HR: Fix some issues with custom widgets
+* Fossil Hybrid HR: Allow setting alarm titles and descriptions
+* Fossil Hybrid HR: Fix step data parsing
+* Amazfit GTS: Fix setting menu items with low MTU
+* Amazfit GTR: Allow setting menu item like GTS
+* ZeTime: Support setting the watch language
+* ZeTime: Support rejecting calls
+* ZeTime: Try to fix weather conditions on newer firmware
+* ZeTime: Fix could not synchronize calendar on connect
+* ZeTime: Fix calendar event time and date
+* ZeTime: Send up to 16 upcoming calendar events on connect if option is enabled
+* Allow set light/dark theme according to system settings (new default)
+
+#### Version 0.43.3
+* Fossil Hybrid HR: Initial support for activity tracking (no sleep yet)
+* Fossil Hybrid HR: Support setting alarms on newer firmware
+* Amazfit GTR/GTS: Fix flashing watchfaces and maybe firmware/res update (still untested)
+* Amazfit GTS: Support enabling/disabling menu items on the watch
+* Implement transliteration for Korean
+
+#### Version 0.43.2
+* Fossil Hybrid HR: Allow choosing and cropping image to be set as watch background
+* Fossil Hybrid HR: Option to draw circles around widgets
+* Fossil Hybrid HR: Experimental firmware update support
+* Fossil Hybrid HR: Fix vibration strength setting
+* Huami: Do not display firmware information and whitelist information when flashing watchfaces
+* Huami: Disable air quality indicator on Huami devices instead of showing 0
+* Bangle.js: Change encoded char set to match Espruino's 8 bit fonts
+* Steps/Sleep averages: Skip days with zero data
+
+#### Version 0.43.1
+* Initial support for Amazfit Bip S (incomplete, needs the official app once to obtain the pairing key)
+* Amazfit Bip Lite: Allow relaxing firmware checks to allow flashing of the regular Bip firmware (for the brave)
+* Fossil Hybrid HR: Fix notification history on newer firmwares
+* Fossil Hybrid HR: Add option to disable widget circle
+* Bangle.js: Don't set time if the option is turned off in settings
+* Bangle.js: DST and time zone fixes
+* Add Arabic-style Eastern Arabic numerals to transliteration
+
+#### Version 0.43.0
+* Initial support for Fossil Hybrid HR (needs complicated key extraction, read wiki)
+* Fossil: Allow switching off the Q Icon and use the default Gadgetbridge icon
+* Fix VoIP call handling during DND
+* Fix find-my-phone for Android 10
+* Huami: Fix crash when calendar event description or title was null
+* Huami: Ignore all-day events when syncing calendar events
+
+#### Version 0.42.1
+* Fix accepting/rejecting calls on Android 9
+* Mi Band 3/4, Amazfit Bip/Cor/GTS/GTR: Option to sync calendar events as reminder
+
+#### Version 0.42.0
+* Initial iTag support
+* Fix indefinitely lasting Bluetooth scans when location permission has not been granted
+* Try to stop incoming VoIP call notification when the call is answered
+* Vectorize some icons and add a new Mi Scale 2 icon
+* Mi Band 4: Make high MTU optional, fixes problems on some phones
+* ZeTime: Fix probably broken support (duplicate id used by Fossil)
+
+#### Version 0.41.1
+* Huami: allow to have alarms without snooze feature
+* Mi Band 2: Properly stop a call notification when text notifications are disabled
+* VoIP calls: ignore notifications with only one action, assuming it is an outgoing call
+* Try to fix notifications from Business Calendar
+
+#### Version 0.41.0
+* JYou Y5: Initial support
+* Mi Band 2/Amazfit Bip: Redesign button actions for easy music control setup and support long presses on Bip
+* Amazfit Bip: Remove RES file limit (for BipOS)
+* Huami: Automatically toggle alarm switch when toggling on the Band/Watch while in Alarm settings in Gadgetbridge
+* Recognize Pixart-Messenger as Chat App
+
+#### Version 0.40.1
+* Mi Band/Amazfit: Recognize changes when toggling alarm on device (immediately when connected, else when connecting)
+* Mi Band/Amazfit: Fix some bugs with stuck connection when re-connecting
+* Mi Band 4: Support higher MTU for multiple times faster firmware transfer (probably also Amazfit GTR/GTS)
+* Amazfit Cor: Fix setting language to Chinese manually
+
+#### Version 0.40.0
+* Fossil Q Hybrid: Initial support
+* Bangle.js: Initial support
+* Reserve Alarm for Calendar feature restricted to Mi Band 1/2 and moved to per-device settings
+* New icon for App Manager
+
+#### Version 0.39.1
+* Try to actively re-connect when a connection gets interrupted (interval grows up to 64 seconds)
+* Mi Band2/Amazfit Bip: Make button action settings per-device and enable for Amazfit Bip
+
+#### Version 0.39.0
+* Amazfit GTS: Initial and incomplete support, mostly untested
+* Add forward/backward buttons to charts for faster navigation
+* Debug: allow to reset last fetch date for Huami devices
+
+#### Version 0.38.0
+* Amazfit GTR: Initial and incomplete support, mostly untested
+* Amazfit Bip: add Portuguese to the list of selectable languages
+* Mi Band 4: Enable emoji font setting
+* Makibes HR3: Support the English version
+* Makibes HR3: Enable Bluetooth pairing for working re-connection
+* Work around crash when trying to display changelog
+* Sleep detection settings: Rolling 24 hours (existing style) or Noon to noon
+* Add alternative color to heart rate in chart settings
+
+#### Version 0.37.1
+* Amazfit Bip Lite: Support flashing firmware and watch faces
+
+#### Version 0.37.0
+* Initial Makibes HR3 support
+* Amazfit Bip Lite: Initial working support, firmware update is disabled for now (we do not have any firmware for testing)
+* Amazfit Cor 2: Enable Emoji Font setting and 3rd party HR access
+* Find Phone now also vibration in addition to playing the ring tone
+* ID115: All settings are now per-device
+* Time format settings are now per-device for all supported devices
+* Wrist location settings are now per-device for all supported devices
+* Work around broken layout in database management activity
+* Show toast in case no app is installed which can handle GPX files
+* Mi Band 4/Amazfit Bip Lite: Trim white spaces and new lines from auth key
+* Mi Band 4/Amazfit Bip Lite: Display a toast and do not try to pair if there was no auth key supplied
+* Skip service scan if supported device could be recognized without UUIDs during discovery
+
+#### Version 0.36.2
+* Amazfit Bip: Untested support for Lite variant
+* Force Lineage OS to ask for permission when Trust is used to fix non-working incoming calls
+* Charts: List multiple sleep sessions per day
+
+#### Version 0.36.1
+* Mi Band 2/3/4, Amazfit Bip/Cor: Add setting to expose the HR sensor to 3rd party apps
+* Mi Band 4: Really fix weather location not being updated on the Band
+* Mi Band 4: Fix call notification not stopping when call gets answered or rejected on the phone
+* Amazfit Bip/Cor: Support for custom emoji font
+* ZeTime: Enable emoji support
+* ZeTime: Make watch language the same as the phone language by default
+* New status and alarms widget
+* Fix crash when entering notification filter settings
+* Make diagram settings accessible from charts activity
+* Add option to hide the floating plus button in the main activity
+* Fix a potential crash on Android 4.4 KitKat
+
+#### Version 0.36.0
+* Initial Mijia LYWSD02 support (Smart Clock with Humidity and Temperature Sensor), just for setting the time
+* Mi Band 3/4: Allow enabling the NFC menu where supported (useless for now)
+* Mi Band 3/4, Amazfit Cor/Bip: Set language immediately when changing it (not only on connect)
+* Mi Band 3/4, Amazfit Cor/Bip: Add icons for "swimming" and "exercise"
+* Mi Band 4: Support flashing the V2 font
+* Mi Band 4: Fix weather location not being updated on the Band
+* Mi Band 4: remove unsupported DND setting from settings menu
+* Amazfit Bip/Cor: Fix resetting of last fetched date for sports activities
+* Amazfit Bip: Fix sharing GPX files for some Apps
+* Pebble: Use Rebble Store URI
+* Support LineageOS 16.0 weather provider
+* Add Averages to Charts
+* Allow togging between weekly and monthly charts
+
+#### Version 0.35.2
+* Mi Band 1/2: Crash when updating firmware while phone is set to Spanish
+* Mi Band 4: Enable music info support (displays now on the band)
+* Mi Band 4: Support setting date format (for built-in watch-faces)
+* Amazfit Cor 2: Try to fix empty menu on device
+
+#### Version 0.35.1
+* Mi Band 4: Support flashing watch-faces, res and firmware (.ft untested)
+
+#### Version 0.35.0
+* Mi Band 4: Initial support (WARNING: INITIAL SETUP NEEDS MI FIT WITH ACCOUNT AND ROOT, NOT A RECOMMENDED DEVICE FOR GADGETBRIDGE)
+
+#### Version 0.34.1
+* Mi Band 1: Fix crash when entering per-device settings
+* Mi Band 3: Allow setting date format in per-device settings
+* ZeTime: Fix time stamps
+* Fix a crash when flashing an non-whitelisted firmware while using Gadgetbridge in Spanish
+
+#### Version 0.34.0
+* Mi Band 1/2/3/Bip/Cor: Migrate many settings to per-device settings (new settings icon in device card in main activity)
+* Mi Band 3: Fix setting menu items with 2.4 firmware and add support for the new timer menu
+* Amazfit Bip/Cor, Casio: Add support for muting incoming calls
+* ZeTime: Remove endless recursion in ZeTime settings
+* Recognize FairEmail notifications as generic email notifications
+
+#### Version 0.33.1
+* Mi Band 3: Recognize "Xiaomi Band 3"
+* Amazfit Bip: Add German, Italian, French and Turkish to language settings
+
+#### Version 0.33.0
+* BFH-16: Initial support
+* Mi Band 2/3/Bip/Cor: Generate random per-device security keys when pairing, allow manual override to still support multiple android devices connecting to the same device
+* Mi Band 3: Add Indonesian, Thai, Arabic, Vietnamese, Portuguese, Dutch, Turkish and Ukrainian to language settings
+* Mi Band 3: Support flashing latest Japanese-Korean font
+* Amazfit Cor 2: Initial experimental support (untested)
+* Pebble: Add PebbleKit extension for reopening last app
+* Casio: Bug fixes and improvements
+* Lookup contacts also in work profile
+* Fix searching in application name when blacklisting
+* Remove misleading title from database management activity when no legacy database is available
+
+#### Version 0.32.4
+* Make VoIP call support optional (disabled by default)
+* Amazfit Bip: GPX export corrections
+* ZeTime: Fix setting alarms
+* ZeTime: Fix wrong activity timestamps
+* ZeTime: Set HR alarm limits when changed, not only on connect
+* ZeTime: Sync preferences from the watch to Gadgetbridge settings
+
+#### Version 0.32.3
+* Fix a crash in charts due to a broken German translation
+* Fix a crash when transliterating emoji
+* Amazfit Bip/Cor: Support disconnect notification (must be configured in Bip settings for Cor also for now)
+
+#### Version 0.32.2
+* Fix setting alarms under some circumstances
+* Support calls notifications for some VoIP apps
+* Mi Band 3: Enable fetching sports activities (currently only useful for flushing activities)
+* Casio: Improve stability
+* Casio: Add explicit support for GB-6900B, GB-X6900B and GB-5600B
+
+#### Version 0.32.1
+* Fix db deadlock on alarm migration
+
+#### Version 0.32.0
+* Initial support for Casio GB-6900B
+* Increase number of alarms and store them per-device
+* Support factory reset in debug activity (Mi Band 1/2/3, Bip, Cor)
+* Filter out Unicode control sequences (fixes problems with Telegram and probably others)
+* Fix endless loop resulting in OOM when RTL support is enabled
+* Recognize p鈮 as an email app
+* No longer display Android paired devices in that were not a paired with Gadgetbridge
+* Amazfit Bip: Allow flashing latest GPS firmware
+* Pebble: Native support for M7S watch face
+* No1 F1: Support for a Chinese clone
+
+#### Version 0.31.3
+* Pebble: Fix crash with DISMISS and OPEN actions
+
+#### Version 0.31.2
+* Pebble: Fix a regression that caused non-working mute, open and dismiss actions
+* Fix setting language to Czech manually
+* Ignore summary notification from K-9 Mail (caused notification spamming)
+
+#### Version 0.31.1
+* Pebble: Fix crash when no canned replies have been set
+* Pebble: Let the firmware show localized default canned replies if none have been set
+* Amazfit Bip: Fix importing GPS tracks that have been recorded with Firmware 1.1.5.02
+* Display measured hr value in debug screen
+
+#### Version 0.31.0
+* Pebble: Send all wearable notification actions (not only reply)
+* Pebble: Always allow reply action even if untested features are turned off
+* Pebble: Temporarily disable broken auto remove notification feature
+* Amazfit Bip: Allow flashing latest GPS firmware (Mili_dth.gps)
+* Mi Band 3/Amazfit Bip/Amazfit Cor: Send Fahrenheit if units are set to imperial
+* Roidmi 3: Fix and enable support
+* Mi Band 3/Amazfit Bip: fix find phone crash
+* Prevent re-sending old notifications to the wearable
+* Enhancement and Fixes for Bengali Transliteration
+* Disable excessive logging in RTL support
+
+#### Version 0.30.0
+* Amazfit Bip + Mi Band 3: Support for right to left display (configurable) (#976)
+* Add Arabic, Bengali Farsi, Persian, Scandinavian transliteration
+* Add support for some Roidmi FM receivers
+* Mi Band 3: Allow enabling the "Workout" menu item
+* Mi Band 3: Support for night mode configuration
+* Huami devices: fix seldom activity/sports synchronization problem (#1264)
+* Preferences: Make minimum heart rate configurable (lower values will be disregarded)
+* Preferences: Configure minimum time between notifications
+* Preferences: Group language settings
+* Attempt to fix BLE connection issues on Samsung S devices
+* Week sleep and steps charts: display balance (actual value vs. desired value)
+* Live Activity: show current/maximum heart rate, display minute steps and total steps and more improvements
+* Live Activity: fix discrepancy between number of steps in Gadgetbridge and wearable device
+* Fix missing caller ID for incoming calls on Android 9
+* Support for easy sharing of log files via the Debug screen
+* Misc small bugfixes
+
+#### Version 0.29.1
+* Mi Band 3: Support setting language to to German, Italian, French, Polish, Japanese, Korean (read wiki)
+* Mi Band 3: Support flashing latest RES files
+* Mi Band 3: Fix notification text not being displayed
+* Mi Band 3/Cor/Bip: Display app name when no app specific icon is available
+* Teclast: add/improve H1 and H3 watch recognition
+* Support transliteration for Lithuanian and Bengali
+* Fix BLE reconnect issues in certain conditions
+* Various fixes for display issues on small screens
+* Fix some potential NPEs
+* WIP: Display start and end of sleep in statistics
+
+#### Version 0.29.0
+* New Device: Initial support for ID115
+* New Device: Initial support for Lenovo Watch9
+* Show splash screen during startup
+* Vertically align device icon in main activity
+* Try to support the google clock application (untested)
+* Amazfit Cor: Allow to configure displayed menu items
+* Amazfit Cor: Support basic music control
+* Amazfit Cor: Fix flashing font files
+* Amazfit Bip: improved GPX export
+* Amazfit Bip: Fix exported GPX file names for *FAT storage
+* Amazfit Bip: Fix current weather not being displayed with later firmwares
+* Amazfit Bip/Cor: Try to fix device being sometimes stuck in connecting state
+* Mi Band 2: Put some device specific settings into its own settings category
+* Mi Band 3: Support disabling of on-device menu items
+* Mi Band 3: Support locking the Mi Band screen (swipe up to unlock)
+* Mi Band 2/3: New icon
+* NO1 F1: Set time during initialization
+
+#### Version 0.28.1
+* Fix wrong weather icon mapping in rare cases
+* Fix device discovery on Android 4.4
+* Amazfit Bip: Use UTC in gpx tracks for better compatibility with external software
+* Amazfit Bip: Add the (localized) activity type to the gpx filename
+* Amazfit Bip: Fix weather on latest firmwares
+
+#### Version 0.28.0
+* Initial support for ZeTime: time, weather and activity data sync, notification support and music playback control is working
+* Amazfit Bip/Cor: Rework firmware detection to cope with new version scheme
+* Amazfit Bip: Support setting language to Russian
+* Amazfit Cor: Support language switching on newer firmwares
+* Mi Band 3: support setting language (english and spanish tested)
+* Mi Band 3: Fix pairing
+* Mi Band 3: Send AQI to enable display of current temperature
+
+#### Version 0.27.1
+* Pebble: Change app store search to point to RomanPort's Pebble app store
+* Mi Band 3: Allow flashing fonts (untested)
+* Amazfit Bip: Allow flashing latest firmwares
+* Amazfit Cor: Allow flashing Bip fonts (untested)
+* Allow to limit auto fetch to a user configurable time interval
+
+#### Version 0.27.0
+* Initial support for Mi Band 3 (largely untested, needs to be connected to Mi Fit once)
+* Option for automatic activity sync after screen unlock
+* Allow hiding activity transfer notification on Android Oreo and above
+* Allow blacklisting of PebbleKit notifications for individual apps
+* Allow blacklisting all application at once
+* Forward Skype notifications to wearable even if "local only" flag is set
+* Show Gadgetbridge logo behind cards in main activity
+* Always stop BT/BTLE discovery when exiting the discovery activity
+* Amazfit Bip/Cor: Fix scheduled setting for "display on lift wrist" preference
+* Amazfit Bip/Cor: add recent firmwares to whitelist
+* Pebble: Fix a rare crash in WebView
+
+#### Version 0.26.5
+* Fix autoreconnect at boot on recent Android versions
+* Bluetooth connection is more stable on Oreo
+* Potentially fix the watch continuously vibrating after call pickup
+* Amazfit Bip: Add setting to configure shortcuts (swipe to right from watchface)
+* Recognize Q8 as a HPlus device
+
+#### Version 0.26.4
+* Fix a bug with Toasts appearing every time a notification arrives when bluetooth is disabled
+* Pebble 2: Add optional GATT client only mode that might help with connection stability
+* Amazfit Cor: Fix detection of newer firmwares
+* Mi Band 2: Fix text notifications not appearing with short vibration patterns
+
+#### Version 0.26.3
+* Amazfit Bip: Add proper mime type to shared gpx files
+* Amazfit Bip: allow to set displayed menu items
+* Amazfit Bip: fix fetching logs from device via debug menu
+* Amazfit Bip: Raise .res limit to 700000 bytes for modded files
+
+#### Version 0.26.2
+* Amazfit Bip: Time and timezone fixes for Android <=6 when exporting GPX
+
+#### Version 0.26.1
+* Fix crashes and connection problems on Android 6 and lower
+
+#### Version 0.26.0
+* Amazfit Bip: Initial support for GPS tracks
+* Pebble: Wind speed/direction support and bugfixes for weather when using background javascript
+
+#### Version 0.25.1
+* Amazfit Cor: Try to send weather location instead of AQI
+* Amazfit Bip: Support setting start end end time for background light when lifting the arm
+* Pebble: various fixes and improvements for background javascript
+* Explicitly ask for RECEIVE_SMS permission to fix problems with Android 8
+
+#### Version 0.25.0
+* Initial support for Xwatch
+* Move the connected device to top in control center
+* Add adaptive launcher icon for Android 8.x
+* No longer plot heart rate graph when device was detected as not worn
+* Pebble: Small fixes for background js (e.g. Pebble-Casio-WV58DE)
+* Pebble: native (non bg js) support for weather in Simply Light watchface
+
+#### Version 0.24.6
+* Display the chat icon for notifications coming from Kontalk and Antox
+* Pebble: Fix for background js which try to send floats (e.g. TrekVolle)
+* Mi Band 2: Change the way vibration patterns work, also fixes problems with missing text on newer firmwares
+
+#### Version 0.24.5
+* Fix crash in settings activity with export location
+* Fix notification deletion regression
+* Add '艁' and '艂' to transliteration map
+* Omnijaws Weather: correctly pick today's min and max temperature
+* Fix alarm details activity on small screen
+* Pebble: mimic online check of TrekVolle when using background js
+
+#### Version 0.24.4
+* Amazfit Bip: Fix language setting on new firmwares
+
+#### Version 0.24.3
+* Charts: Try to fix another crash
+* Pebble: Fix weather for some watchfaces when using background JS
+* Amazfit Cor: Allow watchfaces to be flashed (untested)
+* Amazfit Bip: Better detection for flashable font types
+* Fix number only privacy option
+
+#### Version 0.24.2
+* Fix crash when changing the periodic database export interval
+* Amazfit Bip: Allow fonts and new res format to be flashed
+* Amazfit Cor: Allow new res format to be flashed
+* Pebble: Background js fixes
+
+#### Version 0.24.1
+* Amazfit Bip: prevent menu icons from vanishing when using firmware 0.1.0.51
+* Pebble: "find phone" feature for upcoming pebble helper app
+
+#### Version 0.24.0
+* Fix logs sometimes not containing stacktraces
+* Support periodic database export
+* Support transliteration for Arabic and Farsi
+* Try to make alarm details scrollable (for small devices)
+* Amazfit Bip: Implement find phone feature
+* Amazfit Bip: Support flashing latest GPS firmware
+* Amazfit Cor: Support flashing latest firmware
+* Pebble: Fix crash with experimental background javascript
+* Charts: Several fixes to the MPAndroidChart library
+
+#### Version 0.23.2
+* Mi Band 1S: Fix sync problem with firmware 4.16.11.15 (probably also Mi Band 1.0.15.0 and Mi Band 1A 5.16.11.15)
+* Amazfit Cor: Fix problem with firmware >=1.0.6.27 being detected as Mi Band 2
+
+#### Version 0.23.1
+* Initial support for Omnijaws weather service
+* Amazfit Bip: Allow installation of latest gps firmware
+* Amazfit Cor: Fixes for installing newer firmware versions
+
+#### Version 0.23.0
+* Initial support for LineageOS/CyanogenMod weather provider
+* Amazfit Bip/Cor: Support for current weather temperature
+* Amazfit Bip/Cor: Display firmware version and type also for non-whitelisted firmware files
+
+#### Version 0.22.5
+* Unlock Teclast H10 support using the same code as H30
+* Amazfit Bip: Fix installation of 0.1.0.11 Firmware
+* Amazfit Bip/Cor: Send three days of weather forecast including (untranslated) conditions
+* Workaround for a crash on Android 4.4 when connecting
+
+#### Version 0.22.4
+* Mi Band 2/Bip/Cor: Whole day HR support
+* Mi Band 2/Bip/Cor: Prevent writing a lot of HR samples to the database when not using the live activity feature
+* Pebble: Fix some nasty crashes which occur since 0.22.0
+* Workaround for non-working notifications from wechat and outlook
+
+#### Version 0.22.3
+* Amazfit Bip: Allow flashing watchfaces
+* Amazfit Cor: Fix flashing new .res files
+* Mi Band 2/HRX/Bip/Cor: Try to fix stuck activity sync
+
+#### Version 0.22.2
+* Charts: Add setting to disable swiping charts left/right and some UI changes
+* Pebble: Use the configured unit system also for system weather app
+* Mi Band 2: Fix HR being absent in charts
+* Amazfit Bip: Allow manual language selection in settings
+* Amazfit Cor: Fix firmware update
+
+#### Version 0.22.1
+* Mi Band 2: Fix being detected as Amazfit Bip which lead to various problems especially on newly paired devices
+
+#### Version 0.22.0
+* Pebble: Experimental support for background javascript, allows weather and other features for watchapps without special Gadgetbridge support
+* Add experimental support for Amazfit Cor and Mi Band HRX (no firmware update on the latter)
+* Mi Band 2: Support more icons and textual notifications for more apps
+* Add some quick action buttons to Gadgetbridge's notification
+* Add transliteration support for ukrainian cyrillic characters
+* Fix annoying toast in Mi Band settings
+
+#### Version 0.21.6
+* Amazfit Bip: Fix non-working notifications from Outlook, Yahoo Mail and GMail
+* HPlus: Fix Unicode encoding
+* No.1 F1: Alarms support
+* No.1 F1: Show data fetching progress
+
+#### Version 0.21.5
+* Mi2/Bip: Support setting distance units (metric/imperial)
+
+#### Version 0.21.4
+* Mi2/Bip: Fix sleep detection for newer firmwares
+* Mi2/Bip: Fix ancient bug resulting in wrong activity data at the beginning in diagrams and aggregate data
+* No.1 F1: Support setting time format and distance units (metric/imperial)
+* Pebble: Support setting distance units to miles for Health (need to reactivate Health in App Manager after toggling)
+* HPlus: Make changing distance unit system effective immediately on toggling
+
+#### Version 0.21.3
+* Amazfit Bip: Auto-switch language on connect (English, Simplified Chinese, Traditional Chinese), requires FW 0.0.9.14+
+
+#### Version 0.21.2
+* Amazfit Bip: Support flashing CEP and ALM files for AGPS
+* Amazfit Bip: Initial experimental support for fetching logs from the watch
+* Mi2/Bip: Send user info to the device (fixes calories and distance display)
+* Mi2/Bip: Fix firmware update progressbar being stuck at the end
+* Pebble/Bip: Support more notification icons
+* Pebble: Automatically determine color for unknown notifications on Pebble Time
+
+#### Version 0.21.1
+* Initial support for EXRIZU K8 (HPLus variant)
+* Amazfit Bip: fix long messages not being displayed at all
+* Mi Band 2: Support multiple button actions
+* NO.1 F1: Fetch sleep data
+* NO.1 F1: Heart rate support
+* Pebble: Support controlling the current active media playback application
+* Fix suspended activities coming to front when rotating the screen
+
+#### Version 0.21.0
+* Initial NO.1 F1 support
+* Initial Teclast H30 support
+* Amazfit Bip: Display GPS firmware version
+* Amazfit Bip: Fix E-Mail notifications
+* Amazfit Bip: Fix call notification with unknown caller
+* Amazfit Bip: Fix crash when weather is updated and device reconnecting
+* Mi2/Bip: Fix crash when synchronizing calendar to alarms
+* Pebble: Fix crash when taking screenshots on Android 8.0 (Oreo)
+* Pebble: Support some google app icons
+* Pebble: try to support spotify
+* Mi Band 2: Support configurable button actions
+* Fix language being reset to system default
+
+#### Version 0.20.2
+* Amazfit Bip: Various fixes regarding weather, add condition string support for FW 0.0.8.74
+* Amazfit Bip: enable caller display in later firmwares
+* Amazfit Bip: initial firmware update support (EXPERIMENTAL, AT YOUR OWN RISK)
+* Re-enable improved speed zones tab
+* Probably fix crash with certain music players
+* Improve theme and add changelog icon
+
+#### Version 0.20.1
+* Amazfit Bip: Support icons and text body for notifications
+* Mi Band: Fix setting smart alarms
+
+#### Version 0.20.0
+* Initial Amazfit Bip support (WIP)
+* Various theming fixes
+* Add workaround for blacklist not properly persisting
+* Handle resetting language to default properly
+* Pebble: Pass booleans from Javascript Appmessage correctly
+* Pebble: Make local configuration pages work on most recent WebView implementation
+* Pebble: Allow to blacklist calendars
+* Add Greek and German transliteration support
+* Various visual improvements to charts
+
+#### Version 0.19.4
+* Replace or relicense CC-NC licensed icons to satisfy F-Droid
+* Mi Band 2: Make info to display on the Band configurable
+* Mi Band 2: Support wrist rotation to switch info setting
+* Mi Band 2: Support goal notification setting
+* Mi Band 2: Support do not disturb setting
+* Mi Band 2: Support inactivity warning setting
+
+#### Version 0.19.3
+* Pebble: Fix crash when calendar access permission has been denied
+* Pebble: Fix wrong timestamps with Morpheuz running on Firmware >=3
+* Mi Band 2: Improve reliability when fetching activity data
+* HPlus: Fix intensity calculation without continuous connectivity
+* HPlus: Fix Unicode handling
+* HPlus: Initial not work detection
+* Fix memory leak
+* Only show Realtime Chart on devices supporting it
+
+#### Version 0.19.2
+* Pebble: Fix recurring calendar events only appearing once per week
+* HPlus: Fix crash when receiving calls without phone number
+* HPlus: Detect unicode support on Zeband Plus
+* No longer quit Gadgetbridge when bluetooth gets turned off
+
+#### Version 0.19.1
+* Fix crash at startup
+* HPlus: Improve reconnection to device
+* Improve transliteration
+
+#### Version 0.19.0
+* Pebble: allow calendar sync with Timeline (Title, Location, Description)
+* Pebble: display calendar icon for reminders from AOSP Calendar
+* HPlus: try to fix latin characters showing as random Chinese text
+* Improve reconnection with BLE devices
+* Improve generic notification reliability by trying to restart the notification listener when stale/crashed
+* Other small bugfixes
+
+#### Version 0.18.5
+* Applied some material design guidelines to Charts and Pebble app management
+* Changed colours: deep sleep is now dark blue, light sleep is now light blue
+* Support for exporting and importing of preferences in addition to the database
+* Visual improvements of the pie charts
+* Add filter by name in the App blacklist activity
+* Pebble: improve compatibility with watch app configuration pages
+* Pebble: display battery percentage (will only update once an hour)
+* HPlus: users can now decide whether they want to pair the device or not, hopefully fixing some connection problems (#642)
+* HPlus: display battery state and warn on low battery
+
+#### Version 0.18.4
+* Mi Band 2: Display realtime steps in Live Activity
+* Mi Band: Attempt to recognize Mi Band model with hwVersion = 8
+* Alarms activity improvements and fixes
+* Make Buttons in the main activity easier to hit
+
+#### Version 0.18.3
+* Fix bug that caused the same value in weekly charts for every day on Android 6 and older
+
+#### Version 0.18.2
+* Mi Band 2: Fix crash on "chat" or "social network" text notification (#603)
+
+#### Version 0.18.1
+* Pebble: Fix Firmware installation on Pebble Time Round (broken since 0.16.0)
+* Start VibrationActivity when using "find device" button with Vibratissimo
+* Support material fork of K9
+
+#### Version 0.18.0
+* All new GUI for the control center
+* Add Portuguese pt_PT and pt_BR translations
+* Add Czech translation
+* Add Hebrew translation and transliteration
+* Consistently display device specific icons already during discovery
+* Add sleep chart displaying the last week of sleep
+* Huge speedup for weekly charts when changing days
+* Drop support for importing pre Gadgetbridge 0.12.0 database
+* Pebble: allow configuration web pages (clay) to access device location
+* Mi Band 2: Initial support for text notifications, caller ID, and icons (requires font installation) (#560)
+* Mi Band 2: Support for flashing Mili_pro.ft* font files
+* Mi Band 2: Improved firmware/font updated
+* Mi Band 2: Set 12h/24h time format, following the Android configuration (#573)
+* Improved BLE discovery and connectivity
+
+#### Version 0.17.5
+* Automatically start the service on boot (can be turned off)
+* Pebble: PebbleKit compatibility improvements (Data logging)
+* Pebble: Display music shuffle and repeat states for some players
+* Pebble 2/LE: Speed up data transfer
+
+#### Version 0.17.4
+* Better integration with android music players
+* Privacy options for calls (hide caller name/number)
+* Send a notification to the connected if the Android Alarm Clock rings (com.android.deskclock)
+* Fixes for Cyrillic transliteration
+* Pebble: Implement notification privacy modes
+* Pebble: Support weather for Obsidian watchface
+* Pebble: add a dev option to always and immediately ACK PebbleKit messages to the watch
+* HPlus: Support alarms
+* HPlus: Fix time and date sync and time format (12/24)
+* HPlus: Add device specific preferences and icon
+* HPlus: Support for Makibes F68
+
+#### Version 0.17.3
+* HPlus: Improve display of new messages and phone calls
+* HPlus: Fix bug related to steps and heart rate
+* Pebble: Support dynamic keys for natively supported watchfaces and watchapps (more stability across versions)
+* Pebble: Fix error Toast being displayed when TimeStyle watchface is not installed
+* Mi Band 1+2: Support for connecting without BT pairing (workaround for certain connection problems)
+
+#### Version 0.17.2
+* Pebble: Fix temperature unit in Timestyle Pebble watchface
+* Add optional Cyrillic transliteration (for devices lacking the font)
+
+#### Version 0.17.1
+* Pebble: Fix installation of some watchapps
+* Pebble: Try to improve PebbleKit compatibility
+* HPlus: Fix bug setting current date
+
+#### Version 0.17.0
+* Add weather support through "Weather Notification" app
+* Various fixes for K9 mail when using the generic notification receiver
+* Add a preference to hide the persistent notification icon of Gadgetbridge
+* Pebble: Support for build-in weather system app (FW 4.x)
+* Pebble: Add weather support for various watchfaces
+* Pebble: Add option to disable call display
+* Pebble: Add option to automatically delete notifications that got dismissed on the phone
+* Pebble: Bug fix for some PebbleKit enabled 3rd party apps (TCW and maybe other)
+* Pebble 2/LE: Improve reliability and transfer speed
+* HPlus: Improved discovery and pairing
+* HPlus: Improved notifications (display + vibration)
+* HPlus: Synchronize time and date
+* HPlus: Display firmware version and battery charge
+* HPlus: Near real time Heart rate measurement
+* HPlus: Experimental synchronization of activity data (only sleep, steps and intensity)
+* HPlus: Fix some disconnection issues
+
+#### Version 0.16.0
+* New devices: HPlus (e.g. Zeblaze ZeBand), contributed by Joa虄o Paulo Barraca
+* ZeBand: Initial support: notifications, heart rate, sleep monitoring, user configuration, date+time
+* Pebble 2: Fix Pebble Classic FW 3.x app variant being prioritized over native Pebble 2 app variant
+* Charts (Live Activity): Fix axis labels color in dark theme
+* Mi Band: Fix ginormous step count when using Live Activity
+* Mi Band: Improved performance during activity sync
+* Mi Band 2: Fix activity data missing after doing manual hr measurements or live activity
+* Support sharing firmwares/watchapps/watchfaces to Gadgetbridge
+* Support for the "Subsonic" music player (#474)
+
+#### Version 0.15.2
+* Mi Band: Fix crash with unknown notification sources
+
+#### Version 0.15.1
+* Improved handling of notifications for some apps
+* Pebble 2/LE: Add setting to limit GATT MTU for debugging broken BLE stacks
+* Mi Band 2: Display battery status
+
+#### Version 0.15.0
+* New device: Liveview
+* Liveview: initial support (set the time and receive notifications)
+* Pebble: log pebble app logs if option is enabled in pebble development settings
+* Pebble: notification icons for more apps
+* Pebble: Further improve compatibility for watchface configuration
+* Mi Band 2: Initial support for firmware update (tested so far: 1.0.0.39)
+
+#### Version 0.14.4
+* Pebble 2/LE: Fix multiple bugs in re-connection code, honor reconnect tries from settings
+* Mi Band 2: Experimental support for activity recognition
+* Mi Band 2: Fix time setting code
+
+#### Version 0.14.3
+* Pebble: Experimental support for pairing and using all Pebble models via BLE
+* Mi Band 1: Fix regression causing display of wrong activity data (#440)
+* Mi Band 2: Support for continuous heart rate measurements in live activity view
+
+#### Version 0.14.2
+* Pebble 2: Fix a bug where the Pebble got disconnected by other unrelated LE devices
+
+#### Version 0.14.1
+* Mi Band 2: Initial experimental support for activity data
+* Mi Band 2: Send the fitness goal (steps) to the band
+* Pebble 2: Work around firmware installation issues (tested with upgrading 4.2 to 4.3)
+* Pebble: Further improve compatibility for watchface configuration
+* Pebble: add Kickstart watch face to app manager on FW 4.x
+* Charts: display the total time range, not just the range with available data
+
+#### Version 0.14.0
+* Pebble 2: Initial experimental support for P2/PT2 using BLE
+* Pebble: Special support in device discovery activity (MUST be used to get Pebble 2 working)
+* Pebble: Improve compatibility for watchface configuration
+* Mi Band 2: support for heart rate measurement during sleep
+* Mi Band 2: configuration option to activate the display on lift
+* Mi Band 2: configuration option to display the time + date or just the time
+* Mi Band 2: honor the wear location configuration option
+
+#### Version 0.13.9
+* Pebble: use the last known location for setting sunrise and sunset
+* Pebble: fix Health disappearing forever when deactivating through app manager (and get it back for affected users)
+* Mi Band 2: More fixes for connection issues (#408)
+
+#### Version 0.13.8
+* Mi Band 2: fix connection issues for users of Mi Fit (#408, #425)
+* Mi Band 1A: fix firmware update for certain 1A models
+
+#### Version 0.13.7
+* Pebble: Fix configuration of certain pebble apps (eg. QR Generator, Squared 4.0)
+* Pebble: Add context menu option in app manager to search a watchapp in the Pebble app store
+* Mi Band: allow to delete Mi Band address from development settings
+* Mi Band 2: Initial support for heart rate readings (Debug activity only)
+* Mi Band 2: Support disabled alarms
+* Attempt to fix spurious device discovery problems
+* Correctly recognize Toffeed, Slimsocial and MaterialFBook as Facebook notification sources
+
+#### Version 0.13.6
+* Mi Band 2: Support for multiple alarms (3 at the moment)
+* Mi Band 2: Fix for alarms not working when just one is enabled
+
+#### Version 0.13.5
+* Mi Band 2: Support setting one alarm
+* Pebble: Health compatibility for Firmware 4.2
+* Improve support for K9 when generic notifications are used (K9 notifications set to never)
+
+#### Version 0.13.4
+* Mi Band: Initial support for recording heart and displaying rate values
+* Mi Band: Support for testing vibration patterns directly from the preferences
+* Mi Band: Clean up vibration preferences
+* Possibly fix logging to file on certain devices (#406)
+* Mi Band 2: Possibly fix weird connection interdependence between Mi 1 and 2 (#323)
+* Mi Band 1S: White list firmware 4.16.4.22
+* Mi Band: try application level pairing again, in order to support data sharing with Mi Fit (#250)
+* Pebble: new icons and colors for certain apps
+* Debug-screen: added button to test "new functionality", currently live sensor data for Mi Band 1
+
+#### Version 0.13.3
+* Fix regressions with missing bars and labels in charts
+* Allow to set notification type in Debug activity
+* Move "Disconnect" back to the bottom of the context menu
+* Mi Band 2: Display Message and Phone icons
+
+#### Version 0.13.2
+* Support deleting devices (and their data) in control center
+* Sort devices lexicographically in control center
+* Do not forward group summary notifications (could fix some duplicate notifications)
+* Pebble: Support for health on FW 4.1
+* Mi Band: Fix offline charts not displaying heartrate for Mi 1S
+
+#### Version 0.13.1
+* Improved BLE scanning for Android 5.0+
+* Pebble: try to work around duplicate Telegram messages and support Telegram icon
+* Pebble: fix some incompatibilities with certain PebbleKit Android apps
+
+#### Version 0.13.0
+* Initial working Mi Band 2 support (only notifications, no activity and heart rate support)
+* Experimental support for Vibratissimo devices
+
+#### Version 0.12.2
+* Fix for user attribute database table getting spammed and store sleep and steps goals properly
+
+#### Version 0.12.1 (release withdrawn)
+* Pebble: Fix activity data being associated with the wrong device and/or user in some cases causing them to invisible in charts
+* Remove special handling for Conversations notifications since upstream dropped special pebble support
+
+#### Version 0.12.0 (release withdrawn)
+* NB: User action needed to migrate existing data!
+* Store activity data per device and provider to allow multiple devices of the same kind with separate data. Migration is available, except for Pebble Misfit data. Existing data from multiple devices of the same kind (eg. multiple Mi Bands) will get merged while importing.
+* In Control Center, display known devices even when Bluetooth is off
+* In Control center, new menu point to launch the new "Database management" activity
+* Pebble: Support for Pebble Health on Firmware 4.0
+* Pebble: Optionally allow raw Pebble Health data to be stored in database completely (for later interpretation, when we are able to decode it)
+* Mi Band: fix displaying of deep sleep vs. light sleep (was inverted)
+
+#### Version 0.11.2
+* Mi Band: support for devices that cannot pair with the band (#349)
+
+#### Version 0.11.1
+* Various fixes (including crashes) for location settings
+* Pebble: Support Pebble Time 2 emulator (needs recompilation of Gadgetbridge)
+* Fix a rare crash when, due to Bluetooth problems, when a device has no name
+* Fix activity fetching getting stuck when double tapping (#333)
+* Mi Band: in the Device Discovery activity, do not display devices that are already paired
+* Mi Band: only allow automatic re-connection on disconnect when the device was previously fully connected
+* Mi Band: fix a rare crash when reading data fails due to Bluetooth problems
+* Mi Band: log full activity sample to help deciphering activity kinds (#341)
+* Mi Band 2: improved discovery mechanism to not rely on MAC addresses (#323)
+* Charts: only display heart rate samples on devices that support that
+* Add more logging to detect problems with external directories (#343)
+
+#### Version 0.11.0
+* Pebble: new App Manager (keeps track of installed apps and allows app sorting on FW 3.x)
+* Pebble: call dismissal with canned SMS (FW 3.x)
+* Pebble: watchapp configuration presets
+* Pebble: fix regression with FW 2.x (almost everything was broken in 0.10.2)
+
+#### Version 0.10.2
+* Pebble: allow to manually paste configuration data for legacy configuration pages
+* Pebble: various improvements to the configuration page
+* Pebble: Support FW 4.0-dp1 and Pebble2 emulator (needs recompilation of Gadgetbridge)
+* Pebble: Fix a problem with key events when using the Pebble music player
+
+#### Version 0.10.1
+* Pebble: set extended music info by dissecting notifications on Android 5.0+
+* Pebble: various other improvements to music playback
+* Pebble: allow ignoring activity trackers individually (to keep the data on the pebble)
+* Mi Band: support for shifting the device time by N hours (for people who sleep at daytime)
+* Mi Band: initial and untested support for Mi Band 2
+* Allow setting the application language
+
+#### Version 0.10.0
+* Pebble: option to send sunrise and sunset events to timeline
+* Pebble: fix problems with unknown app keys while configuring watchfaces
+* Mi Band: BLE connection fixes
+* Fixes for enabling logging at without restarting Gadgetbridge
+* Re-enable device paring activity on Android 6 (BLE scanning needs the location preference)
+* Display device address in device info
+
+#### Version 0.9.8
+* Pebble: fix more reconnect issues
+* Pebble: fix deep sleep not being detected with Firmware 3.12 when using Pebble Health
+* Pebble: option in AppManager to delete files from cache
+* Pebble: enable pbw cache and watchface configuration for Firmware 2.x
+* Pebble: allow enabling of Pebble Health without "untested features" being enabled
+* Pebble: fix music information being messed up
+* Honour "Do Not Disturb" for phone calls and SMS
+
+#### Version 0.9.7
+* Pebble: hopefully fix some reconnect issues
+* Mi Band: fix live activity monitoring running forever if back button pressed
+* Mi Band: allow low latency firmware updates, fixes update with some phones
+* Mi Band: initial experimental and probably broken support for Amazfit
+* Show aliases for BT Devices if they had been renamed in BT Settings
+* Do not show a hint about App Manager when a Mi Band is connected
+
+#### Version 0.9.6
+* Again some UI/theme improvements
+* New preference to reconnect after connection loss (defaults to true)
+* Fix crash when dealing with certain old preference values
+* Mi Band: automatically reconnect when back in range after connection loss
+* Mi Band 1S: display heart rate value again when invoked via the Debug view
+
+#### Version 0.9.5
+* Several UI Improvements
+* Easier First-time setup by using a FAB
+* Optional Dark Theme
+* Notification App Blacklist is now sorted
+* Gadgetbridge Icon in the notification bar displays connection state
+* Logging is now configurable without restart
+* Mi Band 1S: Initial live heartrate tracking
+* Fix certain crash in charts activity on slower devices (#277)
+
+#### Version 0.9.4
+* Pebble: support pebble health datalog messages of firmware 3.11 (this adds support for deep sleep!)
+* Pebble: try to reconnect on new notifications and phone calls when connection was lost unexpectedly
+* Pebble: delay between re-connection attempts (from 1 up to 64 seconds)
+* Fix crash in charts activities when changing the date, quickly (#277)
+* Mi Band: preference to enable heart rate measurement during sleep (#232, thanks computerlyrik!)
+* Mi Band: display measured heart rate in charts (#232)
+* Mi Band 1S: full support for firmware upgrade/downgrade (both for Mi Band and heart rate sensor) (#234)
+* Mi Band 1S: fix device detection for certain versions
+
+#### Version 0.9.3
+* Pebble: Fix Pebble Health activation (was not available in the App Manager)
+* Simplify connection state display (only connecting->connected)
+* Small improvements to the pairing activity
+* Mi Band 1S: Fix for mi band firmware update
+
+#### Version 0.9.2
+* Mi Band: Fix update of second (HR) firmware on Mi1S (#234)
+* Fix ordering issue of device info being displayed
+
+#### Version 0.9.1
+* Mi Band: fix sporadic connection problems (stuck on "Initializing" #249)
+* Mi Band: enable low latency connection (faster) during initialization and activity sync
+* Mi Band: better feedback for firmware update
+* Device Item is now clickable also when the information entries are visible
+* Fix enabling log file writing #261
+
+#### Version 0.9.0
+* Pebble: Support for configuring watchfaces/apps locally (clay) or though web browser (some do not work)
+* Pebble: hide the alarm management activity as it's unsupported
+* Mi Band: Improve firmware detection and updates, including 1S support
+* Mi Band: Display HR FW for 1S
+* FW and HW versions are only displayed after tapping on the "info" button in Control Center
+* Do not display activity samples when navigating too far in the past
+* Fix auto connect which was broken under some circumstances
+
+#### Version 0.8.2
+* Fix database creation and updates (thanks @feclare)
+* Add experimental widget to set the alarm time to a configurable number of hours in the future (thanks @0nse)
+* Use ckChangeLog to display the Changelog within Gadgetbridge
+* Workaround to fix logfile rotation (bug in logback-android)
+
+#### Version 0.8.1
+* Pebble: install (and start) freshly-installed apps on the watch instead of showing a Toast that tells the user to do so. (only applies to firmware 3.x)
+* Pebble: fix crash while receiving Health data
+* Mi Band 1S: support for synchronizing activity data (#205)
+* Mi Band 1S: support for reading the heart rate via the "Debug Screen" #178
+
+#### Version 0.8.0
+* Pebble: Support Pebble Health: steps/activity data are stored correctly. Sleep time is considered as light sleep. Deep sleep is discarded. The pebble will send data where it seems appropriate, there is no action to perform on the watch for this to happen.
+* Pebble: Fix support for newer version of morpheuz (>=3.3?)
+* Pebble: Allow to select the preferred activity tracker via settings activity (Health, Misfit, Morpheuz)
+* Pebble: Fix wrong(previous) contact being displayed on the pebble
+* Mi Band: improvements to pairing and connecting
+* Fix a problem related to shared preferences storage of activity settings
+* Very basic support Android 6 runtime permission
+* Fix layout of the alarms activity
+
+#### Version 0.7.4
+* Refactored the settings activity: User details are now generic instead of Mi Band specific. Old settings are preserved.
+* Pebble: Fix regression with broken active reconnect since 0.7.0
+* Pebble: Support activation and deactivation of Pebble Health. Activation uses the User details as seen above. Insights are NOT activated.
+ Please be aware that deactivation does NOT delete the data stored on the watch (but it seems to stop the tracking), and we do not know how to switch to metric length units.
+
+#### Version 0.7.3
+* Pebble: Report connection state to PebbleKit companion apps via content provider. NOTE: Makes Gadgetbridge mutual exclusive with the original Pebble app.
+* Ignore generic notification when from SMSSecure when SMS Notifications are on
+
+#### Version 0.7.2
+* Pebble: Allow replying to generic notifications that contain a wearable reply action (tested with Signal)
+* Pebble: Support setting up a common suffix for canned replies (defaults to " (canned reply)")
+* Mi Band: Avoid NPEs when aborting an erroneous sync #205
+* Mi Band: Fix discovery of Mi Band 1S
+* Add a confirmation dialog when performing a db import
+* Sort blacklist by package names
+
+#### Version 0.7.1
+* Pebble: allow re-installation of apps in pbw-cache from App Manager (long press menu)
+* Pebble: Fix regression which freezes Gadgetbridge when disconnecting via long-press menu
+
+#### Version 0.7.0
+* Read upcoming events (up to 7 days in the future). Requires READ_CALENDAR permission
+* Fix double SMS on Sony Android and Android 6.0
+* Pebble: Support replying to SMS form the watch (canned replies)
+* Pebble: Allow installing apps compiled with SDK 2.x also on the basalt platform (Time, Time Steel)
+* Pebble: Fix decoding strings in appmessages from the pebble (fixes sending SMS from "Dialer for Pebble")
+* Pebble: Support incoming re-connections when device returns from "Airplane Mode" or "Stand-By Mode"
+* Pebble: Fix crash when turning off Bluetooth when connected on Android 6.0
+* Mi Band: reserve some alarm slots for alerting when upcoming events begin. NB: the band will vibrate at the start time of the event, android reminders are ignored
+* Mi Band: Display unique devices Names, not just "MI"
+* Some new and updated icons
+
+#### Version 0.6.9
+* Pebble: Store app details in pbw-cache and display them in app manager on firmware 3.x
+* Pebble: Increase maximum notification body length from 255 to 512 bytes on firmware 3.x
+* Pebble: Support installing .pbl (language files) on firmware 3.x
+* Pebble: Correct setting the timezone on firmware 3.x (pebble expects the "ID" eg. Europe/Berlin)
+* Pebble: Show correct icon for activity tracker and watchfaces in app installer (language and fw icons still missing)
+* Pebble: Fix crash when trying to install files though a file manager which are located inside the pbw-cache on firmware 3.x
+* Support for deleting all activity data (in the 'Debug' screen)
+* Don't pop up the virtual keyboard when entering the Debug screen
+* Remove all pending notifications on quit
+* Mi Band: KitKat: hopefully fixed showing the progress bar during activity data synchronization (#155)
+* Mi Band 1S: hopefully fixed connection errors (#178) Notifications probably do not work yet, though
+
+#### Version 0.6.8
+* Mi Band: support for Firmware upgrade/downgrade on Mi Band 1A (white LEDs, no heartrate sensor)
+* Pebble: fix regression in 0.6.7 when installing pbw/pbz files from content providers (eg. download manager)
+* Pebble: fix installation of pbw files on firmware 3.x when using content providers (eg. download manager)
+* Pebble: fix crash on firmware 3.x when pebble requests a pbw that is not in Gadgetbridge's cache
++ Treat Signal notifications as chat notifications
+* Fix crash when contacts cannot be read on Android 6.0 (non-granted permissions)
+
+#### Version 0.6.7
+* Pebble: Allow installation of 3.x apps on OG Pebble (FW will be released soon)
+* Fix crashes on startup when logging is enabled or when entering the app manager on some phones
++ Fix Pebble being detected as MI when unpaired and auto-connect is enabled
+* Fix Crash when not having K9 Mail permissions (happens when installing K9 after Gadgetbridge) (#175)
+
+#### Version 0.6.6
+* Mi Band: Huge performance improvement fetching activity data
+* Mi Band: attempt at fixing connection problems (#156)
+* Pebble: Try to interpret sleep data from Misfit data
+* Fix exporting the activity database on devices with read-only external storage (#153)
+* Fix totally wrong sleep time in the sleep chart
+
+#### Version 0.6.5
+* Mi Band: Support "Locate Device" with Mi Band 1A (and Mi Band 1 with new firmware)
+* Pebble: Support syncing steps from Misfit (untested features must be turned on to see them), intensity=steps, no sleep support yet
+* Disable activity fetching when not supported
+* Small improvements to live activity charts
+
+#### Version 0.6.4
+* Support pull down to synchronize activity data (#138)
+* Display tabs in the Charts activity (#139)
+* Mi Band: initial support for Mi Band 1a (the one with white LEDs) (thanks @sarg) (#136)
+* Mi Band: Attempt at fixing problem with never finishing activity data fetching (#141, #142)
+* Register/unregister BroadcastReceivers instead of enabling/disabling them with PackageManager (#134)
+ (should fix disconnection because the service is being killed)
+
+#### Version 0.6.3
+* Pebble: support installation of language files (.pbl) on FW 2.x
+* Try to prevent service being killed by disallowing backups
+
+#### Version 0.6.2
+* Mi Band: support firmware version 1.0.10.14 (and onward?) vibration
+* Mi Band: get device name from official BT SIG endpoint
+* Mi Band: initial support for displaying live activity data, screen stays on
+
+#### Version 0.6.1
+* Pebble: Allow muting (blacklisting) Apps from within generic notifications on the watch
+* Pebble: Detect all known Pebble Versions including new "chalk" platform (Pebble Time Round)
+* Option to ignore phone calls (useful for Pebble Dialer)
+* Mi Band: Added progress bar for activity data transfer and fixes for firmware transfer progress bar
+* Bug fix for app blacklist (some checkboxes where wrongly drawn as checked)
+
+#### Version 0.6.0
+* Pebble: WIP implementation of PebbleKit Intents to make some 3rd party Android apps work with the Pebble (eg. Ventoo)
+* Pebble: Option to set re-connection attempts in settings (one attempt usually takes about 5 seconds)
+* Support controlling all audio players that react to media buttons (can be chosen in settings)
+* Treat SMS as generic notification if set to "never" (can be blacklisted there also if desired)
+* Treat Conversations messages as chat messages, even if arrived via Pebble Intents (nice icon for Pebble FW 3.x)
+* Allow opening firmware / app files from the download manager "app" (technically a content provider)
+* Mi Band: whitelisted a few firmware versions
+
+#### Version 0.5.4
+* Mi Band: allow the transfer of activity data without clearing MiBand's memory
+* Pebble: for generic notifications use generic icon instead of SMS icons on FW 3.x (thanks @roidelapluie)
+* Pebble: use different icons and background colors for specific groups of applications (chat, mail, etc) (thanks @roidelapluie)
+* In settings, support blacklisting apps for generic notifications
+
+#### Version 0.5.3
+* Pebble: For generic notifications, support dismissing individual notifications and "Open on Phone" feature (OG & PT)
+* Pebble: Allow to treat K9 notifications as generic notifications (if notification mode is set to never)
+* Ignore QKSMS notifications to avoid double notification for incoming SMS
+* Improved UI of Firmware/App installer
+* Device state again visible on lock screen
+* Date display and navigation now working properly for all charts
+
+#### Version 0.5.2
+* Pebble: support "dismiss all" action also on Pebble Time/FW 3.x notifications
+* Mi Band: show a notification when the battery is below 10%
+* Graphs are now using the same theme as the rest of the application
+* Graphs now show when the device was not worn by the user (for devices that send this information)
+* Remove unused settings option in charts view
+* Build target is now Android SDK 23 (Marshmallow)
+
+#### Version 0.5.1
+* Pebble: support taking screenshot from Pebble Time
+* Fix broken "find lost device" which was broken in 0.5.0
+
+#### Version 0.5.0
+* Mi Band: fix setting wear location
+* Pebble: experimental watchapp installation support for FW 3.x/Pebble Time
+* Pebble: support Pebble emulator via TCP connection (needs rebuild with INTERNET permission)
+* Pebble: use SMS/EMAIL icons for FW 3.x/Pebble Time
+* Pebble: do not throttle notifications
+* Support going forward/backwards in time in the activity charts
+* Various small bugfixes to the App/FW Installation Activity
+
+#### Version 0.4.6
+* Mi Band: Fixed negative number of steps displayed (#91)
+* Mi Band: fixed (re-) connection problems after band getting disconnected
+* Pebble: new option to enable untested code (enable only if you like bad surprises)
+* Pebble: always enable 2.x notifications with "dismiss all" action on FW 2.x (except for K9)
+* Fixed slight steps graph distortion through black text labels
+* Fixed control center activity and notification showing different device connection state
+* Small firmware installation improvements
+* Various refactoring and code cleanups
+
+#### Version 0.4.5
+* Enhancement to activity graphs: new graph showing the number of steps done today and in the last week
+* New preference to set the desired fitness goal (number of steps to walk in one day)
+* Mi Band: support for setting the fitness goal (the band will show the progress to the goal with the LEDs and vibrates when the goal is reached)
+* Mi Band: send the wear location (left / right hand) to the device
+* Mi Band: support for flashing firmware from .fw files (upgrades and downgrades are possible)
+* Fixed crash when synchronizing activity data in the graphs activity and changing device orientation
+
+#### Version 0.4.4
+* Set Gadgetbridge notification visibility to public, to show the connection status on the lock screen
+* Support for backup up and restoring of the activity database (via Debug activity)
+* Support for graceful upgrades and downgrades, keeping your activity database intact
+* Enhancement to activity graphs: new graphs for sleep data (only last night) accessible swiping right from the main graph
+* Enhancement to graphs activity: it is now possible to fetch the activity data directly from this activity
+* Pebble: experimental support for dismissing (all) notifications via actionable notifications (disabled by default)
+* Pebble: make FW 3.x notifications available by default
+* Mi Band: Set the graphs activity as the default action available with a single tap on the connected device
+
+#### Version 0.4.3
+* Mi Band: Support for setting alarms
+* Mi Band: Bug fix for activity data synchronization
+
+#### Version 0.4.2
+* Material style for Lollipop
+* Support for finding a lost device (vibrate until cancelled)
+* Mi Band: Support for vibration profiles, configurable for notifications
+* Pebble: Support taking screenshots from the device context menu (Pebble Time not supported yet)
+
+#### Version 0.4.1
+* New icons, thanks xphnx!
+* Improvements to Sleep Monitor charts
+* Pebble: use new Sleep Monitor for Morpheuz (previously Mi Band only)
+* Pebble: experimental support for FW 3.x notification protocol
+* Pebble: dev option to force latest notification protocol
+
+#### Version 0.4.0
+* Pebble: Initial Morpheuz protocol support for getting sleep data
+* Pebble: Support launching of watchapps though the AppManager Activity
+* Pebble: Support CM 12.1 default music app (Eleven)
+* Pebble: Fix firmware installation when all 8 app slots are in use
+* Pebble: Fix firmware installation when Pebble is in recovery mode
+* Pebble: Fix error when reinstalling apps, useful for upgrading/downgrading
+* Mi Band: Make vibration count configurable for different kinds of Notifications
+* Mi Band: Initial support for fetching activity data
+* Support rebooting Mi Band/Pebble through the Debug Activity
+* Add highly experimental sleep monitor (accessible via long press on a device)
+* Fix Debug activity (SMS and E-Mail buttons were broken)
+* Add Turkish translation contributed by Tarik Sekmen
+
+#### Version 0.3.5
+* Add discovery and pairing Activity for Pebble and Mi Band
+* Listen for Pebble Message Intents and forward notifications (used by Conversations)
+* Make strings translatable and add German, Italian, Russian, Spanish and Korean translations
+* Mi Band: Display battery status
+
+#### Version 0.3.4
+* Pebble: Huge speedup for app/firmware installation.
+* Pebble: Use a separate notification with progress bar for installation procedure
+* Pebble: Bug fix for being stuck while waiting for a slot, when none is available
+* Mi Band: Display connection status in notification (previously Pebble only)
+
+#### Version 0.3.3
+* Pebble: Try to reduce battery usage by acknowledging datalog packets
+* Mi Band: Set current time on the device (thanks to PR by @danielegobbetti)
+* More robust connection state handling and display
+
+#### Version 0.3.2
+* Mi Band: Fix for notifications only working after manual connection
+* Mi Band: Display firmware version
+* Pebble: Display hardware revision
+* Pebble: Check if firmware is compatible before allowing installation
+
+#### Version 0.3.1
+* Mi Band: Fix for notifications only working in Debug
+
+#### Version 0.3.0
+* Mi Band: Initial support (see README.md)
+* Pebble: Firmware installation (USE AT YOUR OWN RISK)
+* Pebble: Fix installation problems with certain .pbw files
+* Pebble: Volume control
+* Add icon for activity tracker apps (icon by xphnx)
+* Let the application quit when in reconnecting state
+
+#### Version 0.2.0
+* Experimental pbw installation support (watchfaces/apps)
+* New icons for device and app lists
+* Fix for device list not refreshing when Bluetooth gets turned on
+* Filter out annoying low battery notifications
+* Fix for crash on some devices when creating a debug notification
+* Lots of internal changes preparing multi device support
+
+#### Version 0.1.5
+* Fix for DST (summer time)
+* Option to sync time on connect (enabled by default)
+* Opening .pbw files with Gadgetbridge prints some package information
+ (This was not meant to be released yet, but the DST fix made a new release necessary)
+
+#### Version 0.1.4
+* New AppManager shows installed Apps/Watchfaces (removal possible via context menu)
+* Allow back navigation in ActionBar (Debug and AppManager Activities)
+* Make sure Intent broadcasts do not leave Gadgetbridge
+* Show hint in the Main Activity (tap to connect etc)
+
+#### Version 0.1.3
+* Remove the connect button, list all supported devices and connect on tap instead
+* Display connection status and firmware of connected devices in the device list
+* Remove quit button from the service notification, put a quit item in the context menu instead
+
+#### Version 0.1.2
+* Added option to start Gadgetbridge and connect automatically when Bluetooth is turned on
+* stop service if Bluetooth is turned off
+* try to reconnect if connection was lost
+
+#### Version 0.1.1
+* Fixed various bugs regarding K-9 Mail notifications.
+* "Generic notification support" in Setting now opens Androids "Notification access" dialog.
+
+#### Version 0.1.0
+* Initial release
diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst
new file mode 100644
index 0000000..62a05ff
--- /dev/null
+++ b/CONTRIBUTORS.rst
@@ -0,0 +1,553 @@
+.. 2>/dev/null
+ names ()
+ {
+ echo -e "\n exit;\n**Contributors (sorted by number of commits):**\n";
+ git log --format='%aN:%aE' origin/master | grep -Ev "(anonymous:|FYG_.*_bot_ignore_me|reformat-bot@freeyourgadget.org)" | sed 's/@users.github.com/@users.noreply.github.com/g' | awk 'BEGIN{FS=":"}{ct[$1]+=1;e[$1]=$2}END{for (i in e) { n[i]=e[i];c[i]+=ct[i] }; for (a in e) print c[a]"\t* "a" <"n[a]">";}' | sort -n -r | cut -f 2-
+ }
+ quine ()
+ {
+ {
+ echo ".. 2>/dev/null";
+ declare -f names | sed -e 's/^[[:space:]]*/ /';
+ declare -f quine | sed -e 's/^[[:space:]]*/ /';
+ echo -e " quine\n";
+ names;
+ echo -e "\nAnd all the former Transifex translators, who cannot be listed automatically.\n\n*To update the contributors list just run this file with bash. Prefix a name with % in .mailmap to set a contact as preferred*"
+ } > CONTRIBUTORS.rst;
+ exit
+ }
+ quine
+
+
+ exit;
+**Contributors (sorted by number of commits):**
+
+* Andreas Shimokawa <shimokawa@fsfe.org>
+* Carsten Pfeiffer <cpfeiffer@users.noreply.github.com>
+* Jos茅 Rebelo <joserebelo@outlook.com>
+* Daniele Gobbetti <daniele+github@gobbetti.name>
+* Petr Van臎k <petr@linuks.cz>
+* Yaron Shahrabani <sh.yaron@gmail.com>
+* Daniel Dakhno <dakhnod@gmail.com>
+* 闄堝皯涓� <oshirisu.red@gmail.com>
+* Arjan Schrijver <arjan5@noreply.codeberg.org>
+* Vinc猫n PUJOL <vincen@vincen.org>
+* O臒uz Ersen <oguzersen@protonmail.com>
+* Allan Nordh酶y <epost@anotheragency.no>
+* Ihor Hordiichuk <igor_ck@outlook.com>
+* nautilusx <mail.ka@mailbox.org>
+* Taavi Eom盲e <taavi.eomae+github@gmail.com>
+* Gordon Williams <gw@pur3.co.uk>
+* Rafael Fontenelle <rafaelff@gnome.org>
+* Michal L <michalrmsmi@wp.pl>
+* Sebastian Kranz <lightwars@web.de>
+* Joa虄o Paulo Barraca <jpbarraca@gmail.com>
+* Linerly <linerly@protonmail.com>
+* Rex_sa <rex.sa@pm.me>
+* mamucho <mamut_killer@yahoo.com>
+* postsorino <postsorino@krutt.org>
+* Manuel-Senpai <senpai99@hotmail.com>
+* Andreas B枚hler <dev@aboehler.at>
+* FransM <fransmeulenbroeks@yahoo.com>
+* Jonas <jonasdcdm@posteo.net>
+* HenRy <helge1o1o1@gmail.com>
+* Yukai Li <yukaili.geek@gmail.com>
+* Roi Greenberg <roigreenberg@gmail.com>
+* gallegonovato <fran-carro@hotmail.es>
+* Nikita Epifanov <nikgreens@protonmail.com>
+* kirill blaze <kirillblaze2@gmail.com>
+* 脫scar Fern谩ndez D铆az <oscfdezdz@tuta.io>
+* Jeannette L <j.lavoie@net-c.ca>
+* Vadim Kaushan <admin@disasm.info>
+* protomors <protomors@gmail.com>
+* Cre3per <lukas96s@web.de>
+* Davis Mosenkovs <davikovs@gmail.com>
+* ssantos <ssantos@web.de>
+* Michael <quelbs@gmail.com>
+* glemco <glemco@codeberg>
+* 115ek <e.blosz@hotmail.de>
+* 0que <0que@users.noreply.hosted.weblate.org>
+* 小邪褕邪 袩械褌褉芯胁懈褯 <salepetronije@gmail.com>
+* naofum <naofum@gmail.com>
+* My Random Thoughts <weblate@myrandomthoughts.co.uk>
+* Damien 'Psolyca' Gaignon <damien.gaignon@gmail.com>
+* 0eoc <0eoc@users.noreply.hosted.weblate.org>
+* mesnevi <shams@airpost.net>
+* Kintu <kintukp@gmail.com>
+* youzhiran <2668760098@qq.com>
+* mueller-ma <mueller-ma@users.noreply.github.com>
+* ivanovlev <ivanovlev@mail.ru>
+* Tijl Schepens <tijl.schepens@hotmail.com>
+* Sophanimus <sennamails@googlemail.com>
+* Pavel Elagin <elagin.pasha@gmail.com>
+* NekoBox <nekobox@noreply.codeberg.org>
+* MPeter <>
+* MrYoranimo <yvulker@gmail.com>
+* mondstern <hello@mondstern.tk>
+* Hadri谩n Candela <hadrian.candela@gmail.com>
+* 脕cs Zolt谩n <acszoltan111@gmail.com>
+* Zhong Jianxin <azuwis@gmail.com>
+* Milo Ivir <mail@milotype.de>
+* Gabriele Monaco <monaco@eit.uni-kl.de>
+* foxstidious <foxstidious@gmail.com>
+* Andy Yang <a962702@yahoo.com>
+* Abdullah Manaz <manaz@noreply.codeberg.org>
+* Richard de Boer <git@tubul.net>
+* mkusnierz <>
+* Julien Pivotto <roidelapluie@inuits.eu>
+* tomechio <tomasz@salamon.fi>
+* Steffen Liebergeld <perl@gmx.org>
+* Skrripy <rozihrash.ya6w7@simplelogin.com>
+* Petr Kadlec <mormegil@centrum.cz>
+* Pavel <pavel.gorbanj@gmail.com>
+* Lem Dulfo <lemuel.dulfo@gmail.com>
+* Dmitriy Bogdanov <di72nn@gmail.com>
+* Olexandr Nesterenko <olexn@ukr.net>
+* Nevena Mircheva <nevena.mircheva@gmail.com>
+* musover <meoberto@mthree.es>
+* Matthieu Baerts <matttbe@gmail.com>
+* Felix Konstantin Maurer <maufl@maufl.de>
+* Axus Wizix <aw.ts@bk.ru>
+* Xtremo3 <a.lewicki95@gmail.com>
+* Utsob Roy <uroybd@gmail.com>
+* taras3333 <taras3333@gmail.com>
+* Sergey Trofimov <sarg@sarg.org.ru>
+* Sebastian Krey <sebastian@skrey.net>
+* Noodlez <contact@nathanielbarragan.xyz>
+* M. Hadi <hhhadddi@yahoo.com>
+* Martin Boonk <martin@boonk.info>
+* Lukas <lukas.edi@gmx.net>
+* Ganblejs <ganblejs@noreply.codeberg.org>
+* Deixondit <jperals@protonmail.com>
+* akasaka / Genjitsu Labs <vladkorotnev@gmail.com>
+* Szylu <chipolade@gmail.com>
+* Robert Barat <rbarat07@gmail.com>
+* Reza Almanda <rezaalmanda27@gmail.com>
+* Mario <mariomobla@gmail.com>
+* ksiwczynski <k.siwczynski@gmail.com>
+* JohnnySun <bmy001@gmail.com>
+* Gilles 脡milien MOREL <contact@gilles-morel.fr>
+* firekonstantin <firekonstantin@mail.ru>
+* bruh <quangtrung02hn16@gmail.com>
+* Uwe Hermann <uwe@hermann-uwe.de>
+* Patric Gruber <me@patric-gruber.at>
+* opavlov <forpoststuff@gmail.com>
+* Michalis <michalisntovas@yahoo.gr>
+* Mario Rossi <kk1o2n+61euckrwqwqecz3pme3@sharklasers.com>
+* ifurther <i.further.5.4@gmail.com>
+* Edoardo Rosa <edoardo.rosa90@gmail.com>
+* d <dmanye@gmail.com>
+* Bo偶ydar <trening302@o2.pl>
+* Alberto <albertsal83@gmail.com>
+* AiLab <vpuhoff92@gmail.com>
+* zsolt3991 <zsolt_93@yahoo.com>
+* winver <kirillstuzhuk@gmail.com>
+* Vladislav Serkov <vladserkoff@protonmail.com>
+* Vebryn <vebryn@gmail.com>
+* uli <cybuzuma@vnxs.de>
+* Ted Stein <me@tedstein.net>
+* sinore <sinoren263@niekie.com>
+* Shimon <simonfarm0@gmail.com>
+* Reiner Herrmann <reiner@reiner-h.de>
+* NicoBuntu <nicolas__du95@hotmail.fr>
+* Nee Sorry <sven.fasterding@posteo.de>
+* Marc Nause <marc.nause@audioattack.de>
+* Louis-Marie Croisez <louis.croisez@gmail.com>
+* Kry拧tof 膶ern媒 <cleverline1mc@gmail.com>
+* Johannes Krude <johannes@krude.de>
+* Jean-Fran莽ois Greffier <jf.greffier@gmail.com>
+* Hasan Ammar <ammarh@gmail.com>
+* Giuseppe Caliendo <giuseppe.caliendo@gmail.com>
+* Gergely Peidl <gergely@peidl.net>
+* Fabio Parri <parrif_ibb@yahoo.com>
+* Evo <weblate@verahawk.com>
+* Emre <wenigerpluesch@mailbox.org>
+* Elwood <elwood21@gmail.com>
+* Dmitry Markin <dmitry@markin.tech>
+* CE4 <chregger@gmail.com>
+* ce4 <ce4@posteo.de>
+* Baka Gaijin <lewdwarrior@waifu.club>
+* AndrewBedscastle <1462953+AndrewBedscastle@users.noreply.github.com>
+* abettenburg <a.bettenburg@gmail.com>
+* 0nse <0nse@users.noreply.github.com>
+* 袦邪泻褋懈屑 携泻懈屑褔褍泻 <xpinovo@gmail.com>
+* Ye Wint Htut Kyaw <oxygen.2521998@gmail.com>
+* xaos <xaos@noreply.codeberg.org>
+* Thomas <tutonis@gmail.com>
+* TheScientistPT <joao.ed.reis.gomes@gmail.com>
+* SnowCat <kittythesnowcat@protonmail.com>
+* Sergio Varela <sergitroll9@gmail.com>
+* Sebastian Obrusiewicz <sobrus2@o2.pl>
+* Sebastian Espinosa <hipypuff@gmail.com>
+* Robbert Gurdeep Singh <git@beardhatcode.be>
+* Rimas Raguli奴nas <rarimas@gmail.com>
+* mvn23 <schopdiedwaas@gmail.com>
+* Minori Hiraoka (氙鸽吀毽�) <minori@mnetwork.co.kr>
+* MASVA <masva10@gmail.com>
+* masakoodaa <masakoodaa@protonmail.com>
+* Marius Cornescu <marius_cornescu@yahoo.com>
+* mantas-p <megaliuz@gmail.com>
+* Lukas Veneziano <fs@venezilu.de>
+* LL <lu.lecocq@free.fr>
+* LizardWithHat <martin.betcher@gmail.com>
+* Lesur Frederic <contact@memiks.fr>
+* leela <53352@protonmail.com>
+* kukuruzka <anton.dan1657@gmail.com>
+* Kompact <joaorafael123@hotmail.com>
+* Kalle <hiwokey596@vip4e.com>
+* K0L0B0G <github@gorobav.ru>
+* Johann C. Rode <jcrode@ece.ucsb.edu>
+* jimman2003 <jim41825@gmail.com>
+* jfgreffier <jfgreffier@users.noreply.github.com>
+* Jasper <jespiex456@hotmail.com>
+* ITCactus <itcactus@noreply.codeberg.org>
+* illis <illis@beyond8labs.com>
+* Francesco Marinucci <francesco.marinucci@posteo.net>
+* FintasticMan <finlay.neon.kid@gmail.com>
+* Doma Gerg艖 <domag02@gmail.com>
+* Dikay900 <dark900@xyz.de>
+* Denis <korden@sky-play.ru>
+* Christian Fischer <sw-dev@computerlyrik.de>
+* Benjamin Swartley <reep236@gmail.com>
+* Asbesbopispa <c.alfano+github@outlook.it>
+* Adolfo Jayme Barrientos <fitojb@ubuntu.com>
+* 6arms1leg <m.brnsfld@googlemail.com>
+* Your Name <you@example.com>
+* XqweX <xqwex@seznam.cz>
+* walkjivefly <mark@walkjivefly.com>
+* WaldiS <admin@sto.ugu.pl>
+* Vytenis <slivinskasvytenis@gmail.com>
+* Vladislav Glinsky <cl0ne@mithril.org.ua>
+* vishnu <vishnu012@protonmail.com>
+* Vianney le Cl茅ment de Saint-Marcq <vianney@noreply.codeberg.org>
+* Toby Murray <toby.murray+gitkraken@protonmail.com>
+* thyttan <6uuxstm66@mozmail.com>
+* Thorsten <js1_531b3pm29ng2@byom.de>
+* Stephan Lachnit <stephanlachnit@protonmail.com>
+* Sebastian Reichel <sre@ring0.de>
+* Saul Nunez <saul.nunez99@gmail.com>
+* Rui Mendes <xz9@protonmail.com>
+* roolx <rbzikadze@gmail.com>
+* rarder44 <rarder44@noreply.codeberg.org>
+* rany <ranygh@riseup.net>
+* Ranved Sticon <the7bulk@gmail.com>
+* Rajesh Kumbhakar <sssraj.sssraj@gmail.com>
+* Ptilopsis Leucotis <PtilopsisLeucotis@yandex.com>
+* petronovak <petro.novak@gmail.com>
+* Pascal <pascal.tannich@gmail.com>
+* odavo32nof <odavo32nof@noreply.codeberg.org>
+* octospacc <octo@tutamail.com>
+* NotAFIle <nota@notafile.com>
+* Normano64 <per.bergqwist@gmail.com>
+* Nikolay Korotkiy <sikmir@gmail.com>
+* Nick Spacek <peter.spacek@siemens.com>
+* Nekromanser <ari.taitto@protonmail.com>
+* Nathan <bonnemainsnathan@gmail.com>
+* narektor <narektor@noreply.codeberg.org>
+* MyTimeKill <26295589+MyTimeKill@users.noreply.github.com>
+* Moln谩r Barnab谩s <nsd4rkn3ss@gmail.com>
+* Moarc <aldwulf@gmail.com>
+* Michal Novotny <mignov@gmail.com>
+* maxvel <maxvel@noreply.codeberg.org>
+* Maxime Reyrolle <dev@reyrolle.fr>
+* Mattias M眉nster <mattiasmun@gmail.com>
+* Mattherix <mattherix@protonmail.com>
+* Martin <ritualz@users.noreply.github.com>
+* marco.altomonte <marco.altomonte@gmail.com>
+* Le Poisson Libre <services@spika.xyz>
+* Krzysztof Marcinek <krzymar2002@gmail.com>
+* krzys_h <krzys_h@interia.pl>
+* Konrad Iturbe <KonradIT@users.noreply.github.com>
+* Kamalei Zestri <38802353+KamaleiZestri@users.noreply.github.com>
+* Joel Beckmeyer <joel@beckmeyer.us>
+* Jes煤s <zaagur@gmail.com>
+* Jes煤s F <jfmiguel@wanadoo.es>
+* Irul <wedesignthing@gmail.com>
+* Igor Polyakov <igorpolyakov@protonmail.com>
+* homocomputeris <homocomputeris+git@gmail.com>
+* Grzegorz <grzesjam@duck.com>
+* GeekosaurusR3x <skad@protonmail.com>
+* Francesco Franchina <cescus92@gmail.com>
+* fparri <fparri@noreply.codeberg.org>
+* Fabien Brachere <fabien@brachere.net>
+* exit-failure <hakrala@web.de>
+* Ertu (Er2, Err) <er2official@outlook.com>
+* Er2 <er2@dismail.de>
+* Dreamwalker <aristojeff@gmail.com>
+* DAWID <aggregate_diffused400@simplelogin.com>
+* Dario Lopez-K盲sten <cl2dlope@gmail.com>
+* Da Pa <da.pa@noreply.codeberg.org>
+* DanialHanif <danialhanif@outlook.com>
+* Cristian Alfano <c.alfano@outlook.it>
+* criogenic <criogenic@gmail.com>
+* chabotsi <chabotsi+github@chabotsi.fr>
+* bowornsin <bowornsin@gmail.com>
+* Avamander <Avamander@users.noreply.github.com>
+* Artem <KovalevArtem.ru@gmail.com>
+* AnthonyDiGirolamo <anthony.digirolamo@gmail.com>
+* Anonymous <noreply@weblate.org>
+* Andreas Kromke <Andreas.Kromke@web.de>
+* Alex <leha-bot@yandex.ru>
+* Albert <albert@avdm043>
+* Ain膩rs <ainars71@inbox.lv>
+* 毓亘丿丕賱乇卅賵賮 毓丕亘丿蹖 <abdolraoofabedi@gmail.com>
+* 袝谐芯褉 袝褉屑邪泻芯胁 <eg.ermakov2016@yandex.ru>
+* 獠嗏瞾獠涒矒 桅i <daniphii@outlook.com>
+* Yusuf Cihan <yusufcihandemirbas@gmail.com>
+* yk <bivol21883@cyadp.com>
+* Yar <yaroslav.isakov@gmail.com>
+* xzovy <caleb@caleb-cooper.net>
+* xphnx <xphnx@users.noreply.github.com>
+* Xos茅 M <xosem@disroot.org>
+* Xeoy <arventh@pm.me>
+* Xavier RENE-CORAIL <xavier.renecorail@gmail.com>
+* x29a <x29a@noreply.codeberg.org>
+* w2q <w2q@noreply.codeberg.org>
+* Vitaliy Shuruta <vshuruta@gmail.com>
+* veecue <veecue@ventos.tk>
+* Unixware <csynt@hotmail.com>
+* TylerWilliamson <tyler.williamson51@gmail.com>
+* Triet Pham <triet.phm@gmail.com>
+* Traladarer <Traladarer@users.noreply.hosted.weblate.org>
+* Tomer Rosenfeld <tomerosenfeld007@gmail.com>
+* Tomas Radej <tradej@redhat.com>
+* t-m-w <t-m-w-codeberg@galac.tk>
+* tiparega <11555126+tiparega@users.noreply.github.com>
+* TinfoilSubmarine <tinfoilsubmarine@noreply.codeberg.org>
+* Tim <tim.w1995@gmail.com>
+* thirschbuechler <thirschbuechler@github.com>
+* Thiago Rodrigues <thiaguinho.the@gmail.com>
+* thermatk <thermatk@noreply.codeberg.org>
+* theghostofheathledger <theghostofheathledger@noreply.codeberg.org>
+* Temuri Doghonadze <temuri.doghonadze@gmail.com>
+* Tarik Sekmen <tarik@ilixi.org>
+* Szymon Tomasz Stefanek <s.stefanek@gmail.com>
+* szilardx <15869670+szilardx@users.noreply.github.com>
+* Swann Martinet <swann.ranskassa@laposte.net>
+* Stefan Bora <stefan.bora@outlook.de>
+* Stan Gomin <stan@gomin.me>
+* ssilverr <ssilverr@noreply.codeberg.org>
+* Sky233ml <sky233@sky233.ml>
+* SinMan <emilio.galvan@gmail.com>
+* Simon Sievert <ssievert@protonmail.com>
+* Sergio Lopez <slp@sinrega.org>
+* Sergey Vasilyev <koniponi@gmail.com>
+* sedy89 <sedy89@noreply.codeberg.org>
+* Sebastian Nilsson <sebbe_kompis@hotmail.com>
+* S Dantas <dantasosteney@gmail.com>
+* Santiago Benalc谩zar <santiagosdbc@gmail.com>
+* Samuel Carvalho de Ara煤jo <samuelnegro12345@gmail.com>
+* Sami Alaoui <4ndroidgeek@gmail.com>
+* Saman rsh <sasan.r75@gmail.com>
+* Salif Mehmed <mail@salif.eu>
+* SalavatR <salavat.rakhmanov@gmail.com>
+* Roxystar <roxystar@arcor.de>
+* Roman Plevka <rplevka@redhat.com>
+* rom4nik <codeberg@rom4nik.pl>
+* Robin Davidsson <robindavidsson@outlook.com>
+* Roberto P. Rubio <outtakes_handgun090@familiapm.com>
+* rober <rober@prtl.nodomain.net>
+* Rivo Z盲ngov <rivozangov@gmail.com>
+* rimasx <riks_12@hot.ee>
+* rikka356 <rikka356@outlook.com>
+* Richard Finegold <goldfndr+git@gmail.com>
+* Retew <salau@tutanota.com>
+* redking <redking974@gmail.com>
+* Ray <RayL-er@outlook.com>
+* RandomItalianGuy <donatoperruso@protonmail.com>
+* Raghd Hamzeh <raghd@rhamzeh.com>
+* Quang Ng么 <ngoquang2708@gmail.com>
+* Quallenauge <Hamsi2k@freenet.de>
+* Q-er <9142398+Q-er@users.noreply.github.com>
+* pommes <pommes@noreply.codeberg.org>
+* pishite <pishite@meta.ua>
+* Perflyst <mail@perflyst.de>
+* Pavel Motyrev <legioner.r@gmail.com>
+* Pauli Salmenrinne <susundberg@gmail.com>
+* pangwalla <pangwalla@noreply.codeberg.org>
+* Pander <pander@users.sourceforge.net>
+* ozkanpakdil <ozkan.pakdil@gmail.com>
+* opcode <luigi@sciolla.net>
+* Ond艡ej Sedl谩膷ek <ond.sedlacek@gmail.com>
+* Olivier Bloch <blochjunior@gmail.com>
+* Oleg Vasilev <oleg.vasilev@virtuozzo.com>
+* Oleg <oleg.invisibleman@protonmail.com>
+* Nur Aiman Fadel <nuraiman@gmail.com>
+* Nikolai Sinyov <nikolay.sinyov@yandex.ru>
+* Nicol貌 Balzarotti <anothersms@gmail.com>
+* Nephiel <Nephiel@users.noreply.github.com>
+* Nathan Philipp Bo Seddig <natpbs@gmail.com>
+* Natanael Arndt <arndtn@gmail.com>
+* Nabil BENDAFI <nabil@bendafi.fr>
+* myxor <myxor@noreply.codeberg.org>
+* Morten Rieger Hannemose <mohan@dtu.dk>
+* Mirko Covizzi <mrkcvzz@gmail.com>
+* Milan 艩alka <salka.milan@googlemail.com>
+* Mike van Rossum <mike@vanrossum.net>
+* mika laka <Mikhaila.Eaddy@easymailer.live>
+* Michal Novak <michal.novak@post.cz>
+* Michael Wiesinger <michw2014@gmail.com>
+* michaelneu <git@michaeln.eu>
+* MedusasSphinx <medusassphinx@noreply.codeberg.org>
+* McSym28 <McSym28@users.noreply.github.com>
+* MaxL <z60loa8qw3umzu3@my10minutemail.com>
+* maxirnilian <maxirnilian@users.noreply.github.com>
+* Maxim Baz <git@maximbaz.com>
+* Mave95 <mave95@noreply.codeberg.org>
+* Matej Drobni膷 <matejdro@gmail.com>
+* Marvin D <mave95@posteo.de>
+* Martin Piatka <chachacha2323@gmail.com>
+* Martin.JM <>
+* Margreet <margreetkeelan@gmail.com>
+* Marc Schlaich <marc.schlaich@googlemail.com>
+* Marco Alberto Diosdado Nava <betoxxdiosnava@gmail.com>
+* Marco A <35718078+TomasCartman@users.noreply.github.com>
+* Marc Laporte <marc@laporte.name>
+* Marcin <ml.cichy@gmail.com>
+* Marcel pl (m4rcel) <marcel.garbarczyk@gmail.com>
+* Manuel Soler <vg8020@gmail.com>
+* Manuel Ru脽 <manuel_russ@dismail.de>
+* mangel <mangelcursos@gmail.com>
+* magimel.francois <magimel.francois@gmail.com>
+* Maciej Ku艣nierz <>
+* m4sk1n <me@m4sk.in>
+* LukasEdl <lukasedl@noreply.codeberg.org>
+* LuK1337 <priv.luk@gmail.com>
+* Luiz Felipe das Neves Lopes <androidfelipe23@gmail.com>
+* Luis zas <dalues@gmail.com>
+* Ludovic Jozeau <unifai@protonmail.com>
+* luca sain <luca.sain@outlook.com>
+* lucanomax <lucano.valo@gmail.com>
+* Liao junchao <liaojunchao@outlook.com>
+* Leon Omelan <rozpierog@gmail.com>
+* Leonardo Amaral <contato@leonardoamaral.com.br>
+* Leo bonilla <leo_lf9@hotmail.com>
+* LeJun <lejun@gmx.fr>
+* Lejun <adrienzhang@hotmail.com>
+* lazarosfs <lazarosfs@csd.auth.gr>
+* Lars Vogdt <lars.vogdt@suse.com>
+* ladbsoft <30509719+ladbsoft@users.noreply.github.com>
+* Kyaw Min Khant <kyawmink@gmail.com>
+* Kriszti谩n G谩ncs <990024@gmail.com>
+* Kristjan R盲ts <kristjanrats@gmail.com>
+* Korn茅l Schmidt <kornel.schmidt@clubspot.app>
+* kirk1984 <kirk1984@noreply.codeberg.org>
+* kieranc001 <kieranc001@noreply.codeberg.org>
+* kevlarcade <kevlarcade@gmail.com>
+* Kevin Richter <me@kevinrichter.nl>
+* Kevin MacMartin <prurigro@gmail.com>
+* keeshii <keeshii@ptcg.eu>
+* Kaz Wolfe <root@kazwolfe.io>
+* Kasha <kasha_malaga@hotmail.com>
+* kalaee <alex.kalaee@gmail.com>
+* Julien Winning <heijulien@web.de>
+* Julian Lam <julian@nodebb.org>
+* jugendhacker <jugendhacker@gmail.com>
+* Joseph Kim <official.jkim@gmail.com>
+* jonnsoft <>
+* Johannes Tysiak <vinyl@users.sf.net>
+* Jochen S <tsalin@noreply.codeberg.org>
+* joaquim.org <joaquim.org@gmail.com>
+* jhey <jhey@noreply.codeberg.org>
+* JF <jf@codingfield.com>
+* Jean-Fran莽ois Milants <jf@codingfield.com>
+* jcrode <46062294+jcrode@users.noreply.github.com>
+* Jan Lolek <janlolek@seznam.cz>
+* Jakub Jel铆nek <jakub.jelinek@gmail.com>
+* Jacque Fresco <aidter@use.startmail.com>
+* Izzy <izzy@qumran.org>
+* iwonder <hosted.weblate.org@heychris.eu>
+* Ivan <ivan_tizhanin@mail.ru>
+* InternalErrorX <internalerrorx@noreply.codeberg.org>
+* H眉seyin Aslan <ha098784@gmail.com>
+* Hugel <qihu@nfschina.com>
+* hr-sales <hericsonregis@hotmail.com>
+* Hirnchirurg <anonymous11@posteo.net>
+* Hen Ry <nobo@go4more.de>
+* HelloCodeberg <hellocodeberg@noreply.codeberg.org>
+* HardLight <hardlightxda@gmail.com>
+* Hanhan Husna <matcherapy@gmail.com>
+* halemmerich <halemmerich@noreply.codeberg.org>
+* hackoder <hackoder@noreply.codeberg.org>
+* Gustavo Ramires <gustavo.nramires@gmail.com>
+* gsbhat <>
+* Grzegorz Dznsk <grantmlody96@gmail.com>
+* Golbinex <2061409-Golbinex@users.noreply.gitlab.com>
+* gnufella <gnufella@noreply.codeberg.org>
+* gnu-ewm <gnu.ewm@protonmail.com>
+* Gleb Chekushin <mail@glebchek.com>
+* Giuseppe <giuseppe.parasilitipalumbo@studium.unict.it>
+* Gide茫o Gomes Ferreira <trjctr@gmail.com>
+* gfwilliams <gfwilliams@noreply.codeberg.org>
+* GabO <gabosuelto@gmail.com>
+* Gabe Schrecker <gabe@pbrb.co.uk>
+* freezed-or-frozen <freezed.or.frozen@gmail.com>
+* Frank Slezak <KazWolfe@users.noreply.github.com>
+* Frank Ertl <hrglpfrmpf@noreply.codeberg.org>
+* Florian Beuscher <florianbeuscher@gmail.com>
+* Fabian Hof <weblate@fabian-hof.de>
+* 脡tienne Deparis <etienne@depar.is>
+* Est茅bastien Robespi <estebastien@mailbox.org>
+* Ernst <ernst@seebens.de>
+* Enrico Brambilla <enricobilla@noreply.codeberg.org>
+* Edoardo Tronconi <edoardo.tronconi@gmail.com>
+* Dougal19 <4662351+Dougal19@users.noreply.github.com>
+* Donato <pread.xa4mx@simplelogin.com>
+* Dmytro Bielik <mitrandir.hex@gmail.com>
+* djurik <dirceu.semighini@protonmail.com>
+* DerFetzer <kontakt@der-fetzer.de>
+* Dean <3114661520@qq.com>
+* Deactivated Account <diastasis@gmail.com>
+* David Gir贸n <contacto@duhowpi.net>
+* Davide Corradini <updates+weblate.org@corradinidavi.de>
+* Daniel Thompson <daniel@redfelineninja.org.uk>
+* Daniel Hauck <maill@dhauck.eu>
+* Dam BOND <dambond2001@gmail.com>
+* 鞚挫爼頋� <daemul72@gmail.com>
+* Dachi G <duchy007@yahoo.com>
+* C <weblate@wolki.de>
+* cokecodecock <lights1140977891@163.com>
+* CodeSpoof <nao.s_l_t_e_e_l@protonmail.com>
+* C O <cosmin.oprisan@gmail.com>
+* clach04 <Chris.Clark@actian.com>
+* Chris Perelstein <chris.perelstein@gmail.com>
+* chklump <chklump@noreply.codeberg.org>
+* C茅dric Bellegarde <cedric.bellegarde@adishatz.org>
+* Carlos Ferreira <calbertoferreira@gmail.com>
+* C0rn3j <spleefer90@gmail.com>
+* ButterflyOfFire <ButterflyOfFire@protonmail.com>
+* bucala <marcel.bucala@gmail.com>
+* boun <boun@gmx.de>
+* BobIsMyManager <bobismymanager@noreply.codeberg.org>
+* Bilel MEDIMEGH <bilel.medimegh@gmail.com>
+* Benjamin Kahlau <nyhkkbjyek@roanapur.de>
+* Ben <ben.david.wallner@gmail.com>
+* beardhatcode <beardhatcode@noreply.codeberg.org>
+* batataspt@gmail.com <batataspt@gmail.com>
+* atkyritsis <at.kyritsis@gmail.com>
+* Ascense <ascense@noreply.codeberg.org>
+* Aprilhoomie <Aprilhoomie@gmail.com>
+* apre <adrienpre+github@gmail.com>
+* Ann Test <testkimochiaz@gmail.com>
+* Aniruddha Adhikary <aniruddha@adhikary.net>
+* angelpup <angelpup@noreply.codeberg.org>
+* Anemograph <dyraybn@gmail.com>
+* Andrzej Surowiec <emeryth@gmail.com>
+* Andrew Watkins <randnv20@noreply.codeberg.org>
+* andrewlytvyn <indusfreelancer@gmail.com>
+* AndrewH <36428679+andrewheadricke@users.noreply.github.com>
+* andre <andre.buesgen@yahoo.de>
+* Andrea Lepori <mafaldo@hotmail.it>
+* Allen B <28495335+Allen-B1@users.noreply.github.com>
+* Alicia Hormann <ahormann@gmx.net>
+* Alfeu Lucas Guedes dos Santos <alfeugds@gmail.com>
+* Alexey Afanasev <avafanasiev@gmail.com>
+* Alexandra Sevostyanova <asevostyanova@gmail.com>
+* Aidan Crane <aidancrane78@gmail.com>
+* aerowolf <aerowolf@tom.com>
+* Adam B眉chner <buechner.adam@gmx.de>
+* a b <65567823+abb128@users.noreply.github.com>
+
+And all the former Transifex translators, who cannot be listed automatically.
+
+*To update the contributors list just run this file with bash. Prefix a name with % in .mailmap to set a contact as preferred*
diff --git a/GBDaoGenerator/.gitignore b/GBDaoGenerator/.gitignore
new file mode 100644
index 0000000..81631c6
--- /dev/null
+++ b/GBDaoGenerator/.gitignore
@@ -0,0 +1,2 @@
+/bin
+/build
diff --git a/GBDaoGenerator/build.gradle b/GBDaoGenerator/build.gradle
new file mode 100644
index 0000000..89d2029
--- /dev/null
+++ b/GBDaoGenerator/build.gradle
@@ -0,0 +1,70 @@
+apply plugin: 'java'
+apply plugin: 'maven-publish'
+apply plugin:'application'
+
+archivesBaseName = 'gadgetbridge-daogenerator'
+
+dependencies {
+ // https://github.com/Freeyourgadget/greenDAO/tree/fyg
+ implementation 'org.greenrobot:greendao-generator:2.2.0'
+}
+
+sourceSets {
+ main {
+ java {
+ srcDir 'src'
+ }
+ }
+}
+
+mainClassName = "nodomain.freeyourgadget.gadgetbridge.daogen.GBDaoGenerator"
+
+tasks.register('genSources', JavaExec) {
+ mainClass = mainClassName
+ classpath = sourceSets.main.runtimeClasspath
+ workingDir = '../'
+ doLast {
+ // 瀹樻柟 Gadgetbridge 浣跨敤 Freeyourgadget/greenDAO 鍒嗘敮锛屼細鐢熸垚
+ // PRIMARY KEY ("TIMESTAMP","DEVICE_ID")銆傛湰浠撳簱浠嶇敤鍘熺増 greendao-generator锛�
+ // 浼氭妸澶嶅悎涓婚敭鍐欐垚涓ゅ垪鍚勮嚜 PRIMARY KEY锛屾柊绯荤粺 SQLite锛堝惈鍗庝负锛変細鐩存帴鎶ラ敊闂��銆�
+ def dir = file("${project.rootDir}/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities")
+ int patched = 0
+ dir.eachFileMatch(~/.*Dao\.java/) { f ->
+ def src = f.getText('UTF-8')
+ def methodRe = ~/(?s)(public static void createTable\(SQLiteDatabase db, boolean ifNotExists\) \{.*?\n \})/
+ def next = src.replaceAll(methodRe) { all, method ->
+ def pks = []
+ def colRe = ~/"\\"([A-Z0-9_]+)\\" (?:INTEGER|TEXT|REAL|BLOB) PRIMARY KEY/
+ (method =~ colRe).each { m -> pks << m[1] }
+ if (pks.size() <= 1) return method
+ def out = method.replaceAll(/ PRIMARY KEY/, '')
+ def pkSql = 'PRIMARY KEY (\\"' + pks.join('\\", \\"') + '\\")'
+ def closeIdx = out.indexOf(');");')
+ if (closeIdx < 0) return method
+ return out.substring(0, closeIdx) + ', ' + pkSql + ');");' + out.substring(closeIdx + 5)
+ }
+ if (next != src) {
+ f.write(next, 'UTF-8')
+ patched++
+ }
+ }
+ println "fixCompositePrimaryKeys: patched ${patched} DAO files"
+ }
+}
+
+artifacts {
+ archives jar
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+}
+
+tasks.named("distTar") {
+ duplicatesStrategy = 'include'
+}
+
+tasks.named("distZip") {
+ duplicatesStrategy = 'include'
+}
diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
new file mode 100644
index 0000000..73cd6e2
--- /dev/null
+++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
@@ -0,0 +1,1591 @@
+/*
+ * Copyright (C) 2011 Markus Junginger, greenrobot (http://greenrobot.de)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package nodomain.freeyourgadget.gadgetbridge.daogen;
+
+import de.greenrobot.daogenerator.DaoGenerator;
+import de.greenrobot.daogenerator.Entity;
+import de.greenrobot.daogenerator.Index;
+import de.greenrobot.daogenerator.Property;
+import de.greenrobot.daogenerator.Schema;
+
+/**
+ * Generates entities and DAOs for the example project DaoExample.
+ * Automatically run during build.
+ */
+public class GBDaoGenerator {
+
+ private static final String VALID_FROM_UTC = "validFromUTC";
+ private static final String VALID_TO_UTC = "validToUTC";
+ private static final String MAIN_PACKAGE = "nodomain.freeyourgadget.gadgetbridge";
+ private static final String MODEL_PACKAGE = MAIN_PACKAGE + ".model";
+ private static final String VALID_BY_DATE = MODEL_PACKAGE + ".ValidByDate";
+ private static final String ACTIVITY_SUMMARY = MODEL_PACKAGE + ".ActivitySummary";
+ private static final String OVERRIDE = "@Override";
+ private static final String SAMPLE_RAW_INTENSITY = "rawIntensity";
+ private static final String SAMPLE_STEPS = "steps";
+ private static final String SAMPLE_RAW_KIND = "rawKind";
+ private static final String SAMPLE_HEART_RATE = "heartRate";
+ private static final String SAMPLE_HRV_WEEKLY_AVERAGE = "weeklyAverage";
+ private static final String SAMPLE_HRV_LAST_NIGHT_AVERAGE = "lastNightAverage";
+ private static final String SAMPLE_HRV_LAST_NIGHT_5MIN_HIGH = "lastNight5MinHigh";
+ private static final String SAMPLE_HRV_BASELINE_LOW_UPPER = "baselineLowUpper";
+ private static final String SAMPLE_HRV_BASELINE_BALANCED_LOWER = "baselineBalancedLower";
+ private static final String SAMPLE_HRV_BASELINE_BALANCED_UPPER = "baselineBalancedUpper";
+ private static final String SAMPLE_HRV_STATUS_NUM = "statusNum";
+ private static final String SAMPLE_HRV_VALUE = "value";
+ private static final String SAMPLE_TEMPERATURE = "temperature";
+ private static final String SAMPLE_TEMPERATURE_TYPE = "temperatureType";
+ private static final String SAMPLE_WEIGHT_KG = "weightKg";
+ private static final String TIMESTAMP_FROM = "timestampFrom";
+ private static final String TIMESTAMP_TO = "timestampTo";
+
+
+ public static void main(String[] args) throws Exception {
+ final Schema schema = new Schema(92, MAIN_PACKAGE + ".entities");
+
+ Entity userAttributes = addUserAttributes(schema);
+ Entity user = addUserInfo(schema, userAttributes);
+
+ Entity deviceAttributes = addDeviceAttributes(schema);
+ Entity device = addDevice(schema, deviceAttributes);
+
+ // yeah deep shit, has to be here (after device) for db upgrade and column order
+ // because addDevice adds a property to deviceAttributes also....
+ deviceAttributes.addStringProperty("volatileIdentifier");
+
+ Entity tag = addTag(schema);
+ Entity userDefinedActivityOverlay = addActivityDescription(schema, tag, user);
+
+ addMakibesHR3ActivitySample(schema, user, device);
+ addMiBandActivitySample(schema, user, device);
+ addHuamiExtendedActivitySample(schema, user, device);
+ addHuamiStressSample(schema, user, device);
+ addHuamiSpo2Sample(schema, user, device);
+ addHuamiHeartRateManualSample(schema, user, device);
+ addHuamiHeartRateMaxSample(schema, user, device);
+ addHuamiHeartRateRestingSample(schema, user, device);
+ addHuamiPaiSample(schema, user, device);
+ addHuamiSleepRespiratoryRateSample(schema, user, device);
+ addXiaomiActivitySample(schema, user, device);
+ addXiaomiSleepTimeSamples(schema, user, device);
+ addHeartPulseSamples(schema, user, device);
+ addXiaomiSleepStageSamples(schema, user, device);
+ addXiaomiManualSamples(schema, user, device);
+ addXiaomiDailySummarySamples(schema, user, device);
+ addCmfActivitySample(schema, user, device);
+ addCmfStressSample(schema, user, device);
+ addCmfSpo2Sample(schema, user, device);
+ addCmfSleepSessionSample(schema, user, device);
+ addCmfSleepStageSample(schema, user, device);
+ addCmfHeartRateSample(schema, user, device);
+ addCmfWorkoutGpsSample(schema, user, device);
+ addPebbleHealthActivitySample(schema, user, device);
+ addPebbleHealthActivityKindOverlay(schema, user, device);
+ addPebbleMisfitActivitySample(schema, user, device);
+ addPebbleMorpheuzActivitySample(schema, user, device);
+ addHPlusHealthActivityKindOverlay(schema, user, device);
+ addHPlusHealthActivitySample(schema, user, device);
+ addNo1F1ActivitySample(schema, user, device);
+ addXWatchActivitySample(schema, user, device);
+ addZeTimeActivitySample(schema, user, device);
+ addID115ActivitySample(schema, user, device);
+ addJYouActivitySample(schema, user, device);
+ addWatchXPlusHealthActivitySample(schema, user, device);
+ addWatchXPlusHealthActivityKindOverlay(schema, user, device);
+ addTLW64ActivitySample(schema, user, device);
+ addLefunActivitySample(schema, user, device);
+ addLefunBiometricSample(schema, user, device);
+ addLefunSleepSample(schema, user, device);
+ addSonySWR12Sample(schema, user, device);
+ addBangleJSActivitySample(schema, user, device);
+ addCasioGBX100Sample(schema, user, device);
+ addFitProActivitySample(schema, user, device);
+ addPineTimeActivitySample(schema, user, device);
+ addWithingsSteelHRActivitySample(schema, user, device);
+ addHybridHRActivitySample(schema, user, device);
+ addHybridHRSpo2Sample(schema, user, device);
+ addVivomoveHrActivitySample(schema, user, device);
+ addGarminFitFile(schema, user, device);
+ addGarminActivitySample(schema, user, device);
+ addGarminStressSample(schema, user, device);
+ addGarminBodyEnergySample(schema, user, device);
+ addGarminSpo2Sample(schema, user, device);
+ addGarminSleepStageSample(schema, user, device);
+ addGarminEventSample(schema, user, device);
+ addGarminHrvSummarySample(schema, user, device);
+ addGarminHrvValueSample(schema, user, device);
+ addGarminRespiratoryRateSample(schema, user, device);
+ addGarminHeartRateRestingSample(schema, user, device);
+ addGarminRestingMetabolicRateSample(schema, user, device);
+ addGarminSleepStatsSample(schema, user, device);
+ addGarminIntensityMinutesSample(schema, user, device);
+ addPendingFile(schema, user, device);
+ addWena3EnergySample(schema, user, device);
+ addWena3BehaviorSample(schema, user, device);
+ addWena3CaloriesSample(schema, user, device);
+ addWena3ActivitySample(schema, user, device);
+ addWena3HeartRateSample(schema, user, device);
+ addWena3Vo2Sample(schema, user, device);
+ addWena3StressSample(schema, user, device);
+ addFemometerVinca2TemperatureSample(schema, user, device);
+ addMiScaleWeightSample(schema, user, device);
+ addColmiActivitySample(schema, user, device);
+ addColmiHeartRateSample(schema, user, device);
+ addColmiSpo2Sample(schema, user, device);
+ addColmiStressSample(schema, user, device);
+ addColmiSleepSessionSample(schema, user, device);
+ addColmiSleepStageSample(schema, user, device);
+ addColmiHrvValueSample(schema, user, device);
+ addColmiHrvSummarySample(schema, user, device);
+
+ addHuaweiActivitySample(schema, user, device);
+
+ Entity huaweiWorkoutSummary = addHuaweiWorkoutSummarySample(schema, user, device);
+ addHuaweiWorkoutDataSample(schema, huaweiWorkoutSummary);
+ addHuaweiWorkoutPaceSample(schema, huaweiWorkoutSummary);
+ addHuaweiWorkoutSwimSegmentsSample(schema, huaweiWorkoutSummary);
+
+ Entity huaweiDictData = addHuaweiDictData(schema, user, device);
+ addHuaweiDictDataValues(schema, huaweiDictData);
+
+ addCalendarSyncState(schema, device);
+ addAlarms(schema, user, device);
+ addReminders(schema, user, device);
+ addWorldClocks(schema, user, device);
+ addContacts(schema, user, device);
+ addAppSpecificNotificationSettings(schema, device);
+ addCyclingSample(schema, user, device);
+
+ Entity notificationFilter = addNotificationFilters(schema);
+
+ addNotificationFilterEntry(schema, notificationFilter);
+
+ addActivitySummary(schema, user, device);
+ addBatteryLevel(schema, device);
+ new DaoGenerator().generateAll(schema, "app/src/main/java");
+ }
+
+ private static Entity addTag(Schema schema) {
+ Entity tag = addEntity(schema, "Tag");
+ tag.addIdProperty();
+ tag.addStringProperty("name").notNull();
+ tag.addStringProperty("description").javaDocGetterAndSetter("An optional description of this tag.");
+ tag.addLongProperty("userId").notNull();
+
+ return tag;
+ }
+
+ private static Entity addActivityDescription(Schema schema, Entity tag, Entity user) {
+ Entity activityDesc = addEntity(schema, "ActivityDescription");
+ activityDesc.setJavaDoc("A user may further specify his activity with a detailed description and the help of tags.\nOne or more tags can be added to a given activity range.");
+ activityDesc.addIdProperty();
+ activityDesc.addIntProperty(TIMESTAMP_FROM).notNull();
+ activityDesc.addIntProperty(TIMESTAMP_TO).notNull();
+ activityDesc.addStringProperty("details").javaDocGetterAndSetter("An optional detailed description, specific to this very activity occurrence.");
+
+ Property userId = activityDesc.addLongProperty("userId").notNull().getProperty();
+ activityDesc.addToOne(user, userId);
+
+ Entity activityDescTagLink = addEntity(schema, "ActivityDescTagLink");
+ activityDescTagLink.addIdProperty();
+ Property sourceId = activityDescTagLink.addLongProperty("activityDescriptionId").notNull().getProperty();
+ Property targetId = activityDescTagLink.addLongProperty("tagId").notNull().getProperty();
+
+ activityDesc.addToMany(tag, activityDescTagLink, sourceId, targetId);
+
+ return activityDesc;
+ }
+
+ private static Entity addUserInfo(Schema schema, Entity userAttributes) {
+ Entity user = addEntity(schema, "User");
+ user.addIdProperty();
+ user.addStringProperty("name").notNull();
+ user.addDateProperty("birthday").notNull();
+ user.addIntProperty("gender").notNull();
+ Property userId = userAttributes.addLongProperty("userId").notNull().getProperty();
+
+ // sorted by the from-date, newest first
+ Property userAttributesSortProperty = getPropertyByName(userAttributes, VALID_FROM_UTC);
+ user.addToMany(userAttributes, userId).orderDesc(userAttributesSortProperty);
+
+ return user;
+ }
+
+ private static Property getPropertyByName(Entity entity, String propertyName) {
+ for (Property prop : entity.getProperties()) {
+ if (propertyName.equals(prop.getPropertyName())) {
+ return prop;
+ }
+ }
+ throw new IllegalStateException("Could not find property " + propertyName + " in entity " + entity.getClassName());
+ }
+
+ private static Entity addUserAttributes(Schema schema) {
+ // additional properties of a user, which may change during the lifetime of a user
+ // this allows changing attributes while preserving user identity
+ Entity userAttributes = addEntity(schema, "UserAttributes");
+ userAttributes.addIdProperty();
+ userAttributes.addIntProperty("heightCM").notNull();
+ userAttributes.addIntProperty("weightKG").notNull();
+ userAttributes.addIntProperty("sleepGoalHPD").javaDocGetterAndSetter("Desired number of hours of sleep per day.");
+ userAttributes.addIntProperty("stepsGoalSPD").javaDocGetterAndSetter("Desired number of steps per day.");
+ addDateValidityTo(userAttributes);
+
+ return userAttributes;
+ }
+
+ private static void addDateValidityTo(Entity entity) {
+ entity.addDateProperty(VALID_FROM_UTC).codeBeforeGetter(OVERRIDE);
+ entity.addDateProperty(VALID_TO_UTC).codeBeforeGetter(OVERRIDE);
+
+ entity.implementsInterface(VALID_BY_DATE);
+ }
+
+ private static Entity addDevice(Schema schema, Entity deviceAttributes) {
+ Entity device = addEntity(schema, "Device");
+ device.addIdProperty();
+ device.addStringProperty("name").notNull();
+ device.addStringProperty("manufacturer").notNull();
+ device.addStringProperty("identifier").notNull().unique().javaDocGetterAndSetter("The fixed identifier, i.e. MAC address of the device.");
+ device.addIntProperty("type").notNull().javaDocGetterAndSetter("The DeviceType key, i.e. the GBDevice's type.").codeBeforeGetterAndSetter("@Deprecated");
+ device.addStringProperty("typeName").notNull().javaDocGetterAndSetter("The DeviceType enum name, for example SONY_WH_1000XM3");
+ device.addStringProperty("model").javaDocGetterAndSetter("An optional model, further specifying the kind of device.");
+ device.addStringProperty("alias");
+ device.addStringProperty("parentFolder").javaDocGetterAndSetter("Folder name containing this device.");
+ Property deviceId = deviceAttributes.addLongProperty("deviceId").notNull().getProperty();
+ // sorted by the from-date, newest first
+ Property deviceAttributesSortProperty = getPropertyByName(deviceAttributes, VALID_FROM_UTC);
+ device.addToMany(deviceAttributes, deviceId).orderDesc(deviceAttributesSortProperty);
+
+ return device;
+ }
+
+ private static Entity addDeviceAttributes(Schema schema) {
+ Entity deviceAttributes = addEntity(schema, "DeviceAttributes");
+ deviceAttributes.addIdProperty();
+ deviceAttributes.addStringProperty("firmwareVersion1").notNull();
+ deviceAttributes.addStringProperty("firmwareVersion2");
+ addDateValidityTo(deviceAttributes);
+
+ return deviceAttributes;
+ }
+
+ private static Entity addMakibesHR3ActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "MakibesHR3ActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
+ private static Entity addMiBandActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "MiBandActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
+ private static Entity addHuamiExtendedActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "HuamiExtendedActivitySample");
+ addCommonActivitySampleProperties("MiBandActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty("unknown1");
+ activitySample.addIntProperty("sleep");
+ activitySample.addIntProperty("deepSleep");
+ activitySample.addIntProperty("remSleep");
+ return activitySample;
+ }
+
+ private static Entity addHuamiStressSample(Schema schema, Entity user, Entity device) {
+ Entity stressSample = addEntity(schema, "HuamiStressSample");
+ addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
+ stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
+ stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ return stressSample;
+ }
+
+ private static Entity addHuamiSpo2Sample(Schema schema, Entity user, Entity device) {
+ Entity spo2sample = addEntity(schema, "HuamiSpo2Sample");
+ addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
+ spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
+ spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ return spo2sample;
+ }
+
+ private static Entity addHuamiHeartRateManualSample(Schema schema, Entity user, Entity device) {
+ Entity hrManualSample = addEntity(schema, "HuamiHeartRateManualSample");
+ addCommonTimeSampleProperties("AbstractHeartRateSample", hrManualSample, user, device);
+ hrManualSample.addIntProperty("utcOffset").notNull();
+ hrManualSample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetter(OVERRIDE);
+ return hrManualSample;
+ }
+
+ private static Entity addHuamiHeartRateMaxSample(Schema schema, Entity user, Entity device) {
+ Entity hrMaxSample = addEntity(schema, "HuamiHeartRateMaxSample");
+ addCommonTimeSampleProperties("AbstractHeartRateSample", hrMaxSample, user, device);
+ hrMaxSample.addIntProperty("utcOffset").notNull();
+ hrMaxSample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetter(OVERRIDE);
+ return hrMaxSample;
+ }
+
+ private static Entity addHuamiHeartRateRestingSample(Schema schema, Entity user, Entity device) {
+ Entity hrRestingSample = addEntity(schema, "HuamiHeartRateRestingSample");
+ addCommonTimeSampleProperties("AbstractHeartRateSample", hrRestingSample, user, device);
+ hrRestingSample.addIntProperty("utcOffset").notNull();
+ hrRestingSample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetter(OVERRIDE);
+ return hrRestingSample;
+ }
+
+ private static Entity addHuamiPaiSample(Schema schema, Entity user, Entity device) {
+ Entity paiSample = addEntity(schema, "HuamiPaiSample");
+ addCommonTimeSampleProperties("AbstractPaiSample", paiSample, user, device);
+ paiSample.addIntProperty("utcOffset").notNull();
+ paiSample.addFloatProperty("paiLow").notNull().codeBeforeGetter(OVERRIDE);
+ paiSample.addFloatProperty("paiModerate").notNull().codeBeforeGetter(OVERRIDE);
+ paiSample.addFloatProperty("paiHigh").notNull().codeBeforeGetter(OVERRIDE);
+ paiSample.addIntProperty("timeLow").notNull().codeBeforeGetter(OVERRIDE);
+ paiSample.addIntProperty("timeModerate").notNull().codeBeforeGetter(OVERRIDE);
+ paiSample.addIntProperty("timeHigh").notNull().codeBeforeGetter(OVERRIDE);
+ paiSample.addFloatProperty("paiToday").notNull().codeBeforeGetter(OVERRIDE);
+ paiSample.addFloatProperty("paiTotal").notNull().codeBeforeGetter(OVERRIDE);
+ return paiSample;
+ }
+
+ private static Entity addHuamiSleepRespiratoryRateSample(Schema schema, Entity user, Entity device) {
+ Entity sleepRespiratoryRateSample = addEntity(schema, "HuamiSleepRespiratoryRateSample");
+ addCommonTimeSampleProperties("AbstractRespiratoryRateSample", sleepRespiratoryRateSample, user, device);
+ sleepRespiratoryRateSample.addIntProperty("utcOffset").notNull();
+ sleepRespiratoryRateSample.addIntProperty("rate").notNull().codeBeforeGetter(
+ "@Override\n" +
+ " public float getRespiratoryRate() {\n" +
+ " return (float) getRate();\n" +
+ " }\n\n"
+ );
+ return sleepRespiratoryRateSample;
+ }
+
+ private static Entity addXiaomiActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "XiaomiActivitySample");
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.implementsSerializable();
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty("stress");
+ activitySample.addIntProperty("spo2");
+ return activitySample;
+ }
+
+ private static Entity addXiaomiSleepTimeSamples(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "XiaomiSleepTimeSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
+ sample.addLongProperty("wakeupTime");
+ sample.addBooleanProperty("isAwake");
+ sample.addIntProperty("totalDuration");
+ sample.addIntProperty("deepSleepDuration");
+ sample.addIntProperty("lightSleepDuration");
+ sample.addIntProperty("remSleepDuration");
+ sample.addIntProperty("awakeDuration");
+ return sample;
+ }
+
+ private static Entity addHeartPulseSamples(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "HeartPulseSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
+ return sample;
+ }
+
+ private static Entity addXiaomiSleepStageSamples(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "XiaomiSleepStageSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
+ sample.addIntProperty("stage");
+ return sample;
+ }
+
+ private static Entity addXiaomiManualSamples(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "XiaomiManualSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
+ sample.addIntProperty("type");
+ sample.addIntProperty("value");
+ return sample;
+ }
+
+ private static Entity addXiaomiDailySummarySamples(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "XiaomiDailySummarySample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
+ sample.addIntProperty("timezone");
+ sample.addIntProperty("steps");
+ sample.addIntProperty("hrResting");
+ sample.addIntProperty("hrMax");
+ sample.addIntProperty("hrMaxTs");
+ sample.addIntProperty("hrMin");
+ sample.addIntProperty("hrMinTs");
+ sample.addIntProperty("hrAvg");
+ sample.addIntProperty("stressAvg");
+ sample.addIntProperty("stressMax");
+ sample.addIntProperty("stressMin");
+ sample.addIntProperty("standing");
+ sample.addIntProperty("calories");
+ sample.addIntProperty("spo2Max");
+ sample.addIntProperty("spo2MaxTs");
+ sample.addIntProperty("spo2Min");
+ sample.addIntProperty("spo2MinTs");
+ sample.addIntProperty("spo2Avg");
+ sample.addIntProperty("trainingLoadDay");
+ sample.addIntProperty("trainingLoadWeek");
+ sample.addIntProperty("trainingLoadLevel");
+ sample.addIntProperty("vitalityIncreaseLight");
+ sample.addIntProperty("vitalityIncreaseModerate");
+ sample.addIntProperty("vitalityIncreaseHigh");
+ sample.addIntProperty("vitalityCurrent");
+ return sample;
+ }
+
+ private static Entity addCmfActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "CmfActivitySample");
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.implementsSerializable();
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty("distance");
+ activitySample.addIntProperty("calories");
+ return activitySample;
+ }
+
+ private static Entity addCmfStressSample(Schema schema, Entity user, Entity device) {
+ Entity stressSample = addEntity(schema, "CmfStressSample");
+ addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
+ stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ return stressSample;
+ }
+
+ private static Entity addCmfSpo2Sample(Schema schema, Entity user, Entity device) {
+ Entity spo2sample = addEntity(schema, "CmfSpo2Sample");
+ addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
+ spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ return spo2sample;
+ }
+
+ private static Entity addCmfSleepSessionSample(Schema schema, Entity user, Entity device) {
+ Entity sleepSessionSample = addEntity(schema, "CmfSleepSessionSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepSessionSample, user, device);
+ sleepSessionSample.addLongProperty("wakeupTime");
+ sleepSessionSample.addByteArrayProperty("metadata");
+ return sleepSessionSample;
+ }
+
+ private static Entity addCmfSleepStageSample(Schema schema, Entity user, Entity device) {
+ Entity sleepStageSample = addEntity(schema, "CmfSleepStageSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device);
+ sleepStageSample.addIntProperty("duration").notNull();
+ sleepStageSample.addIntProperty("stage").notNull();
+ return sleepStageSample;
+ }
+
+ private static Entity addCmfHeartRateSample(Schema schema, Entity user, Entity device) {
+ Entity heartRateSample = addEntity(schema, "CmfHeartRateSample");
+ addCommonTimeSampleProperties("AbstractHeartRateSample", heartRateSample, user, device);
+ heartRateSample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetter(OVERRIDE);
+ return heartRateSample;
+ }
+
+ private static Entity addCmfWorkoutGpsSample(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "CmfWorkoutGpsSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
+ sample.addIntProperty("latitude");
+ sample.addIntProperty("longitude");
+ return sample;
+ }
+
+ private static Entity addColmiActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "ColmiActivitySample");
+ addCommonActivitySampleProperties("AbstractColmiActivitySample", activitySample, user, device);
+ activitySample.implementsSerializable();
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty("distance").notNull();
+ activitySample.addIntProperty("calories").notNull();
+ return activitySample;
+ }
+
+ private static Entity addColmiHeartRateSample(Schema schema, Entity user, Entity device) {
+ Entity heartRateSample = addEntity(schema, "ColmiHeartRateSample");
+ heartRateSample.implementsSerializable();
+ addCommonTimeSampleProperties("AbstractHeartRateSample", heartRateSample, user, device);
+ heartRateSample.addIntProperty(SAMPLE_HEART_RATE).notNull();
+ return heartRateSample;
+ }
+
+ private static Entity addColmiStressSample(Schema schema, Entity user, Entity device) {
+ Entity stressSample = addEntity(schema, "ColmiStressSample");
+ addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
+ stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ return stressSample;
+ }
+
+ private static Entity addColmiSpo2Sample(Schema schema, Entity user, Entity device) {
+ Entity spo2sample = addEntity(schema, "ColmiSpo2Sample");
+ addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
+ spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ return spo2sample;
+ }
+
+ private static Entity addColmiSleepSessionSample(Schema schema, Entity user, Entity device) {
+ Entity sleepSessionSample = addEntity(schema, "ColmiSleepSessionSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepSessionSample, user, device);
+ sleepSessionSample.addLongProperty("wakeupTime");
+ return sleepSessionSample;
+ }
+
+ private static Entity addColmiSleepStageSample(Schema schema, Entity user, Entity device) {
+ Entity sleepStageSample = addEntity(schema, "ColmiSleepStageSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device);
+ sleepStageSample.addIntProperty("duration").notNull();
+ sleepStageSample.addIntProperty("stage").notNull();
+ return sleepStageSample;
+ }
+
+ private static Entity addColmiHrvValueSample(Schema schema, Entity user, Entity device) {
+ Entity hrvValueSample = addEntity(schema, "ColmiHrvValueSample");
+ addCommonTimeSampleProperties("AbstractHrvValueSample", hrvValueSample, user, device);
+ hrvValueSample.addIntProperty(SAMPLE_HRV_VALUE).notNull().codeBeforeGetter(OVERRIDE);
+ return hrvValueSample;
+ }
+
+ private static Entity addColmiHrvSummarySample(Schema schema, Entity user, Entity device) {
+ Entity hrvSummarySample = addEntity(schema, "ColmiHrvSummarySample");
+ addCommonTimeSampleProperties("AbstractHrvSummarySample", hrvSummarySample, user, device);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_WEEKLY_AVERAGE).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_LAST_NIGHT_AVERAGE).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_LAST_NIGHT_5MIN_HIGH).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_BASELINE_LOW_UPPER).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_BASELINE_BALANCED_LOWER).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_BASELINE_BALANCED_UPPER).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_STATUS_NUM).codeBeforeGetter(OVERRIDE);
+ return hrvSummarySample;
+ }
+
+ private static void addHeartRateProperties(Entity activitySample) {
+ activitySample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ }
+
+ private static Entity addPebbleHealthActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "PebbleHealthActivitySample");
+ addCommonActivitySampleProperties("AbstractPebbleHealthActivitySample", activitySample, user, device);
+ activitySample.addByteArrayProperty("rawPebbleHealthData").codeBeforeGetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
+ private static Entity addPebbleHealthActivityKindOverlay(Schema schema, Entity user, Entity device) {
+ Entity activityOverlay = addEntity(schema, "PebbleHealthActivityOverlay");
+
+ activityOverlay.addIntProperty(TIMESTAMP_FROM).notNull().primaryKey();
+ activityOverlay.addIntProperty(TIMESTAMP_TO).notNull().primaryKey();
+ activityOverlay.addIntProperty(SAMPLE_RAW_KIND).notNull().primaryKey();
+ Property deviceId = activityOverlay.addLongProperty("deviceId").primaryKey().notNull().getProperty();
+ activityOverlay.addToOne(device, deviceId);
+
+ Property userId = activityOverlay.addLongProperty("userId").notNull().getProperty();
+ activityOverlay.addToOne(user, userId);
+ activityOverlay.addByteArrayProperty("rawPebbleHealthData");
+
+ return activityOverlay;
+ }
+
+ private static Entity addPebbleMisfitActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "PebbleMisfitSample");
+ addCommonActivitySampleProperties("AbstractPebbleMisfitActivitySample", activitySample, user, device);
+ activitySample.addIntProperty("rawPebbleMisfitSample").notNull().codeBeforeGetter(OVERRIDE);
+ return activitySample;
+ }
+
+ private static Entity addPebbleMorpheuzActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "PebbleMorpheuzSample");
+ addCommonActivitySampleProperties("AbstractPebbleMorpheuzActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ return activitySample;
+ }
+
+ private static Entity addHPlusHealthActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "HPlusHealthActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addByteArrayProperty("rawHPlusHealthData");
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().primaryKey();
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty("distance");
+ activitySample.addIntProperty("calories");
+
+ return activitySample;
+ }
+
+ private static Entity addHPlusHealthActivityKindOverlay(Schema schema, Entity user, Entity device) {
+ Entity activityOverlay = addEntity(schema, "HPlusHealthActivityOverlay");
+
+ activityOverlay.addIntProperty(TIMESTAMP_FROM).notNull().primaryKey();
+ activityOverlay.addIntProperty(TIMESTAMP_TO).notNull().primaryKey();
+ activityOverlay.addIntProperty(SAMPLE_RAW_KIND).notNull().primaryKey();
+ Property deviceId = activityOverlay.addLongProperty("deviceId").primaryKey().notNull().getProperty();
+ activityOverlay.addToOne(device, deviceId);
+
+ Property userId = activityOverlay.addLongProperty("userId").notNull().getProperty();
+ activityOverlay.addToOne(user, userId);
+ activityOverlay.addByteArrayProperty("rawHPlusHealthData");
+ return activityOverlay;
+ }
+
+ private static Entity addNo1F1ActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "No1F1ActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
+ private static Entity addXWatchActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "XWatchActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
+ private static Entity addZeTimeActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "ZeTimeActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty("caloriesBurnt");
+ activitySample.addIntProperty("distanceMeters");
+ activitySample.addIntProperty("activeTimeMinutes");
+ return activitySample;
+ }
+
+ private static Entity addID115ActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "ID115ActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty("caloriesBurnt");
+ activitySample.addIntProperty("distanceMeters");
+ activitySample.addIntProperty("activeTimeMinutes");
+ return activitySample;
+ }
+
+ private static Entity addJYouActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "JYouActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty("caloriesBurnt");
+ activitySample.addIntProperty("distanceMeters");
+ activitySample.addIntProperty("activeTimeMinutes");
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
+ private static Entity addHybridHRActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "HybridHRActivitySample");
+ activitySample.implementsSerializable();
+
+ addCommonActivitySampleProperties("AbstractHybridHRActivitySample", activitySample, user, device);
+
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty("calories").notNull();
+ activitySample.addIntProperty("variability").notNull();
+ activitySample.addIntProperty("max_variability").notNull();
+ activitySample.addIntProperty("heartrate_quality").notNull();
+ activitySample.addBooleanProperty("active").notNull();
+ activitySample.addByteProperty("wear_type").notNull();
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
+ private static Entity addHybridHRSpo2Sample(Schema schema, Entity user, Entity device) {
+ Entity spo2sample = addEntity(schema, "HybridHRSpo2Sample");
+ addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
+ spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ return spo2sample;
+ }
+
+ private static Entity addCyclingSample(Schema schema, Entity user, Entity device) {
+ Entity cyclingSample = addEntity(schema, "CyclingSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", cyclingSample, user, device);
+
+ cyclingSample.implementsSerializable();
+
+ cyclingSample.addIntProperty("RevolutionCount");
+ cyclingSample.addFloatProperty("Distance");
+ cyclingSample.addFloatProperty("Speed");
+ return cyclingSample;
+ }
+
+ private static Entity addVivomoveHrActivitySample(Schema schema, Entity user, Entity device) {
+ final Entity activitySample = addEntity(schema, "VivomoveHrActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty("caloriesBurnt");
+ activitySample.addIntProperty("floorsClimbed");
+ return activitySample;
+ }
+
+ private static Entity addGarminFitFile(Schema schema, Entity user, Entity device) {
+ final Entity downloadedFitFile = addEntity(schema, "GarminFitFile");
+ downloadedFitFile.implementsSerializable();
+ downloadedFitFile.setJavaDoc("This class represents a single FIT file downloaded from a FIT-compatible Garmin device.");
+ downloadedFitFile.addIdProperty().autoincrement();
+ downloadedFitFile.addLongProperty("downloadTimestamp").notNull();
+ final Property deviceId = downloadedFitFile.addLongProperty("deviceId").notNull().getProperty();
+ downloadedFitFile.addToOne(device, deviceId);
+ final Property userId = downloadedFitFile.addLongProperty("userId").notNull().getProperty();
+ downloadedFitFile.addToOne(user, userId);
+ final Property fileNumber = downloadedFitFile.addIntProperty("fileNumber").notNull().getProperty();
+ downloadedFitFile.addIntProperty("fileDataType").notNull();
+ downloadedFitFile.addIntProperty("fileSubType").notNull();
+ downloadedFitFile.addLongProperty("fileTimestamp").notNull();
+ downloadedFitFile.addIntProperty("specificFlags").notNull();
+ downloadedFitFile.addIntProperty("fileSize").notNull();
+ downloadedFitFile.addByteArrayProperty("fileData");
+
+ final Index indexUnique = new Index();
+ indexUnique.addProperty(deviceId);
+ indexUnique.addProperty(userId);
+ indexUnique.addProperty(fileNumber);
+ indexUnique.makeUnique();
+
+ downloadedFitFile.addIndex(indexUnique);
+
+ return downloadedFitFile;
+ }
+
+ private static Entity addGarminActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "GarminActivitySample");
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.implementsSerializable();
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty("distanceCm").notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty("activeCalories").notNull().codeBeforeGetterAndSetter(OVERRIDE);
+
+ return activitySample;
+ }
+
+ private static Entity addGarminStressSample(Schema schema, Entity user, Entity device) {
+ Entity stressSample = addEntity(schema, "GarminStressSample");
+ addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
+ stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ return stressSample;
+ }
+
+ private static Entity addGarminBodyEnergySample(Schema schema, Entity user, Entity device) {
+ Entity stressSample = addEntity(schema, "GarminBodyEnergySample");
+ addCommonTimeSampleProperties("AbstractBodyEnergySample", stressSample, user, device);
+ stressSample.addIntProperty("energy").notNull().codeBeforeGetter(OVERRIDE);
+ return stressSample;
+ }
+
+ private static Entity addGarminSpo2Sample(Schema schema, Entity user, Entity device) {
+ Entity spo2sample = addEntity(schema, "GarminSpo2Sample");
+ addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
+ spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ return spo2sample;
+ }
+
+ private static Entity addGarminSleepStageSample(Schema schema, Entity user, Entity device) {
+ Entity sleepStageSample = addEntity(schema, "GarminSleepStageSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device);
+ sleepStageSample.addIntProperty("stage").notNull();
+ return sleepStageSample;
+ }
+
+ private static Entity addGarminEventSample(Schema schema, Entity user, Entity device) {
+ Entity sleepStageSample = addEntity(schema, "GarminEventSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device);
+ sleepStageSample.addIntProperty("event").notNull().primaryKey();
+ sleepStageSample.addIntProperty("eventType");
+ sleepStageSample.addLongProperty("data");
+ return sleepStageSample;
+ }
+
+ private static Entity addGarminHrvSummarySample(Schema schema, Entity user, Entity device) {
+ Entity hrvSummarySample = addEntity(schema, "GarminHrvSummarySample");
+ addCommonTimeSampleProperties("AbstractHrvSummarySample", hrvSummarySample, user, device);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_WEEKLY_AVERAGE).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_LAST_NIGHT_AVERAGE).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_LAST_NIGHT_5MIN_HIGH).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_BASELINE_LOW_UPPER).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_BASELINE_BALANCED_LOWER).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_BASELINE_BALANCED_UPPER).codeBeforeGetter(OVERRIDE);
+ hrvSummarySample.addIntProperty(SAMPLE_HRV_STATUS_NUM).codeBeforeGetter(OVERRIDE);
+ return hrvSummarySample;
+ }
+
+ private static Entity addGarminHrvValueSample(Schema schema, Entity user, Entity device) {
+ Entity hrvValueSample = addEntity(schema, "GarminHrvValueSample");
+ addCommonTimeSampleProperties("AbstractHrvValueSample", hrvValueSample, user, device);
+ hrvValueSample.addIntProperty("value").notNull().codeBeforeGetter(OVERRIDE);
+ return hrvValueSample;
+ }
+
+ private static Entity addGarminRespiratoryRateSample(Schema schema, Entity user, Entity device) {
+ Entity garminRespiratoryRateSample = addEntity(schema, "GarminRespiratoryRateSample");
+ addCommonTimeSampleProperties("AbstractRespiratoryRateSample", garminRespiratoryRateSample, user, device);
+ garminRespiratoryRateSample.addFloatProperty("respiratoryRate").notNull().codeBeforeGetter(OVERRIDE);
+ return garminRespiratoryRateSample;
+ }
+
+ private static Entity addGarminHeartRateRestingSample(Schema schema, Entity user, Entity device) {
+ Entity hrRestingSample = addEntity(schema, "GarminHeartRateRestingSample");
+ addCommonTimeSampleProperties("AbstractHeartRateSample", hrRestingSample, user, device);
+ hrRestingSample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetter(OVERRIDE);
+ return hrRestingSample;
+ }
+
+ private static Entity addGarminRestingMetabolicRateSample(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "GarminRestingMetabolicRateSample");
+ sample.addImport(MAIN_PACKAGE + ".model.RestingMetabolicRateSample");
+ addCommonTimeSampleProperties("RestingMetabolicRateSample", sample, user, device);
+ sample.addIntProperty("restingMetabolicRate").notNull().codeBeforeGetter(OVERRIDE);
+ return sample;
+ }
+
+ private static Entity addGarminSleepStatsSample(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "GarminSleepStatsSample");
+ sample.addImport(MAIN_PACKAGE + ".model.SleepScoreSample");
+ addCommonTimeSampleProperties("SleepScoreSample", sample, user, device);
+ sample.addIntProperty("sleepScore").notNull().codeBeforeGetter(OVERRIDE);
+ return sample;
+ }
+
+ private static Entity addGarminIntensityMinutesSample(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "GarminIntensityMinutesSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
+ sample.addIntProperty("moderate");
+ sample.addIntProperty("vigorous");
+ return sample;
+ }
+
+ private static Entity addPendingFile(Schema schema, Entity user, Entity device) {
+ Entity pendingFile = addEntity(schema, "PendingFile");
+ pendingFile.setJavaDoc(
+ "This class represents a file that was fetched from the device and is pending processing."
+ );
+
+ // We need a single-column primary key so that we can delete records
+ pendingFile.addIdProperty().autoincrement();
+
+ Property path = pendingFile.addStringProperty("path").notNull().getProperty();
+ Property deviceId = pendingFile.addLongProperty("deviceId").notNull().getProperty();
+ pendingFile.addToOne(device, deviceId);
+
+ final Index indexUnique = new Index();
+ indexUnique.addProperty(deviceId);
+ indexUnique.addProperty(path);
+ indexUnique.makeUnique();
+ pendingFile.addIndex(indexUnique);
+
+ return pendingFile;
+ }
+
+ private static Entity addWatchXPlusHealthActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "WatchXPlusActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addByteArrayProperty("rawWatchXPlusHealthData");
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().primaryKey();
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty("distance");
+ activitySample.addIntProperty("calories");
+ return activitySample;
+ }
+
+ private static Entity addWatchXPlusHealthActivityKindOverlay(Schema schema, Entity user, Entity device) {
+ Entity activityOverlay = addEntity(schema, "WatchXPlusHealthActivityOverlay");
+
+ activityOverlay.addIntProperty(TIMESTAMP_FROM).notNull().primaryKey();
+ activityOverlay.addIntProperty(TIMESTAMP_TO).notNull().primaryKey();
+ activityOverlay.addIntProperty(SAMPLE_RAW_KIND).notNull().primaryKey();
+ Property deviceId = activityOverlay.addLongProperty("deviceId").primaryKey().notNull().getProperty();
+ activityOverlay.addToOne(device, deviceId);
+
+ Property userId = activityOverlay.addLongProperty("userId").notNull().getProperty();
+ activityOverlay.addToOne(user, userId);
+ activityOverlay.addByteArrayProperty("rawWatchXPlusHealthData");
+
+ return activityOverlay;
+ }
+
+ private static Entity addTLW64ActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "TLW64ActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ return activitySample;
+ }
+
+ private static Entity addSonySWR12Sample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "SonySWR12Sample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ return activitySample;
+ }
+
+ private static Entity addCasioGBX100Sample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "CasioGBX100ActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractGBX100ActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty("calories").notNull();
+ return activitySample;
+ }
+
+ private static Entity addLefunActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "LefunActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty("distance").notNull();
+ activitySample.addIntProperty("calories").notNull();
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
+ private static Entity addLefunBiometricSample(Schema schema, Entity user, Entity device) {
+ Entity biometricSample = addEntity(schema, "LefunBiometricSample");
+ biometricSample.implementsSerializable();
+
+ biometricSample.addIntProperty("timestamp").notNull().primaryKey();
+ Property deviceId = biometricSample.addLongProperty("deviceId").primaryKey().notNull().getProperty();
+ biometricSample.addToOne(device, deviceId);
+ Property userId = biometricSample.addLongProperty("userId").notNull().getProperty();
+ biometricSample.addToOne(user, userId);
+
+ biometricSample.addIntProperty("type").notNull();
+ biometricSample.addIntProperty("value1").notNull();
+ biometricSample.addIntProperty("value2");
+ return biometricSample;
+ }
+
+ private static Entity addLefunSleepSample(Schema schema, Entity user, Entity device) {
+ Entity sleepSample = addEntity(schema, "LefunSleepSample");
+ sleepSample.implementsSerializable();
+
+ sleepSample.addIntProperty("timestamp").notNull().primaryKey();
+ Property deviceId = sleepSample.addLongProperty("deviceId").primaryKey().notNull().getProperty();
+ sleepSample.addToOne(device, deviceId);
+ Property userId = sleepSample.addLongProperty("userId").notNull().getProperty();
+ sleepSample.addToOne(user, userId);
+
+ sleepSample.addIntProperty("type").notNull();
+ return sleepSample;
+ }
+
+ private static Entity addBangleJSActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "BangleJSActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
+ private static void addCommonActivitySampleProperties(String superClass, Entity activitySample, Entity user, Entity device) {
+ activitySample.setSuperclass(superClass);
+ activitySample.addImport(MAIN_PACKAGE + ".devices.SampleProvider");
+ activitySample.setJavaDoc(
+ "This class represents a sample specific to the device. Values like activity kind or\n" +
+ "intensity, are device specific. Normalized values can be retrieved through the\n" +
+ "corresponding {@link SampleProvider}.");
+ activitySample.addIntProperty("timestamp").notNull().codeBeforeGetterAndSetter(OVERRIDE).primaryKey();
+ Property deviceId = activitySample.addLongProperty("deviceId").primaryKey().notNull().codeBeforeGetterAndSetter(OVERRIDE).getProperty();
+ activitySample.addToOne(device, deviceId);
+ Property userId = activitySample.addLongProperty("userId").notNull().codeBeforeGetterAndSetter(OVERRIDE).getProperty();
+ activitySample.addToOne(user, userId);
+ }
+
+ private static void addCommonTimeSampleProperties(String superClass, Entity timeSample, Entity user, Entity device) {
+ timeSample.setSuperclass(superClass);
+ timeSample.addImport(MAIN_PACKAGE + ".devices.TimeSampleProvider");
+ timeSample.setJavaDoc(
+ "This class represents a sample specific to the device. Values might be device specific, depending on the sample type.\n" +
+ "Normalized values can be retrieved through the corresponding {@link TimeSampleProvider}.");
+ timeSample.addLongProperty("timestamp").notNull().codeBeforeGetterAndSetter(OVERRIDE).primaryKey();
+ Property deviceId = timeSample.addLongProperty("deviceId").primaryKey().notNull().codeBeforeGetterAndSetter(OVERRIDE).getProperty();
+ timeSample.addToOne(device, deviceId);
+ Property userId = timeSample.addLongProperty("userId").notNull().codeBeforeGetterAndSetter(OVERRIDE).getProperty();
+ timeSample.addToOne(user, userId);
+ }
+
+ private static void addCalendarSyncState(Schema schema, Entity device) {
+ Entity calendarSyncState = addEntity(schema, "CalendarSyncState");
+ calendarSyncState.addIdProperty();
+ Property deviceId = calendarSyncState.addLongProperty("deviceId").notNull().getProperty();
+ Property calendarEntryId = calendarSyncState.addLongProperty("calendarEntryId").notNull().getProperty();
+ Index indexUnique = new Index();
+ indexUnique.addProperty(deviceId);
+ indexUnique.addProperty(calendarEntryId);
+ indexUnique.makeUnique();
+ calendarSyncState.addIndex(indexUnique);
+ calendarSyncState.addToOne(device, deviceId);
+ calendarSyncState.addIntProperty("hash").notNull();
+ }
+
+ private static void addAlarms(Schema schema, Entity user, Entity device) {
+ Entity alarm = addEntity(schema, "Alarm");
+ alarm.implementsInterface("nodomain.freeyourgadget.gadgetbridge.model.Alarm");
+ Property deviceId = alarm.addLongProperty("deviceId").notNull().getProperty();
+ Property userId = alarm.addLongProperty("userId").notNull().getProperty();
+ Property position = alarm.addIntProperty("position").notNull().getProperty();
+ Index indexUnique = new Index();
+ indexUnique.addProperty(deviceId);
+ indexUnique.addProperty(userId);
+ indexUnique.addProperty(position);
+ indexUnique.makeUnique();
+ alarm.addIndex(indexUnique);
+ alarm.addBooleanProperty("enabled").notNull();
+ alarm.addBooleanProperty("smartWakeup").notNull();
+ alarm.addIntProperty("smartWakeupInterval");
+ alarm.addBooleanProperty("snooze").notNull();
+ alarm.addIntProperty("repetition").notNull().codeBeforeGetter(
+ "public boolean isRepetitive() { return getRepetition() != ALARM_ONCE; } " +
+ "public boolean getRepetition(int dow) { return (this.repetition & dow) > 0; }"
+ );
+ alarm.addIntProperty("hour").notNull();
+ alarm.addIntProperty("minute").notNull();
+ alarm.addBooleanProperty("unused").notNull();
+ alarm.addStringProperty("title");
+ alarm.addStringProperty("description");
+ alarm.addToOne(user, userId);
+ alarm.addToOne(device, deviceId);
+ }
+
+ private static void addReminders(Schema schema, Entity user, Entity device) {
+ Entity reminder = addEntity(schema, "Reminder");
+ reminder.implementsInterface("nodomain.freeyourgadget.gadgetbridge.model.Reminder");
+ Property deviceId = reminder.addLongProperty("deviceId").notNull().getProperty();
+ Property userId = reminder.addLongProperty("userId").notNull().getProperty();
+ Property reminderId = reminder.addStringProperty("reminderId").notNull().primaryKey().getProperty();
+ Index indexUnique = new Index();
+ indexUnique.addProperty(deviceId);
+ indexUnique.addProperty(userId);
+ indexUnique.addProperty(reminderId);
+ indexUnique.makeUnique();
+ reminder.addIndex(indexUnique);
+ reminder.addStringProperty("message").notNull();
+ reminder.addDateProperty("date").notNull();
+ reminder.addIntProperty("repetition").notNull();
+ reminder.addToOne(user, userId);
+ reminder.addToOne(device, deviceId);
+ }
+
+ private static void addWorldClocks(Schema schema, Entity user, Entity device) {
+ Entity worldClock = addEntity(schema, "WorldClock");
+ worldClock.implementsInterface("nodomain.freeyourgadget.gadgetbridge.model.WorldClock");
+ Property deviceId = worldClock.addLongProperty("deviceId").notNull().getProperty();
+ Property userId = worldClock.addLongProperty("userId").notNull().getProperty();
+ Property worldClockId = worldClock.addStringProperty("worldClockId").notNull().primaryKey().getProperty();
+ Index indexUnique = new Index();
+ indexUnique.addProperty(deviceId);
+ indexUnique.addProperty(userId);
+ indexUnique.addProperty(worldClockId);
+ indexUnique.makeUnique();
+ worldClock.addIndex(indexUnique);
+ worldClock.addStringProperty("label").notNull();
+ worldClock.addBooleanProperty("enabled");
+ worldClock.addStringProperty("code");
+ worldClock.addStringProperty("timeZoneId").notNull();
+ worldClock.addToOne(user, userId);
+ worldClock.addToOne(device, deviceId);
+ }
+
+ private static void addContacts(Schema schema, Entity user, Entity device) {
+ Entity contact = addEntity(schema, "Contact");
+ contact.implementsInterface("nodomain.freeyourgadget.gadgetbridge.model.Contact");
+ Property deviceId = contact.addLongProperty("deviceId").notNull().getProperty();
+ Property userId = contact.addLongProperty("userId").notNull().getProperty();
+ Property contactId = contact.addStringProperty("contactId").notNull().primaryKey().getProperty();
+ Index indexUnique = new Index();
+ indexUnique.addProperty(deviceId);
+ indexUnique.addProperty(userId);
+ indexUnique.addProperty(contactId);
+ indexUnique.makeUnique();
+ contact.addIndex(indexUnique);
+ contact.addStringProperty("name").notNull();
+ contact.addStringProperty("number").notNull();
+ contact.addToOne(user, userId);
+ contact.addToOne(device, deviceId);
+ }
+
+ private static void addNotificationFilterEntry(Schema schema, Entity notificationFilterEntity) {
+ Entity notificatonFilterEntry = addEntity(schema, "NotificationFilterEntry");
+ notificatonFilterEntry.addIdProperty().autoincrement();
+ Property notificationFilterId = notificatonFilterEntry.addLongProperty("notificationFilterId").notNull().getProperty();
+ notificatonFilterEntry.addStringProperty("notificationFilterContent").notNull().getProperty();
+ notificatonFilterEntry.addToOne(notificationFilterEntity, notificationFilterId);
+ }
+
+ private static Entity addNotificationFilters(Schema schema) {
+ Entity notificatonFilter = addEntity(schema, "NotificationFilter");
+ Property appIdentifier = notificatonFilter.addStringProperty("appIdentifier").notNull().getProperty();
+
+ notificatonFilter.addIdProperty().autoincrement();
+
+ Index indexUnique = new Index();
+ indexUnique.addProperty(appIdentifier);
+ indexUnique.makeUnique();
+ notificatonFilter.addIndex(indexUnique);
+
+ Property notificationFilterMode = notificatonFilter.addIntProperty("notificationFilterMode").notNull().getProperty();
+ Property notificationFilterSubMode = notificatonFilter.addIntProperty("notificationFilterSubMode").notNull().getProperty();
+ return notificatonFilter;
+ }
+
+ private static void addActivitySummary(Schema schema, Entity user, Entity device) {
+ Entity summary = addEntity(schema, "BaseActivitySummary");
+ summary.implementsInterface(ACTIVITY_SUMMARY);
+ summary.addIdProperty();
+
+ summary.setJavaDoc(
+ "This class represents the summary of a user's activity event. I.e. a walk, hike, a bicycle tour, etc.");
+
+ summary.addStringProperty("name").codeBeforeGetter(OVERRIDE);
+ summary.addDateProperty("startTime").notNull().codeBeforeGetter(OVERRIDE);
+ summary.addDateProperty("endTime").notNull().codeBeforeGetter(OVERRIDE);
+ summary.addIntProperty("activityKind").notNull().codeBeforeGetter(OVERRIDE);
+
+ summary.addIntProperty("baseLongitude").javaDocGetterAndSetter("Temporary, bip-specific");
+ summary.addIntProperty("baseLatitude").javaDocGetterAndSetter("Temporary, bip-specific");
+ summary.addIntProperty("baseAltitude").javaDocGetterAndSetter("Temporary, bip-specific");
+
+ summary.addStringProperty("gpxTrack").codeBeforeGetter(OVERRIDE);
+ summary.addStringProperty("rawDetailsPath");
+
+ Property deviceId = summary.addLongProperty("deviceId").notNull().codeBeforeGetter(OVERRIDE).getProperty();
+ summary.addToOne(device, deviceId);
+ Property userId = summary.addLongProperty("userId").notNull().codeBeforeGetter(OVERRIDE).getProperty();
+ summary.addToOne(user, userId);
+ summary.addStringProperty("summaryData");
+ summary.addByteArrayProperty("rawSummaryData");
+ }
+
+ private static Property findProperty(Entity entity, String propertyName) {
+ for (Property prop : entity.getProperties()) {
+ if (propertyName.equals(prop.getPropertyName())) {
+ return prop;
+ }
+ }
+ throw new IllegalArgumentException("Property " + propertyName + " not found in Entity " + entity.getClassName());
+ }
+
+ private static Entity addEntity(Schema schema, String className) {
+ Entity entity = schema.addEntity(className);
+ entity.addImport("de.greenrobot.dao.AbstractDao");
+ return entity;
+ }
+
+ private static Entity addBatteryLevel(Schema schema, Entity device) {
+ Entity batteryLevel = addEntity(schema, "BatteryLevel");
+ batteryLevel.implementsSerializable();
+ batteryLevel.addIntProperty("timestamp").notNull().primaryKey();
+ Property deviceId = batteryLevel.addLongProperty("deviceId").primaryKey().notNull().getProperty();
+ batteryLevel.addToOne(device, deviceId);
+ batteryLevel.addIntProperty("level").notNull();
+ batteryLevel.addIntProperty("batteryIndex").notNull().primaryKey();
+ return batteryLevel;
+ }
+
+ private static Entity addFitProActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "FitProActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractFitProActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty("caloriesBurnt");
+ activitySample.addIntProperty("distanceMeters");
+ activitySample.addIntProperty("spo2Percent");
+ activitySample.addIntProperty("pressureLowMmHg");
+ activitySample.addIntProperty("pressureHighMmHg");
+ activitySample.addIntProperty("activeTimeMinutes");
+ return activitySample;
+ }
+
+ private static Entity addPineTimeActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "PineTimeActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
+
+ private static Entity addWithingsSteelHRActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "WithingsSteelHRActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty("duration").notNull();
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty("distance").notNull();
+ activitySample.addIntProperty("calories").notNull();
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ return activitySample;
+ }
+
+ private static Entity addWena3BehaviorSample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "Wena3BehaviorSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", activitySample, user, device);
+
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull();
+ activitySample.addLongProperty(TIMESTAMP_FROM).notNull();
+ activitySample.addLongProperty(TIMESTAMP_TO).notNull();
+ return activitySample;
+ }
+
+ private static Entity addWena3Vo2Sample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "Wena3Vo2Sample");
+ addCommonTimeSampleProperties("AbstractTimeSample", activitySample, user, device);
+ activitySample.addIntProperty("vo2").notNull();
+ activitySample.addIntProperty("datapoint").notNull().primaryKey();
+ return activitySample;
+ }
+
+ private static Entity addWena3StressSample(Schema schema, Entity user, Entity device) {
+ Entity stressSample = addEntity(schema, "Wena3StressSample");
+ addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
+ stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
+ stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ return stressSample;
+ }
+
+ private static Entity addWena3ActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "Wena3ActivitySample");
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull();
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
+ private static Entity addWena3HeartRateSample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "Wena3HeartRateSample");
+ addCommonTimeSampleProperties("AbstractHeartRateSample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_HEART_RATE).notNull();
+ return activitySample;
+ }
+
+ private static Entity addWena3EnergySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "Wena3EnergySample");
+ addCommonTimeSampleProperties("AbstractTimeSample", activitySample, user, device);
+ activitySample.addIntProperty("energy").notNull();
+ return activitySample;
+ }
+
+ private static Entity addWena3CaloriesSample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "Wena3CaloriesSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", activitySample, user, device);
+ activitySample.addIntProperty("calories").notNull();
+ return activitySample;
+ }
+
+ private static Entity addAppSpecificNotificationSettings(Schema schema, Entity device) {
+ Entity perAppSetting = addEntity(schema, "AppSpecificNotificationSetting");
+ perAppSetting.addStringProperty("packageId").notNull().primaryKey();
+ Property deviceId = perAppSetting.addLongProperty("deviceId").primaryKey().notNull().getProperty();
+ perAppSetting.addToOne(device, deviceId);
+ perAppSetting.addStringProperty("ledPattern");
+ perAppSetting.addStringProperty("vibrationPattern");
+ perAppSetting.addStringProperty("vibrationRepetition");
+ return perAppSetting;
+ }
+
+ private static Entity addHuaweiActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "HuaweiActivitySample");
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty("otherTimestamp").notNull().primaryKey();
+ activitySample.addByteProperty("source").notNull().primaryKey();
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty("calories").notNull().codeBeforeGetter(
+ "@Override\n" +
+ " public int getActiveCalories() {\n" +
+ " return getCalories();\n" +
+ " }\n"
+ );
+ activitySample.addIntProperty("distance").notNull().codeBeforeGetter(
+ "@Override\n" +
+ " public int getDistanceCm() {\n" +
+ " return getDistance() == HuaweiActivitySample.NOT_MEASURED ? HuaweiActivitySample.NOT_MEASURED : getDistance() * 100;\n" +
+ " }\n"
+ );
+ activitySample.addIntProperty("spo").notNull();
+ activitySample.addIntProperty("heartRate").notNull();
+ return activitySample;
+ }
+
+ private static Entity addHuaweiWorkoutSummarySample(Schema schema, Entity user, Entity device) {
+ Entity workoutSummary = addEntity(schema, "HuaweiWorkoutSummarySample");
+
+ workoutSummary.setJavaDoc("Contains Huawei Workout Summary samples (one per workout)");
+
+ workoutSummary.addLongProperty("workoutId").primaryKey().autoincrement();
+
+ Property deviceId = workoutSummary.addLongProperty("deviceId").notNull().getProperty();
+ workoutSummary.addToOne(device, deviceId);
+ Property userId = workoutSummary.addLongProperty("userId").notNull().getProperty();
+ workoutSummary.addToOne(user, userId);
+
+ workoutSummary.addShortProperty("workoutNumber").notNull();
+ workoutSummary.addByteProperty("status").notNull();
+ workoutSummary.addIntProperty("startTimestamp").notNull();
+ workoutSummary.addIntProperty("endTimestamp").notNull();
+ workoutSummary.addIntProperty("calories").notNull();
+ workoutSummary.addIntProperty("distance").notNull();
+ workoutSummary.addIntProperty("stepCount").notNull();
+ workoutSummary.addIntProperty("totalTime").notNull();
+ workoutSummary.addIntProperty("duration").notNull();
+ workoutSummary.addByteProperty("type").notNull();
+ workoutSummary.addShortProperty("strokes").notNull();
+ workoutSummary.addShortProperty("avgStrokeRate").notNull();
+ workoutSummary.addShortProperty("poolLength").notNull();
+ workoutSummary.addShortProperty("laps").notNull();
+ workoutSummary.addShortProperty("avgSwolf").notNull();
+
+ workoutSummary.addByteArrayProperty("rawData");
+
+ workoutSummary.addStringProperty("gpxFileLocation");
+
+ workoutSummary.addIntProperty("maxAltitude");
+ workoutSummary.addIntProperty("minAltitude");
+ workoutSummary.addIntProperty("elevationGain");
+ workoutSummary.addIntProperty("elevationLoss");
+
+ workoutSummary.addIntProperty("workoutLoad").notNull();
+ workoutSummary.addIntProperty("workoutAerobicEffect").notNull();
+ workoutSummary.addByteProperty("workoutAnaerobicEffect").notNull();
+ workoutSummary.addShortProperty("recoveryTime").notNull();
+
+ workoutSummary.addByteProperty("minHeartRatePeak").notNull();
+ workoutSummary.addByteProperty("maxHeartRatePeak").notNull();
+
+ workoutSummary.addByteArrayProperty("recoveryHeartRates");
+
+ workoutSummary.addByteProperty("swimType").notNull();
+
+ workoutSummary.addIntProperty("maxMET").notNull();
+
+ workoutSummary.addByteProperty("hrZoneType").notNull();
+
+ workoutSummary.addShortProperty("runPaceZone1Min").notNull();
+ workoutSummary.addShortProperty("runPaceZone2Min").notNull();
+ workoutSummary.addShortProperty("runPaceZone3Min").notNull();
+ workoutSummary.addShortProperty("runPaceZone4Min").notNull();
+ workoutSummary.addShortProperty("runPaceZone5Min").notNull();
+ workoutSummary.addShortProperty("runPaceZone5Max").notNull();
+
+ workoutSummary.addShortProperty("runPaceZone1Time").notNull();
+ workoutSummary.addShortProperty("runPaceZone2Time").notNull();
+ workoutSummary.addShortProperty("runPaceZone3Time").notNull();
+ workoutSummary.addShortProperty("runPaceZone4Time").notNull();
+ workoutSummary.addShortProperty("runPaceZone5Time").notNull();
+
+ workoutSummary.addByteProperty("algType").notNull();
+ workoutSummary.addIntProperty("trainingPoints").notNull();
+
+ return workoutSummary;
+ }
+
+ private static Entity addHuaweiWorkoutDataSample(Schema schema, Entity summaryEntity) {
+ Entity workoutDataSample = addEntity(schema, "HuaweiWorkoutDataSample");
+
+ workoutDataSample.setJavaDoc("Contains Huawei Workout data samples (multiple per workout)");
+
+ Property id = workoutDataSample.addLongProperty("workoutId").primaryKey().notNull().getProperty();
+ workoutDataSample.addToOne(summaryEntity, id);
+
+ workoutDataSample.addIntProperty("timestamp").notNull().primaryKey();
+ workoutDataSample.addByteProperty("heartRate").notNull();
+ workoutDataSample.addShortProperty("speed").notNull();
+ workoutDataSample.addByteProperty("stepRate").notNull();
+ workoutDataSample.addShortProperty("cadence").notNull();
+ workoutDataSample.addShortProperty("stepLength").notNull();
+ workoutDataSample.addShortProperty("groundContactTime").notNull();
+ workoutDataSample.addByteProperty("impact").notNull();
+ workoutDataSample.addShortProperty("swingAngle").notNull();
+ workoutDataSample.addByteProperty("foreFootLanding").notNull();
+ workoutDataSample.addByteProperty("midFootLanding").notNull();
+ workoutDataSample.addByteProperty("backFootLanding").notNull();
+ workoutDataSample.addByteProperty("eversionAngle").notNull();
+ workoutDataSample.addShortProperty("swolf").notNull();
+ workoutDataSample.addShortProperty("strokeRate").notNull();
+
+ workoutDataSample.addByteArrayProperty("dataErrorHex");
+
+ workoutDataSample.addShortProperty("calories").notNull();
+ workoutDataSample.addShortProperty("cyclingPower").notNull();
+
+ workoutDataSample.addShortProperty("frequency").notNull();
+ workoutDataSample.addIntProperty("altitude");
+
+ return workoutDataSample;
+ }
+
+ private static Entity addHuaweiWorkoutPaceSample(Schema schema, Entity summaryEntity) {
+ Entity workoutPaceSample = addEntity(schema, "HuaweiWorkoutPaceSample");
+
+ workoutPaceSample.setJavaDoc("Contains Huawei Workout pace data samples (one per workout)");
+
+ Property id = workoutPaceSample.addLongProperty("workoutId").primaryKey().notNull().getProperty();
+ workoutPaceSample.addToOne(summaryEntity, id);
+
+ workoutPaceSample.addIntProperty("paceIndex").notNull().primaryKey();
+ workoutPaceSample.addIntProperty("distance").notNull().primaryKey();
+ workoutPaceSample.addByteProperty("type").notNull().primaryKey();
+ workoutPaceSample.addIntProperty("pace").notNull();
+ workoutPaceSample.addIntProperty("pointIndex").notNull();
+ workoutPaceSample.addIntProperty("correction");
+
+ return workoutPaceSample;
+ }
+
+ private static Entity addHuaweiWorkoutSwimSegmentsSample(Schema schema, Entity summaryEntity) {
+ Entity workoutSwimSegmentsSample = addEntity(schema, "HuaweiWorkoutSwimSegmentsSample");
+
+ workoutSwimSegmentsSample.setJavaDoc("Contains Huawei Workout swim segments data samples");
+
+ Property id = workoutSwimSegmentsSample.addLongProperty("workoutId").primaryKey().notNull().getProperty();
+ workoutSwimSegmentsSample.addToOne(summaryEntity, id);
+
+ workoutSwimSegmentsSample.addIntProperty("segmentIndex").notNull().primaryKey();
+ workoutSwimSegmentsSample.addIntProperty("distance").notNull().primaryKey();
+ workoutSwimSegmentsSample.addByteProperty("type").notNull().primaryKey();
+ workoutSwimSegmentsSample.addIntProperty("pace").notNull();
+ workoutSwimSegmentsSample.addIntProperty("pointIndex").notNull();
+ workoutSwimSegmentsSample.addIntProperty("segment").notNull();
+ workoutSwimSegmentsSample.addByteProperty("swimType").notNull();
+ workoutSwimSegmentsSample.addIntProperty("strokes").notNull();
+ workoutSwimSegmentsSample.addIntProperty("avgSwolf").notNull();
+ workoutSwimSegmentsSample.addIntProperty("time").notNull();
+
+ return workoutSwimSegmentsSample;
+ }
+
+ private static Entity addHuaweiDictData(Schema schema, Entity user, Entity device) {
+ Entity dictData = addEntity(schema, "HuaweiDictData");
+
+ dictData.setJavaDoc("Contains Huawei Dict Data");
+
+ dictData.addLongProperty("dictId").primaryKey().autoincrement();
+
+ Property deviceId = dictData.addLongProperty("deviceId").notNull().getProperty();
+ dictData.addToOne(device, deviceId);
+ Property userId = dictData.addLongProperty("userId").notNull().getProperty();
+ dictData.addToOne(user, userId);
+
+ dictData.addIntProperty("dictClass").notNull();
+ dictData.addLongProperty("startTimestamp").notNull();
+ dictData.addLongProperty("endTimestamp");
+ dictData.addLongProperty("modifyTimestamp");
+
+ return dictData;
+ }
+
+ private static Entity addHuaweiDictDataValues(Schema schema, Entity summaryEntity) {
+ Entity dictDataValues = addEntity(schema, "HuaweiDictDataValues");
+
+ dictDataValues.setJavaDoc("Contains Huawei Dict data values");
+
+ Property id = dictDataValues.addLongProperty("dictId").primaryKey().notNull().getProperty();
+ dictDataValues.addToOne(summaryEntity, id);
+
+ dictDataValues.addIntProperty("dictType").notNull().primaryKey();
+ dictDataValues.addByteProperty("tag").notNull().primaryKey();
+ dictDataValues.addByteArrayProperty("value");
+
+ return dictDataValues;
+ }
+
+
+ private static void addTemperatureProperties(Entity activitySample) {
+ activitySample.addFloatProperty(SAMPLE_TEMPERATURE).notNull().codeBeforeGetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_TEMPERATURE_TYPE).notNull().codeBeforeGetter(OVERRIDE);
+ }
+
+ private static Entity addFemometerVinca2TemperatureSample(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "FemometerVinca2TemperatureSample");
+ addCommonTimeSampleProperties("AbstractTemperatureSample", sample, user, device);
+ addTemperatureProperties(sample);
+ return sample;
+ }
+
+ private static Entity addMiScaleWeightSample(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "MiScaleWeightSample");
+ addCommonTimeSampleProperties("AbstractWeightSample", sample, user, device);
+ sample.addFloatProperty(SAMPLE_WEIGHT_KG).notNull().codeBeforeGetter(OVERRIDE);
+ return sample;
+ }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..dbbe355
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program 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.
+
+ This program 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 <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<http://www.gnu.org/licenses/>.
diff --git a/LICENSE.artwork b/LICENSE.artwork
new file mode 100644
index 0000000..71e1fe2
--- /dev/null
+++ b/LICENSE.artwork
@@ -0,0 +1,36 @@
+The following artwork is licensed under the following licenses
+
+Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0):
+ ic_launcher.png
+ ic_device_pebble.png
+ ic_device_miband.png
+ ic_device_lovetoy.png
+ ic_device_hplus.png
+ ic_device_default.png
+ ic_activitytracker.png
+ ic_watchface.png
+ ic_languagepack.png
+ ic_firmware.png
+ ic_watchapp.png
+ ic_systemapp.png
+ icon.png (fastlane metadata directories)
+ featureGraphic.png (fastlane metadata directories)
+(All of the above by xphnx)
+
+Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0):
+ "GET IT ON F-Droid" button by Laura Kalbag. Source: https://ind.ie/about/blog/f-droid-button/
+
+Creative Commons Attribution 3.0 Unported license (CC BY-3.0):
+ ic_notification_battery_low.png by Picol.org. Source: https://commons.wikimedia.org/wiki/File:Battery_1_Picol_icon.svg
+
+Creative Commons Attribution 3.0 United States (CC BY-3.0 US):
+ ic_donate by Peter van Driel https://thenounproject.com/term/donate/239009/
+
+Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0):
+ ic_device_amazfit_bip by Michael quelbs@gmail.com
+
+MIT License as per https://github.com/flipperdevices/Flipper-iOS-App/blob/dev/LICENSE
+ Flipper device icons, Source SVG https://raw.githubusercontent.com/flipperdevices/Flipper-iOS-App/main/Flipper/Shared/Assets.xcassets/Device/FlipperBlack.imageset/FlipperBlack.svg
+
+Apache License Version 2.0 as per https://github.com/google/material-design-icons/blob/master/LICENSE
+ Material Symbols & Icons. Source: https://github.com/google/material-design-icons
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..6d1ae31
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,303 @@
+import java.nio.file.Files
+
+apply plugin: "com.android.application"
+apply plugin: 'com.google.protobuf'
+
+tasks.withType(Test) {
+ systemProperty "MiFirmwareDir", System.getProperty("MiFirmwareDir", null)
+ systemProperty "logback.configurationFile", System.getProperty("user.dir", null) + "/app/src/main/assets/logback.xml"
+ systemProperty "GB_LOGFILES_DIR", Files.createTempDirectory("gblog").toString()
+}
+
+def getVersionCode = { ->
+ try {
+ def commitCount = providers.exec {
+ commandLine('git', 'rev-list', 'HEAD', '--count')
+ }.standardOutput.asText.get().trim()
+ return Integer.valueOf(commitCount)
+ } catch (ignored) {
+ return null
+ }
+}
+
+def buildGitChangelog = {
+ def allCommits = providers.exec {
+ commandLine('git', 'log', '--pretty=format:%h %s')
+ }.standardOutput.asText.get()
+
+ def commitVersionCode = getVersionCode()
+ def includedCommits = 0
+ def changelogNode = new Node(null, 'changelog')
+
+ allCommits.trim().eachLine { line ->
+ if (includedCommits > 100) {
+ return true;
+ }
+
+ def (commitHash, commitMessage) = line.split(" ", 2)
+ if (commitMessage.contains("Translated using Weblate")) {
+ return true;
+ }
+
+ def releaseNode = new Node(changelogNode, 'release', [version: commitHash, versioncode: commitVersionCode--])
+ def _ = new Node(releaseNode, 'change', [:], commitMessage)
+ includedCommits++
+ }
+
+ def changelogFile = new File("${project.rootDir}/app/build/generated/res/changelog/xml/changelog_git.xml")
+ changelogFile.getParentFile().mkdirs()
+ changelogFile.write(groovy.xml.XmlUtil.serialize(changelogNode))
+}
+
+def getGitHashShort = { ->
+ try {
+ return providers.exec {
+ commandLine('git', 'rev-parse', '--short', 'HEAD')
+ }.standardOutput.asText.get().trim()
+ } catch (ignored) {
+ return null
+ }
+}
+
+android {
+ compileOptions {
+ coreLibraryDesugaringEnabled true
+
+ // for Android 5+
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ namespace 'nodomain.freeyourgadget.gadgetbridge'
+
+ defaultConfig {
+ applicationId "com.jsbogang.womp.android"
+
+ //noinspection OldTargetApi
+ targetSdkVersion 34
+ compileSdk 34
+ minSdkVersion 21
+
+ // Note: always bump BOTH versionCode and versionName!
+ versionName "0.83.0"
+ versionCode 235
+ vectorDrawables.useSupportLibrary = true
+ buildConfigField "String", "GIT_HASH_SHORT", "\"${getGitHashShort()}\""
+ buildConfigField "boolean", "INTERNET_ACCESS", "true"
+ }
+
+ signingConfigs {
+ nightly {
+ if (System.getProperty("nightly_store_file") != null) {
+ storeFile file(System.getProperty("nightly_store_file"))
+ storePassword System.getProperty("nightly_store_password")
+ keyAlias System.getProperty("nightly_key_alias")
+ keyPassword System.getProperty("nightly_key_password")
+ }
+ }
+ }
+
+ flavorDimensions "device_type"
+ productFlavors {
+ mainline {
+ // Ensure that when starting from scratch, 'mainline' is selected, not 'banglejs'
+ getIsDefault().set(true)
+ // the default build product flavor
+ dimension "device_type"
+ //applicationIdSuffix ""
+ //versionNameSuffix ""
+ }
+
+ banglejs {
+ dimension "device_type"
+ applicationId "com.espruino.gadgetbridge"
+ applicationIdSuffix ".banglejs"
+ versionNameSuffix "-banglejs"
+ buildConfigField "boolean", "INTERNET_ACCESS", "true"
+ targetSdkVersion 34
+ // Note: app/src/banglejs/AndroidManifest.xml contains some extra permissions
+ }
+ }
+
+ sourceSets {
+ main {
+ res.srcDirs += "build/generated/res/changelog"
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled true
+ //proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
+ }
+
+ nightly {
+ applicationIdSuffix ".nightly"
+ versionNameSuffix "-${getGitHashShort}"
+ //proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
+ minifyEnabled true
+ debuggable false
+
+ if (System.getProperty("nightly_store_file") != null) {
+ signingConfig signingConfigs.nightly
+ } else {
+ signingConfig signingConfigs.debug
+ }
+ }
+
+ nopebble {
+ applicationIdSuffix ".nightly_nopebble"
+ versionNameSuffix "-${getGitHashShort}"
+ //proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
+ minifyEnabled true
+ debuggable false
+
+ if (System.getProperty("nightly_store_file") != null) {
+ signingConfig signingConfigs.nightly
+ } else {
+ signingConfig signingConfigs.debug
+ }
+ }
+
+ applicationVariants.all { variant ->
+ variant.resValue "string", "applicationId", variant.applicationId
+ buildGitChangelog()
+
+ if (variant.buildType.name == 'nightly' || variant.buildType.name == 'nopebble') {
+ variant.outputs.all {
+ setVersionCodeOverride(getVersionCode())
+ //setVersionNameOverride(getGitHashShort())
+ setVersionNameOverride(variant.versionName)
+ outputFileName = "${applicationId}_${variant.versionName}.apk"
+ }
+ }
+ }
+ }
+
+ lint {
+ abortOnError true
+ lintConfig file("$rootDir/app/src/main/lint.xml")
+ // If true, generate an HTML report (with issue explanations, sourcecode, etc)
+ htmlReport true
+ // Optional path to report (default will be lint-results.html in the builddir)
+ htmlOutput file("$project.buildDir/reports/lint/lint.html")
+ // Ignore checks present in the snapshot
+ baseline file("lint-baseline.xml")
+ }
+
+ testOptions {
+ unitTests {
+ returnDefaultValues = true
+ includeAndroidResources = true
+ }
+ }
+ buildFeatures {
+ aidl true
+ buildConfig true
+ }
+}
+
+dependencies {
+ coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.3'
+
+ implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
+ implementation "androidx.camera:camera-core:1.4.1"
+ implementation "androidx.camera:camera-camera2:1.4.1"
+ implementation 'androidx.camera:camera-view:1.4.1'
+ implementation 'androidx.camera:camera-lifecycle:1.4.1'
+
+ testImplementation "junit:junit:4.13.2"
+ testImplementation "org.mockito:mockito-core:5.14.2"
+ testImplementation "org.robolectric:robolectric:4.14.1"
+ testImplementation "org.hamcrest:hamcrest-library:3.0"
+
+ implementation "androidx.appcompat:appcompat:1.7.0"
+ implementation "androidx.preference:preference:1.2.1"
+ implementation "androidx.cardview:cardview:1.0.0"
+ implementation "androidx.recyclerview:recyclerview:1.3.2"
+ implementation "androidx.legacy:legacy-support-v4:1.0.0"
+ implementation "androidx.gridlayout:gridlayout:1.0.0"
+ implementation "androidx.palette:palette:1.0.0"
+ implementation "androidx.activity:activity:1.9.3"
+ implementation "androidx.fragment:fragment:1.8.5"
+ implementation "androidx.viewpager2:viewpager2:1.1.0"
+
+ // Not latest version because of https://github.com/material-components/material-components-android/issues/3924
+ implementation "com.google.android.material:material:1.10.0"
+ implementation 'com.google.android.flexbox:flexbox:3.0.0'
+ implementation "com.google.code.gson:gson:2.11.0"
+
+ implementation "no.nordicsemi.android:dfu:2.5.0"
+ implementation "com.github.tony19:logback-android:3.0.0"
+ implementation "org.slf4j:slf4j-api:2.0.16"
+ implementation "com.github.PhilJay:MPAndroidChart:3.1.0"
+ implementation "com.github.pfichtner:durationformatter:0.1.1"
+ implementation "de.cketti.library.changelog:ckchangelog:1.2.2"
+ implementation "net.e175.klaus:solarpositioning:0.1.10"
+ implementation "co.nstant.in:cbor:0.9"
+ // use pristine greendao instead of our custom version, since our custom jitpack-packaged
+ // version contains way too much and our custom patches are in the generator only.
+ implementation "org.greenrobot:greendao:2.2.1"
+ implementation "org.apache.commons:commons-lang3:3.17.0"
+ implementation "org.cyanogenmod:platform.sdk:6.0"
+ implementation 'com.jaredrummler:colorpicker:1.1.0'
+ implementation 'com.github.wax911.android-emojify:contract:1.9.5'
+ implementation 'com.github.wax911.android-emojify:emojify:1.9.5'
+ implementation 'com.github.wax911.android-emojify:gson:1.9.5'
+ implementation 'com.google.protobuf:protobuf-javalite:4.29.1'
+ implementation 'com.android.volley:volley:1.2.1'
+ implementation 'com.google.zxing:core:3.5.3'
+ implementation 'org.msgpack:msgpack-core:0.9.8'
+
+ // Bouncy Castle is included directly in GB, to avoid pulling the entire dependency
+ // It's included in the org.bouncycastle.shaded package, to fix conflicts with roboelectric
+ //implementation 'org.bouncycastle:bcpkix-jdk18on:1.76'
+ //implementation 'org.bouncycastle:bcprov-jdk18on:1.76'
+
+ // Android SDK bundles org.json, but we need an actual implementation to replace the stubs in tests
+ testImplementation 'org.json:json:20240303'
+
+ // Fix Duplicate class build error for conflicting kotlin-stdlib versions
+ // does not seem to be currently needed, as it uses the latest across all transitive
+ //implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0"))
+
+ // Needed for Armenian transliteration
+ implementation 'org.ahocorasick:ahocorasick:0.6.3'
+}
+
+preBuild.dependsOn(":GBDaoGenerator:genSources")
+
+//gradle.beforeProject {
+// preBuild.dependsOn(":GBDaoGenerator:genSources")
+//}
+
+sourceSets {
+ main {
+ java.srcDirs += "${protobuf.generatedFilesBaseDir}"
+ java.srcDirs += "build/generated/source/buildConfig"
+ }
+}
+
+tasks.register('cleanGenerated', Delete) {
+ delete fileTree('src/main/java/nodomain/freeyourgadget/gadgetbridge/entities') {
+ include '**/*.java'
+ exclude '**/Abstract*.java'
+ }
+}
+
+tasks.clean.dependsOn(tasks.cleanGenerated)
+
+protobuf {
+ protoc {
+ artifact = 'com.google.protobuf:protoc:4.29.1'
+ }
+ generateProtoTasks {
+ all().each { task ->
+ task.builtins {
+ java {
+ option 'lite'
+ }
+ }
+ }
+ }
+}
diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
new file mode 100644
index 0000000..f5af860
--- /dev/null
+++ b/app/lint-baseline.xml
@@ -0,0 +1,16179 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 7.4.2" type="baseline" client="gradle" dependencies="false" name="AGP (7.4.2)" variant="all" version="7.4.2">
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" LOG.info("connection attempt detected from " + device.getAddress() + "(" + device.getName() + ")");"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothConnectReceiver.java"
+ line="53"
+ column="84"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" LOG.info("Bond state changed: " + device + ", state: " + device.getBondState() + ", expected address: " + bondingMacAddress);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="96"
+ column="78"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" if (device.createBond()) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="229"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" device.getName(),"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="235"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" device.getType())"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="237"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" BluetoothClass bluetoothClass = device.getBluetoothClass();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="240"
+ column="45"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" if (device.getBondState() == BluetoothDevice.BOND_BONDED) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="250"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" } else if (device.getBondState() == BluetoothDevice.BOND_BONDING) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="253"
+ column="24"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" toast(context.getContext(), context.getContext().getString(R.string.discovery_bonding_failed_immediately, device.getName()), Toast.LENGTH_SHORT, GB.ERROR);"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="258"
+ column="123"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" if (deviceToPair.getBondState() != BluetoothDevice.BOND_BONDED) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="276"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" return (device.getType() == BluetoothDevice.DEVICE_TYPE_DUAL || device.getType() == BluetoothDevice.DEVICE_TYPE_LE) &&"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="292"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" return (device.getType() == BluetoothDevice.DEVICE_TYPE_DUAL || device.getType() == BluetoothDevice.DEVICE_TYPE_LE) &&"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="292"
+ column="73"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" (device.getName().startsWith("Pebble-LE ") || device.getName().startsWith("Pebble Time LE "));"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="293"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" (device.getName().startsWith("Pebble-LE ") || device.getName().startsWith("Pebble Time LE "));"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="293"
+ column="63"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" return device.getType() == BluetoothDevice.DEVICE_TYPE_LE &&"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="300"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" device.getName().startsWith("Pebble ") &&"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="301"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" !device.getName().startsWith("Pebble Time LE ");"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="302"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" int bondState = device.getBondState();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="371"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" GB.toast(bondingInterface.getContext().getString(R.string.pairing_already_bonded, device.getName(), device.getAddress()), Toast.LENGTH_SHORT, GB.INFO);"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="373"
+ column="95"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" GB.toast(bondingInterface.getContext(), bondingInterface.getContext().getString(R.string.pairing_in_progress, device.getName(), device.getAddress()), Toast.LENGTH_LONG, GB.INFO);"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="385"
+ column="123"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" GB.toast(bondingInterface.getContext(), bondingInterface.getContext().getString(R.string.pairing_creating_bond_with, device.getName(), device.getAddress()), Toast.LENGTH_LONG, GB.INFO);"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BondingUtil.java"
+ line="389"
+ column="126"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" ParcelUuid uuids[] = btDevice.getUuids();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btclassic/BtClassicIoThread.java"
+ line="150"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBtSocket = btDevice.createRfcommSocketToServiceRecord(getUuidToConnect(uuids));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btclassic/BtClassicIoThread.java"
+ line="158"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBtSocket.connect();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btclassic/BtClassicIoThread.java"
+ line="159"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothAdapter.cancelDiscovery();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="260"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGattServer = bluetoothManager.openGattServer(mContext, internalGattServerCallback);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="268"
+ column="36"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGattServer.addService(service);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="274"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGatt = remoteDevice.connectGatt(mContext, false, internalGattCallback, BluetoothDevice.TRANSPORT_LE);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="281"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGatt = remoteDevice.connectGatt(mContext, false, internalGattCallback);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="283"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.disconnect();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="308"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.close();"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="309"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gattServer.clearServices();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="316"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gattServer.close();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="317"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" boolean result = mBluetoothGatt.connect();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="359"
+ column="30"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGatt.discoverServices();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="534"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGattServer.sendResponse(device, requestId, success ? BluetoothGatt.GATT_SUCCESS : BluetoothGatt.GATT_FAILURE, 0, new byte[0]);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="777"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGattServer.sendResponse(device, requestId, success ? BluetoothGatt.GATT_SUCCESS : BluetoothGatt.GATT_FAILURE, 0, new byte[0]);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java"
+ line="803"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" if (deviceToPair.getBondState() != BluetoothDevice.BOND_BONDED) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java"
+ line="760"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" deviceToPair.createBond();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java"
+ line="762"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" GBDeviceCandidate candidate = new GBDeviceCandidate(device, GBDevice.RSSI_UNKNOWN, device.getUuids());"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java"
+ line="120"
+ column="92"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" if (device != null && device.getBondState() == BluetoothDevice.BOND_NONE) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandPairingActivity.java"
+ line="172"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" boolean result = gatt.setCharacteristicNotification(getCharacteristic(), enableFlag);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/NotifyAction.java"
+ line="48"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" result = gatt.writeDescriptor(notifyDescriptor);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/NotifyAction.java"
+ line="56"
+ column="30"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" result = gatt.writeDescriptor(notifyDescriptor);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/NotifyAction.java"
+ line="60"
+ column="30"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.discoverServices();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="115"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.requestMtu(339);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="143"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.writeCharacteristic(characteristic);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="200"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.readCharacteristic(characteristic);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="203"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGatt = btDevice.connectGatt(mContext, false, this);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="232"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.writeDescriptor(descriptor);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="239"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.setCharacteristicNotification(gatt.getService(SERVICE_UUID).getCharacteristic(CONNECTIVITY_CHARACTERISTIC), true);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="240"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.writeDescriptor(descriptor);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="247"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.setCharacteristicNotification(gatt.getService(SERVICE_UUID).getCharacteristic(MTU_CHARACTERISTIC), true);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="248"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.writeDescriptor(descriptor);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="255"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.setCharacteristicNotification(gatt.getService(SERVICE_UUID).getCharacteristic(CONNECTION_PARAMETERS_CHARACTERISTIC), true);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="256"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.writeCharacteristic(characteristic);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="264"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.writeDescriptor(descriptor);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="271"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.setCharacteristicNotification(gatt.getService(PPOGATT_SERVICE_UUID).getCharacteristic(PPOGATT_CHARACTERISTIC_READ), true);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="272"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" boolean success = mBluetoothGatt.writeCharacteristic(writeCharacteristics);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="280"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGatt.disconnect();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="295"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGatt.close();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTClient.java"
+ line="296"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGattServer = bluetoothManager.openGattServer(mContext, this);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTServer.java"
+ line="60"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGattServer.addService(pebbleGATTService);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTServer.java"
+ line="72"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" boolean success = mBluetoothGattServer.notifyCharacteristicChanged(mBtDevice, writeCharacteristics, false);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTServer.java"
+ line="81"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" if (!this.mBluetoothGattServer.sendResponse(device, requestId, 0, offset, new byte[]{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1})) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTServer.java"
+ line="105"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" if (!this.mBluetoothGattServer.sendResponse(device, requestId, 0, offset, value)) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTServer.java"
+ line="148"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGattServer.addService(badbadService);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTServer.java"
+ line="158"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGattServer.cancelConnection(mBtDevice);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTServer.java"
+ line="190"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGattServer.clearServices();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTServer.java"
+ line="191"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBluetoothGattServer.close();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/ble/PebbleGATTServer.java"
+ line="192"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" if (btDevice.getType() == BluetoothDevice.DEVICE_TYPE_LE) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleIoThread.java"
+ line="188"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" ParcelUuid[] uuids = btDevice.getUuids();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleIoThread.java"
+ line="194"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBtSocket = btDevice.createRfcommSocketToServiceRecord(UuidSDP);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleIoThread.java"
+ line="203"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" mBtSocket.connect();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/PebbleIoThread.java"
+ line="207"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" if (btDevice.getBondState() == BluetoothDevice.BOND_BONDED ||"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java"
+ line="124"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" btDevice.getBondState() == BluetoothDevice.BOND_BONDING) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java"
+ line="125"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" String expectedSuffix = btDevice.getName();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java"
+ line="138"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" return gatt.readCharacteristic(getCharacteristic());"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/ReadAction.java"
+ line="40"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" return gatt.requestMtu(this.mtu);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/RequestMtuAction.java"
+ line="43"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" return gattServer.sendResponse(device, requestId, 0, offset, value);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/ServerResponseAction.java"
+ line="60"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" gatt.writeCharacteristic(alertLevelCharacteristic);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/actions/StopNotificationAction.java"
+ line="38"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" final DeviceInformationResponseMessage deviceInformationResponseMessage = new DeviceInformationResponseMessage(VivomoveConstants.STATUS_ACK, 112, -1, VivomoveConstants.GADGETBRIDGE_UNIT_NUMBER, BuildConfig.VERSION_CODE, 16384, getBluetoothAdapter().getName(), Build.MANUFACTURER, Build.DEVICE, protocolFlags);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrSupport.java"
+ line="585"
+ column="236"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" return server.notifyCharacteristicChanged(getDevice(), characteristic, false);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/WithingsServerAction.java"
+ line="41"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" return gatt.writeCharacteristic(characteristic);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/WriteAction.java"
+ line="61"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="MissingPermission"
+ message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
+ errorLine1=" return bluetoothAdapter.getName();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsPhoneService.java"
+ line="205"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="NestedScrolling"
+ message="The vertically scrolling `ScrollView` should not contain another vertically scrolling widget (`ListView`)"
+ errorLine1=" <ListView"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_appinstaller.xml"
+ line="26"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="NestedScrolling"
+ message="The vertically scrolling `ScrollView` should not contain another vertically scrolling widget (`ListView`)"
+ errorLine1=" <ListView"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_appinstaller.xml"
+ line="87"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" pieChart.setCenterText(String.format("%d%%\n%s", (int) (value * 100 / target), label));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingAdapter.java"
+ line="173"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" snackbar.setAction(getString(R.string.dialog_hide).toUpperCase(), new View.OnClickListener() {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingChartFragment.java"
+ line="201"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return file.getPath().toLowerCase().endsWith(".gpx");"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java"
+ line="395"
+ column="39"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String fileName = FileUtils.makeValidFileName("Screenshot-" + ActivityKind.asString(currentItem.getActivityKind(), context).toLowerCase() + "-" + DateTimeUtils.formatIso8601(currentItem.getStartTime()) + ".png");"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java"
+ line="588"
+ column="133"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" return String.format("<ASDP type %d, Seq.No: %d, CRC: %x, Valid: %s, Data: %s>", type.ordinal(), sequenceNo, crc, isCrcValid ? "yes" : String.format("NO [expected %x]", expectedCrc), toHexString(data));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/activity/ActivitySyncDataPacket.java"
+ line="76"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return sb.toString().toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/packets/activity/ActivitySyncDataPacket.java"
+ line="88"
+ column="30"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return new AmbientSoundControl(AmbientSoundControl.Mode.valueOf(soundControl.toUpperCase()), focusVoice, ambientSound);"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AmbientSoundControl.java"
+ line="85"
+ column="86"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return AmbientSoundControlButtonMode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_CONTROL_BUTTON_MODE, "nc_as_off").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AmbientSoundControlButtonMode.java"
+ line="61"
+ column="158"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" final String filterPattern = filter.toString().toLowerCase().trim();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppBlacklistAdapter.java"
+ line="283"
+ column="64"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" if (name.toString().toLowerCase().contains(filterPattern) ||"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppBlacklistAdapter.java"
+ line="287"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return (!isNight) ? iconToLoad : iconToLoad.toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/AppMessageHandlerObsidian.java"
+ line="142"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" final String filterPattern = filter.toString().toLowerCase().trim();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppSpecificNotificationSettingsAppListAdapter.java"
+ line="228"
+ column="64"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" if (name.toString().toLowerCase().contains(filterPattern) ||"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppSpecificNotificationSettingsAppListAdapter.java"
+ line="232"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return this.appName.toLowerCase().compareTo(o.getAppName().toLowerCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/application/ApplicationInformation.java"
+ line="45"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return this.appName.toLowerCase().compareTo(o.getAppName().toLowerCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/application/ApplicationInformation.java"
+ line="45"
+ column="68"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" String version = String.format("%d.%d", buffer.get(), buffer.get());"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/requests/fossil_hr/application/ApplicationsListRequest.java"
+ line="57"
+ column="30"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return AutomaticPowerOff.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_AUTOMATIC_POWER_OFF, "off").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AutomaticPowerOff.java"
+ line="62"
+ column="126"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.valueOf(json.getString("n").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java"
+ line="540"
+ column="109"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" deviceEventCallControl.event = GBDeviceEventCallControl.Event.valueOf(json.getString("n").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java"
+ line="545"
+ column="107"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.valueOf(json.getString("n").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java"
+ line="619"
+ column="113"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String m = json.getString("method").toLowerCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java"
+ line="732"
+ column="49"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" cmdName = field.getName().substring(5).toLowerCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java"
+ line="1313"
+ column="64"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" Mode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_BUTTON_MODE_LEFT, "off").toUpperCase()),"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/ButtonModes.java"
+ line="61"
+ column="111"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" Mode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_BUTTON_MODE_RIGHT, "off").toUpperCase())"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/ButtonModes.java"
+ line="62"
+ column="112"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return name().substring(0, 1).toUpperCase() + name().substring(1).toLowerCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CalibrationActivity.java"
+ line="48"
+ column="79"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return name().substring(0, 1).toUpperCase() + name().substring(1).toLowerCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CalibrationActivity.java"
+ line="48"
+ column="43"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" String timeString = String.format("%02d:%02d:%02d", hours, minutes, seconds);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/um25/Activity/DataActivity.java"
+ line="135"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" (!FileUtils.getExtension(file.toString()).toLowerCase().equals("gpx")) && //keep GPX files"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java"
+ line="452"
+ column="83"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" deviceTypeCache.get(deviceCandidate.getMacAddress().toLowerCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java"
+ line="148"
+ column="77"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" deviceTypeCache.put(deviceCandidate.getMacAddress().toLowerCase(), type);"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java"
+ line="156"
+ column="73"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" deviceTypeCache.put(deviceCandidate.getMacAddress().toLowerCase(), DeviceType.UNKNOWN);"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java"
+ line="160"
+ column="65"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return EqualizerPreset.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_MODE, "off").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/EqualizerPreset.java"
+ line="68"
+ column="119"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String fileName = FileUtils.makeValidFileName("gadgetbridge-" + trackType.toLowerCase() + "-" + DateTimeUtils.formatIso8601(summary.getStartTime()) + ".gpx");"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSportsDetailsOperation.java"
+ line="128"
+ column="91"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" localeString = language + "_" + country.toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/fitpro/FitProDeviceSupport.java"
+ line="662"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" return String.format("%s (at %s: %d)", message.message(), sourceId, message.lineNumber());"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/GBChromeClient.java"
+ line="41"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return fieldName.toLowerCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/GattCharacteristic.java"
+ line="396"
+ column="30"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" builder.append(word.toLowerCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/GattCharacteristic.java"
+ line="406"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" versionInfo.fwVersion = String.format("%d.%d", softwareVersion >> 8, softwareVersion & 0xff);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/GetFirmwareInfoRequest.java"
+ line="47"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" device.setFirmwareVersion2(String.format("%d.%d", hardwareVersion >> 8, hardwareVersion & 0xff));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/lefun/requests/GetFirmwareInfoRequest.java"
+ line="53"
+ column="36"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" if (uri!=null && uri.getPath().toLowerCase().endsWith(".gpx")) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/GpxReceiverActivity.java"
+ line="88"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" baos.write(StringUtils.truncate(clock.getCode(), 3).toUpperCase().getBytes(StandardCharsets.UTF_8));"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java"
+ line="1195"
+ column="69"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" baos.write(StringUtils.truncate(clock.getLabel(), 3).toUpperCase().getBytes(StandardCharsets.UTF_8));"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java"
+ line="1197"
+ column="70"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" localeString = language + "_" + country.toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java"
+ line="3932"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" localeString = language + "_" + country.toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java"
+ line="3991"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" macAddress = mac.toString().toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/HuaweiSupportProvider.java"
+ line="534"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return sb.toString().toLowerCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/JSInterface.java"
+ line="207"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String macAddress = candidate.getMacAddress().toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandCoordinator.java"
+ line="79"
+ column="55"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" if (name != null && name.toUpperCase().startsWith(MiBandConst.MI_GENERAL_NAME_PREFIX.toUpperCase())) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandCoordinator.java"
+ line="93"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" if (name != null && name.toUpperCase().startsWith(MiBandConst.MI_GENERAL_NAME_PREFIX.toUpperCase())) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandCoordinator.java"
+ line="93"
+ column="102"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" LOG.info("READ SENSOR DATA VALUES: counter:"+counter+" step:"+step+" x-axis:"+ String.format("%.03f",xAxis)+" y-axis:"+String.format("%.03f",yAxis)+" z-axis:"+String.format("%.03f",zAxis)+";");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/MiBandSupport.java"
+ line="1283"
+ column="96"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" LOG.info("READ SENSOR DATA VALUES: counter:"+counter+" step:"+step+" x-axis:"+ String.format("%.03f",xAxis)+" y-axis:"+String.format("%.03f",yAxis)+" z-axis:"+String.format("%.03f",zAxis)+";");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/MiBandSupport.java"
+ line="1283"
+ column="136"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" LOG.info("READ SENSOR DATA VALUES: counter:"+counter+" step:"+step+" x-axis:"+ String.format("%.03f",xAxis)+" y-axis:"+String.format("%.03f",yAxis)+" z-axis:"+String.format("%.03f",zAxis)+";");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/MiBandSupport.java"
+ line="1283"
+ column="176"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" packageName = packageName.toLowerCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/NotificationFilterActivity.java"
+ line="77"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" if (!checkNotificationContentForWhiteAndBlackList(sbn.getPackageName().toLowerCase(), textToCheck)) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java"
+ line="395"
+ column="84"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" Query<NotificationFilter> query = notificationFilterDao.queryBuilder().where(NotificationFilterDao.Properties.AppIdentifier.eq(packageName.toLowerCase())).build();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java"
+ line="481"
+ column="152"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return name().toLowerCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationType.java"
+ line="94"
+ column="23"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return new String(hexChars).toLowerCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutKey.java"
+ line="71"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" logDebug(String.format("onReceiveStepsSample: \ndayStepCount=%d, \nsteps=%d, \ndiff=%d, \nprevoiusDayStepCount=%d, ", dayStepCount, steps, diff, prevoiusDayStepCount));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pinetime/PineTimeJFSupport.java"
+ line="1114"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" logDebug(String.format("onReceiveHeartRateMeasurement: \nheartrate=%d", heartrate));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pinetime/PineTimeJFSupport.java"
+ line="1157"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" logDebug(String.format("getDayStartEnd: \ntimeStamp=%d (%s), \ntimeStampStartUtc=%d (-offset=%s), \ntimeStampEndUtc=%d (-offset=%s)", timeStampUtc, calTimeStamp.getTime(), timeStampStartUtc, start.getTime(), timeStampEndUtc, end.getTime()));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pinetime/PineTimeJFSupport.java"
+ line="1223"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" Mode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_QUICK_ACCESS_DOUBLE_TAP, "off").toUpperCase()),"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/QuickAccess.java"
+ line="79"
+ column="118"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" Mode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_QUICK_ACCESS_TRIPLE_TAP, "off").toUpperCase())"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/QuickAccess.java"
+ line="80"
+ column="118"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" localeString = language + "-" + country.toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/requests/SetLanguageSettingRequest.java"
+ line="55"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" LedColor led = LedColor.valueOf(prefs.getString(SonyWena3SettingKeys.DEFAULT_CALL_LED_COLOR, LedColor.WHITE.name()).toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="452"
+ column="133"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" VibrationKind vibra = VibrationKind.valueOf(prefs.getString(SonyWena3SettingKeys.DEFAULT_CALL_VIBRATION_PATTERN, VibrationKind.CONTINUOUS.name()).toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="453"
+ column="163"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" LedColor led = LedColor.valueOf(prefs.getString(SonyWena3SettingKeys.DEFAULT_LED_COLOR, LedColor.BLUE.name()).toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="534"
+ column="123"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" VibrationKind vibra = VibrationKind.valueOf(prefs.getString(SonyWena3SettingKeys.DEFAULT_VIBRATION_PATTERN, VibrationKind.BASIC.name()).toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="535"
+ column="149"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" led = LedColor.valueOf(appSpecificSetting.getLedPattern().toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="543"
+ column="83"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" vibra = VibrationKind.valueOf(appSpecificSetting.getVibrationPattern().toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="547"
+ column="96"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" localeString = language + "_" + country.toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="681"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" VibrationStrength strength = VibrationStrength.valueOf(prefs.getString(SonyWena3SettingKeys.VIBRATION_STRENGTH, VibrationStrength.NORMAL.name()).toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="777"
+ column="154"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String leftIdName = prefs.getString(SonyWena3SettingKeys.LEFT_HOME_ICON, HomeIconId.MUSIC.name()).toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="788"
+ column="107"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String centerIdName = prefs.getString(SonyWena3SettingKeys.CENTER_HOME_ICON, HomeIconId.PEDOMETER.name()).toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="789"
+ column="115"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String rightIdName = prefs.getString(SonyWena3SettingKeys.RIGHT_HOME_ICON, HomeIconId.CALORIES.name()).toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="790"
+ column="112"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" .toUpperCase()"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="806"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" .toUpperCase()"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="828"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String doubleIdName = prefs.getString(SonyWena3SettingKeys.BUTTON_DOUBLE_PRESS_ACTION, DeviceButtonActionId.NONE.name()).toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="880"
+ column="130"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String longIdName = prefs.getString(SonyWena3SettingKeys.BUTTON_LONG_PRESS_ACTION, DeviceButtonActionId.NONE.name()).toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="881"
+ column="126"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return SoundPosition.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_SOUND_POSITION, "off").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SoundPosition.java"
+ line="52"
+ column="117"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" final Sensitivity sensitivity = Sensitivity.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_SPEAK_TO_CHAT_SENSITIVITY, "auto").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SpeakToChatConfig.java"
+ line="119"
+ column="152"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" final Timeout timeout = Timeout.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_SPEAK_TO_CHAT_TIMEOUT, "standard").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SpeakToChatConfig.java"
+ line="120"
+ column="140"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" days_total.setText(String.format("%.1f%%", 0.0));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java"
+ line="220"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" days_total.setText(String.format("%.1f%%", (float) stepsStreaks.total.days / stepsStreaks.total.total_days * 100));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java"
+ line="222"
+ column="36"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" return SurroundMode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_SURROUND_MODE, "off").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SurroundMode.java"
+ line="51"
+ column="115"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" return String.format("%02d", hour) + ":" + String.format("%02d", minute);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/TimePreference.java"
+ line="111"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" return String.format("%02d", hour) + ":" + String.format("%02d", minute);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/TimePreference.java"
+ line="111"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" return String.valueOf(h) + ":" + String.format("%02d", minute) + suffix;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/TimePreference.java"
+ line="118"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" if(!candidate.getName().toLowerCase().contains("vesc")){"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vesc/VescCoordinator.java"
+ line="74"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.valueOf(json.getString("n").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/waspos/WaspOSDeviceSupport.java"
+ line="177"
+ column="109"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" deviceEventCallControl.event = GBDeviceEventCallControl.Event.valueOf(json.getString("n").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/waspos/WaspOSDeviceSupport.java"
+ line="182"
+ column="107"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.valueOf(json.getString("n").toUpperCase());"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/waspos/WaspOSDeviceSupport.java"
+ line="188"
+ column="121"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String macAddress = candidate.getMacAddress().toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9DeviceCoordinator.java"
+ line="64"
+ column="55"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String deviceName = candidate.getName().toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9DeviceCoordinator.java"
+ line="65"
+ column="49"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String macAddress = candidate.getMacAddress().toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusDeviceCoordinator.java"
+ line="91"
+ column="55"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String deviceName = candidate.getName().toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusDeviceCoordinator.java"
+ line="92"
+ column="49"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" if (contentType != null && contentType.toLowerCase().trim().contains("charset=")) {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java"
+ line="129"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" String[] parts = contentType.toLowerCase().trim().split("=");"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java"
+ line="130"
+ column="46"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" return String.format("%.0f", value);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/WeekStepsChartFragment.java"
+ line="123"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" final String newCode = StringUtils.truncate(newLabel, 3).toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WorldClockDetails.java"
+ line="206"
+ column="70"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" final String oldCode = StringUtils.truncate(oldLabel, 3).toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WorldClockDetails.java"
+ line="207"
+ column="70"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" return String.format("%02d", hour) + ":" + String.format("%02d", minute);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java"
+ line="73"
+ column="16"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" return String.format("%02d", hour) + ":" + String.format("%02d", minute);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java"
+ line="73"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" return h + ":" + String.format("%02d", minute) + suffix;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java"
+ line="80"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" localeString = language + "_" + country.toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiSystemService.java"
+ line="267"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" String starttime = String.format("%02d:%02d", msg[6], msg[7]);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/zetime/ZeTimeDeviceSupport.java"
+ line="2007"
+ column="28"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" String endtime = String.format("%02d:%02d", msg[8], msg[9]);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/zetime/ZeTimeDeviceSupport.java"
+ line="2008"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" String starttime = String.format("%02d:%02d", msg[7], msg[8]);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/zetime/ZeTimeDeviceSupport.java"
+ line="2069"
+ column="28"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" String endtime = String.format("%02d:%02d", msg[9], msg[10]);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/zetime/ZeTimeDeviceSupport.java"
+ line="2070"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
+ errorLine1=" localeString = language + "_" + country.toUpperCase();"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/zetime/ZeTimeDeviceSupport.java"
+ line="2167"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="DefaultLocale"
+ message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
+ errorLine1=" final TransactionBuilder builder = performInitialized(String.format("set heart rate interval to: %d minutes", minuteInterval));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java"
+ line="343"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="InlinedApi"
+ message="Field requires API level 29 (current min is 21): `android.Manifest.permission#ACCESS_BACKGROUND_LOCATION`"
+ errorLine1=" ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION}, 0);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="510"
+ column="66"/>
+ </issue>
+
+ <issue
+ id="InlinedApi"
+ message="Field requires API level 31 (current min is 21): `android.app.PendingIntent#FLAG_MUTABLE`"
+ errorLine1=" flags |= PendingIntent.FLAG_MUTABLE;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PendingIntentUtils.java"
+ line="33"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="InlinedApi"
+ message="Field requires API level 31 (current min is 21): `android.app.PendingIntent#FLAG_MUTABLE`"
+ errorLine1=" flags |= PendingIntent.FLAG_MUTABLE;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PendingIntentUtils.java"
+ line="50"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="InlinedApi"
+ message="Field requires API level 31 (current min is 21): `android.app.PendingIntent#FLAG_MUTABLE`"
+ errorLine1=" flags |= PendingIntent.FLAG_MUTABLE;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/PendingIntentUtils.java"
+ line="67"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="InlinedApi"
+ message="Field requires API level 23 (current min is 21): `COMPLETED`"
+ errorLine1=" LineageWeatherManager.RequestStatus.COMPLETED);"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/java/lineageos/weatherservice/ServiceRequest.java"
+ line="71"
+ column="73"/>
+ </issue>
+
+ <issue
+ id="InlinedApi"
+ message="Field requires API level 23 (current min is 21): `NO_MATCH_FOUND`"
+ errorLine1=" LineageWeatherManager.RequestStatus.NO_MATCH_FOUND);"
+ errorLine2=" ~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/lineageos/weatherservice/ServiceRequest.java"
+ line="79"
+ column="77"/>
+ </issue>
+
+ <issue
+ id="InlinedApi"
+ message="Field requires API level 23 (current min is 21): `COMPLETED`"
+ errorLine1=" LineageWeatherManager.RequestStatus.COMPLETED);"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/java/lineageos/weatherservice/ServiceRequest.java"
+ line="82"
+ column="77"/>
+ </issue>
+
+ <issue
+ id="InlinedApi"
+ message="Field requires API level 23 (current min is 21): `FAILED`"
+ errorLine1=" LineageWeatherManager.RequestStatus.FAILED);"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/lineageos/weatherservice/ServiceRequest.java"
+ line="106"
+ column="73"/>
+ </issue>
+
+ <issue
+ id="InlinedApi"
+ message="Field requires API level 23 (current min is 21): `FAILED`"
+ errorLine1=" LineageWeatherManager.RequestStatus.FAILED);"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/lineageos/weatherservice/ServiceRequest.java"
+ line="110"
+ column="73"/>
+ </issue>
+
+ <issue
+ id="LocaleFolder"
+ message="The locale folder "`he`" should be called "`iw`" instead; see the `java.util.Locale` documentation">
+ <location
+ file="src/main/res/values-he"/>
+ </issue>
+
+ <issue
+ id="LocaleFolder"
+ message="The locale folder "`id`" should be called "`in`" instead; see the `java.util.Locale` documentation">
+ <location
+ file="src/main/res/values-id"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" return cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java"
+ line="238"
+ column="45"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" name = contactLookup.getString(contactLookup.getColumnIndex(ContactsContract.Data.DISPLAY_NAME));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java"
+ line="521"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" int orientation = c.getInt(c.getColumnIndex(MediaStore.Images.ImageColumns.ORIENTATION));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceDesignerActivity.java"
+ line="588"
+ column="36"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" int orientation = c.getInt(c.getColumnIndex(MediaStore.Images.ImageColumns.ORIENTATION));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ImageEditActivity.java"
+ line="169"
+ column="36"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" (short) c.getInt(c.getColumnIndex(DB_MINUTE)),"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/PackageConfigHelper.java"
+ line="119"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" (short) c.getInt(c.getColumnIndex(DB_HOUR)),"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/PackageConfigHelper.java"
+ line="120"
+ column="42"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" c.getString(c.getColumnIndex(DB_PACKAGE)),"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/PackageConfigHelper.java"
+ line="121"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" c.getString(c.getColumnIndex(DB_APPNAME)),"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/PackageConfigHelper.java"
+ line="122"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" c.getInt(c.getColumnIndex(DB_RESPECT_SILENT)) == 1,"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/PackageConfigHelper.java"
+ line="123"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" PlayNotificationRequest.VibrationType.fromValue((byte) c.getInt(c.getColumnIndex(DB_VIBRATION))),"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/PackageConfigHelper.java"
+ line="124"
+ column="89"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" c.getInt(c.getColumnIndex(DB_ID))"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/PackageConfigHelper.java"
+ line="125"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="Range"
+ message="Value must be 鈮� 0 but `getColumnIndex` can be -1"
+ errorLine1=" return cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java"
+ line="558"
+ column="49"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" final SimpleDateFormat dateFormat = new SimpleDateFormat("dd.MM.yyyy HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractChartsActivity.java"
+ line="194"
+ column="45"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" SimpleDateFormat annotationDateFormat = new SimpleDateFormat("dd.MM HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BatteryInfoChartFragment.java"
+ line="196"
+ column="49"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gbx100/CasioGBX100DeviceSupport.java"
+ line="367"
+ column="39"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/fitpro/FitProDeviceSupport.java"
+ line="1010"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/fitpro/FitProDeviceSupport.java"
+ line="1050"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/fitpro/FitProDeviceSupport.java"
+ line="1092"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/fitpro/FitProDeviceSupport.java"
+ line="1132"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/fitpro/FitProDeviceSupport.java"
+ line="1182"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3Coordinator.java"
+ line="108"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" SimpleDateFormat format = new SimpleDateFormat("MM-dd HH:mm:ss");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sonyswr12/SonySWR12Util.java"
+ line="35"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="723"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/SonyWena3DeviceSupport.java"
+ line="753"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" this(new SimpleDateFormat("HH:mm"));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TimestampValueFormatter.java"
+ line="33"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusDeviceCoordinator.java"
+ line="255"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusDeviceCoordinator.java"
+ line="284"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" String time = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xwatch/XWatchSupport.java"
+ line="136"
+ column="23"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df_start = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/zetime/ZeTimeDeviceSupport.java"
+ line="1642"
+ column="31"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df_end = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/zetime/ZeTimeDeviceSupport.java"
+ line="1643"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df_start = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/zetime/ZeTimeDeviceSupport.java"
+ line="1770"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="SimpleDateFormat"
+ message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
+ errorLine1=" DateFormat df_end = new SimpleDateFormat("HH:mm");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/zetime/ZeTimeDeviceSupport.java"
+ line="1771"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ActivityDescription.java"
+ line="112"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ActivityDescription.java"
+ line="111"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Alarm.java"
+ line="172"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Alarm.java"
+ line="171"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Alarm.java"
+ line="200"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Alarm.java"
+ line="199"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AppSpecificNotificationSetting.java"
+ line="106"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AppSpecificNotificationSetting.java"
+ line="105"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BangleJSActivitySample.java"
+ line="144"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BangleJSActivitySample.java"
+ line="143"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BangleJSActivitySample.java"
+ line="172"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BangleJSActivitySample.java"
+ line="171"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BaseActivitySummary.java"
+ line="227"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BaseActivitySummary.java"
+ line="226"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BaseActivitySummary.java"
+ line="255"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BaseActivitySummary.java"
+ line="254"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BatteryLevel.java"
+ line="94"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BatteryLevel.java"
+ line="93"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CalendarSyncState.java"
+ line="92"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CalendarSyncState.java"
+ line="91"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CasioGBX100ActivitySample.java"
+ line="130"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CasioGBX100ActivitySample.java"
+ line="129"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CasioGBX100ActivitySample.java"
+ line="158"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CasioGBX100ActivitySample.java"
+ line="157"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfActivitySample.java"
+ line="164"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfActivitySample.java"
+ line="163"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfActivitySample.java"
+ line="192"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfActivitySample.java"
+ line="191"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfHeartRateSample.java"
+ line="106"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfHeartRateSample.java"
+ line="105"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfHeartRateSample.java"
+ line="134"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfHeartRateSample.java"
+ line="133"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSleepSessionSample.java"
+ line="115"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSleepSessionSample.java"
+ line="114"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSleepSessionSample.java"
+ line="143"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSleepSessionSample.java"
+ line="142"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSleepStageSample.java"
+ line="115"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSleepStageSample.java"
+ line="114"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSleepStageSample.java"
+ line="143"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSleepStageSample.java"
+ line="142"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSpo2Sample.java"
+ line="106"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSpo2Sample.java"
+ line="105"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSpo2Sample.java"
+ line="134"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSpo2Sample.java"
+ line="133"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfStressSample.java"
+ line="106"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfStressSample.java"
+ line="105"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfStressSample.java"
+ line="134"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfStressSample.java"
+ line="133"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfWorkoutGpsSample.java"
+ line="115"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfWorkoutGpsSample.java"
+ line="114"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfWorkoutGpsSample.java"
+ line="143"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfWorkoutGpsSample.java"
+ line="142"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Contact.java"
+ line="114"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Contact.java"
+ line="113"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Contact.java"
+ line="142"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Contact.java"
+ line="141"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/FemometerVinca2TemperatureSample.java"
+ line="117"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/FemometerVinca2TemperatureSample.java"
+ line="116"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/FemometerVinca2TemperatureSample.java"
+ line="145"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/FemometerVinca2TemperatureSample.java"
+ line="144"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/FitProActivitySample.java"
+ line="192"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/FitProActivitySample.java"
+ line="191"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/FitProActivitySample.java"
+ line="220"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/FitProActivitySample.java"
+ line="219"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/GarminFitFile.java"
+ line="166"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/GarminFitFile.java"
+ line="165"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/GarminFitFile.java"
+ line="194"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/GarminFitFile.java"
+ line="193"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HPlusHealthActivityOverlay.java"
+ line="118"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HPlusHealthActivityOverlay.java"
+ line="117"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HPlusHealthActivityOverlay.java"
+ line="146"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HPlusHealthActivityOverlay.java"
+ line="145"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HPlusHealthActivitySample.java"
+ line="173"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HPlusHealthActivitySample.java"
+ line="172"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HPlusHealthActivitySample.java"
+ line="201"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HPlusHealthActivitySample.java"
+ line="200"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiExtendedActivitySample.java"
+ line="184"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiExtendedActivitySample.java"
+ line="183"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiExtendedActivitySample.java"
+ line="212"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiExtendedActivitySample.java"
+ line="211"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateManualSample.java"
+ line="116"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateManualSample.java"
+ line="115"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateManualSample.java"
+ line="144"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateManualSample.java"
+ line="143"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateMaxSample.java"
+ line="116"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateMaxSample.java"
+ line="115"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateMaxSample.java"
+ line="144"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateMaxSample.java"
+ line="143"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateRestingSample.java"
+ line="116"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateRestingSample.java"
+ line="115"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateRestingSample.java"
+ line="144"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateRestingSample.java"
+ line="143"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiPaiSample.java"
+ line="193"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiPaiSample.java"
+ line="192"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiPaiSample.java"
+ line="221"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiPaiSample.java"
+ line="220"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiSleepRespiratoryRateSample.java"
+ line="116"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiSleepRespiratoryRateSample.java"
+ line="115"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiSleepRespiratoryRateSample.java"
+ line="144"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiSleepRespiratoryRateSample.java"
+ line="143"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiSpo2Sample.java"
+ line="117"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiSpo2Sample.java"
+ line="116"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiSpo2Sample.java"
+ line="145"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiSpo2Sample.java"
+ line="144"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiStressSample.java"
+ line="117"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiStressSample.java"
+ line="116"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiStressSample.java"
+ line="145"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiStressSample.java"
+ line="144"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiActivitySample.java"
+ line="194"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiActivitySample.java"
+ line="193"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiActivitySample.java"
+ line="222"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiActivitySample.java"
+ line="221"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" huaweiWorkoutSummarySample__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiWorkoutDataSample.java"
+ line="224"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiWorkoutDataSample.java"
+ line="223"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" huaweiWorkoutSummarySample__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiWorkoutPaceSample.java"
+ line="105"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiWorkoutPaceSample.java"
+ line="104"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiWorkoutSummarySample.java"
+ line="246"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiWorkoutSummarySample.java"
+ line="245"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiWorkoutSummarySample.java"
+ line="274"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiWorkoutSummarySample.java"
+ line="273"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HybridHRActivitySample.java"
+ line="180"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HybridHRActivitySample.java"
+ line="179"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HybridHRActivitySample.java"
+ line="208"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HybridHRActivitySample.java"
+ line="207"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ID115ActivitySample.java"
+ line="150"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ID115ActivitySample.java"
+ line="149"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ID115ActivitySample.java"
+ line="178"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ID115ActivitySample.java"
+ line="177"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/JYouActivitySample.java"
+ line="162"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/JYouActivitySample.java"
+ line="161"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/JYouActivitySample.java"
+ line="190"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/JYouActivitySample.java"
+ line="189"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunActivitySample.java"
+ line="152"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunActivitySample.java"
+ line="151"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunActivitySample.java"
+ line="180"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunActivitySample.java"
+ line="179"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunBiometricSample.java"
+ line="116"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunBiometricSample.java"
+ line="115"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunBiometricSample.java"
+ line="144"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunBiometricSample.java"
+ line="143"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunSleepSample.java"
+ line="96"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunSleepSample.java"
+ line="95"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunSleepSample.java"
+ line="124"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunSleepSample.java"
+ line="123"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/MakibesHR3ActivitySample.java"
+ line="132"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/MakibesHR3ActivitySample.java"
+ line="131"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/MakibesHR3ActivitySample.java"
+ line="160"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/MakibesHR3ActivitySample.java"
+ line="159"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/MiBandActivitySample.java"
+ line="144"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/MiBandActivitySample.java"
+ line="143"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/MiBandActivitySample.java"
+ line="172"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/MiBandActivitySample.java"
+ line="171"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/No1F1ActivitySample.java"
+ line="144"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/No1F1ActivitySample.java"
+ line="143"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/No1F1ActivitySample.java"
+ line="172"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/No1F1ActivitySample.java"
+ line="171"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" notificationFilter__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/NotificationFilterEntry.java"
+ line="85"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/NotificationFilterEntry.java"
+ line="84"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleHealthActivityOverlay.java"
+ line="118"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleHealthActivityOverlay.java"
+ line="117"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleHealthActivityOverlay.java"
+ line="146"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleHealthActivityOverlay.java"
+ line="145"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleHealthActivitySample.java"
+ line="143"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleHealthActivitySample.java"
+ line="142"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleHealthActivitySample.java"
+ line="171"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleHealthActivitySample.java"
+ line="170"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleMisfitSample.java"
+ line="107"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleMisfitSample.java"
+ line="106"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleMisfitSample.java"
+ line="135"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleMisfitSample.java"
+ line="134"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleMorpheuzSample.java"
+ line="108"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleMorpheuzSample.java"
+ line="107"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleMorpheuzSample.java"
+ line="136"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleMorpheuzSample.java"
+ line="135"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PineTimeActivitySample.java"
+ line="132"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PineTimeActivitySample.java"
+ line="131"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PineTimeActivitySample.java"
+ line="160"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PineTimeActivitySample.java"
+ line="159"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Reminder.java"
+ line="124"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Reminder.java"
+ line="123"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Reminder.java"
+ line="152"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Reminder.java"
+ line="151"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/SonySWR12Sample.java"
+ line="144"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/SonySWR12Sample.java"
+ line="143"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/SonySWR12Sample.java"
+ line="172"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/SonySWR12Sample.java"
+ line="171"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/TLW64ActivitySample.java"
+ line="132"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/TLW64ActivitySample.java"
+ line="131"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/TLW64ActivitySample.java"
+ line="160"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/TLW64ActivitySample.java"
+ line="159"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/VivomoveHrActivitySample.java"
+ line="164"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/VivomoveHrActivitySample.java"
+ line="163"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/VivomoveHrActivitySample.java"
+ line="192"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/VivomoveHrActivitySample.java"
+ line="191"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WatchXPlusActivitySample.java"
+ line="173"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WatchXPlusActivitySample.java"
+ line="172"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WatchXPlusActivitySample.java"
+ line="201"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WatchXPlusActivitySample.java"
+ line="200"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WatchXPlusHealthActivityOverlay.java"
+ line="118"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WatchXPlusHealthActivityOverlay.java"
+ line="117"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WatchXPlusHealthActivityOverlay.java"
+ line="146"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WatchXPlusHealthActivityOverlay.java"
+ line="145"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3ActivitySample.java"
+ line="129"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3ActivitySample.java"
+ line="128"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3ActivitySample.java"
+ line="157"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3ActivitySample.java"
+ line="156"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3BehaviorSample.java"
+ line="125"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3BehaviorSample.java"
+ line="124"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3BehaviorSample.java"
+ line="153"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3BehaviorSample.java"
+ line="152"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3CaloriesSample.java"
+ line="105"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3CaloriesSample.java"
+ line="104"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3CaloriesSample.java"
+ line="133"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3CaloriesSample.java"
+ line="132"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3EnergySample.java"
+ line="105"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3EnergySample.java"
+ line="104"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3EnergySample.java"
+ line="133"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3EnergySample.java"
+ line="132"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3HeartRateSample.java"
+ line="105"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3HeartRateSample.java"
+ line="104"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3HeartRateSample.java"
+ line="133"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3HeartRateSample.java"
+ line="132"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3StressSample.java"
+ line="117"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3StressSample.java"
+ line="116"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3StressSample.java"
+ line="145"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3StressSample.java"
+ line="144"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3Vo2Sample.java"
+ line="116"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3Vo2Sample.java"
+ line="115"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3Vo2Sample.java"
+ line="144"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3Vo2Sample.java"
+ line="143"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WithingsSteelHRActivitySample.java"
+ line="174"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WithingsSteelHRActivitySample.java"
+ line="173"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WithingsSteelHRActivitySample.java"
+ line="202"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WithingsSteelHRActivitySample.java"
+ line="201"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WorldClock.java"
+ line="134"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WorldClock.java"
+ line="133"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WorldClock.java"
+ line="162"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WorldClock.java"
+ line="161"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XWatchActivitySample.java"
+ line="144"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XWatchActivitySample.java"
+ line="143"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XWatchActivitySample.java"
+ line="172"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XWatchActivitySample.java"
+ line="171"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiActivitySample.java"
+ line="164"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiActivitySample.java"
+ line="163"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiActivitySample.java"
+ line="192"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiActivitySample.java"
+ line="191"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiDailySummarySample.java"
+ line="345"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiDailySummarySample.java"
+ line="344"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiDailySummarySample.java"
+ line="373"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiDailySummarySample.java"
+ line="372"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiManualSample.java"
+ line="115"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiManualSample.java"
+ line="114"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiManualSample.java"
+ line="143"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiManualSample.java"
+ line="142"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiSleepStageSample.java"
+ line="105"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiSleepStageSample.java"
+ line="104"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiSleepStageSample.java"
+ line="133"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiSleepStageSample.java"
+ line="132"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiSleepTimeSample.java"
+ line="165"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiSleepTimeSample.java"
+ line="164"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiSleepTimeSample.java"
+ line="193"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiSleepTimeSample.java"
+ line="192"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" device__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ZeTimeActivitySample.java"
+ line="174"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ZeTimeActivitySample.java"
+ line="173"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="SuspiciousIndentation"
+ message="The indentation string here is different from on the previous line (`" "` vs `\t`)"
+ errorLine1=" user__resolvedKey = __key;"
+ errorLine2=" ~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ZeTimeActivitySample.java"
+ line="202"
+ column="13"/>
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ZeTimeActivitySample.java"
+ line="201"
+ column="13"
+ message="Previous line indentation here"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `iconTint` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:iconTint="#7E7E7E""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/menu/activity_controlcenterv2_main_drawer.xml"
+ line="21"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0" />"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_settings.xml"
+ line="34"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/candidate_item_device_image""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="57"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/candidate_item_device_image""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="99"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/battery_detail_activity_title""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="117"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/title_activity_device_specific_settings""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="219"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_change_fm_frequency""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="231"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_change_led_color""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="267"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_fetch_activity_data""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="291"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_take_screenshot""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="319"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/title_activity_appmanager""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="336"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_start_configure_alarms""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="353"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_start_configure_reminders""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="370"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_start_activitymonitor""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="387"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_start_activity_tracks""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="404"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_find_device""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="420"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_calibrate_device""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="436"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_get_heartrate_measurement""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="448"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/controlcenter_power_off""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="483"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/live_activity_total_steps""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="513"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/activity_list_summary_distance""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="522"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/sleepchart_your_sleep""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="532"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_2.xml"
+ line="25"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_2.xml"
+ line="58"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_2.xml"
+ line="68"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_2.xml"
+ line="78"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_2.xml"
+ line="182"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_2_pro.xml"
+ line="58"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_2_pro.xml"
+ line="68"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_2_pro.xml"
+ line="78"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_2_pro.xml"
+ line="181"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_pro.xml"
+ line="35"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_pro.xml"
+ line="88"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_pro.xml"
+ line="98"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_pro.xml"
+ line="108"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `min` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:min="0""
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/devicesettings_galaxy_buds_pro.xml"
+ line="216"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `singleLineTitle` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:singleLineTitle="false""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/fossil_hr_watchface_settings.xml"
+ line="29"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `singleLineTitle` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:singleLineTitle="false""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/fossil_hr_watchface_settings.xml"
+ line="35"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `singleLineTitle` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:singleLineTitle="false""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/fossil_hr_watchface_settings.xml"
+ line="97"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `singleLineTitle` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:singleLineTitle="false""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/fossil_hr_watchface_settings.xml"
+ line="103"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `singleLineTitle` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:singleLineTitle="false""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/fossil_hr_watchface_settings.xml"
+ line="109"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `singleLineTitle` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:singleLineTitle="false""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/fossil_hr_widget_settings.xml"
+ line="98"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `singleLineTitle` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:singleLineTitle="false""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/fossil_hr_widget_settings.xml"
+ line="104"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `singleLineTitle` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:singleLineTitle="false""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/preferences.xml"
+ line="82"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/step_streak_average_steps_hint" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streak_average.xml"
+ line="23"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/step_streak_average_steps_hint" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streak_average.xml"
+ line="36"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/step_streak_days_hint" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streak_days.xml"
+ line="25"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/step_streak_days_hint" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streak_days.xml"
+ line="38"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/steps_streaks_total_steps_hint" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streak_total.xml"
+ line="24"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/steps_streaks_total_steps_hint" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streak_total.xml"
+ line="37"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="UnusedAttribute"
+ message="Attribute `tooltipText` is only used in API level 26 and higher (current min is 21)"
+ errorLine1=" android:tooltipText="@string/steps_streaks_hint" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streaks_dashboard.xml"
+ line="33"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="AppBundleLocaleChanges"
+ message="Found dynamic locale changes, but did not find corresponding Play Core library calls for downloading languages and splitting by language is not disabled in the `bundle` configuration"
+ errorLine1=" config.setLocale(language);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/AndroidUtils.java"
+ line="110"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="InflateParams"
+ message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout's root element)"
+ errorLine1=" convertView = inflater.inflate(R.layout.fossil_hr_row_installed_app, null);"
+ errorLine2=" ~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/AppsManagementActivity.java"
+ line="113"
+ column="86"/>
+ </issue>
+
+ <issue
+ id="InflateParams"
+ message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout's root element)"
+ errorLine1=" view = ((LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE)).inflate(R.layout.qhybrid_package_settings_item, null);"
+ errorLine2=" ~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ConfigActivity.java"
+ line="574"
+ column="133"/>
+ </issue>
+
+ <issue
+ id="InflateParams"
+ message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout's root element)"
+ errorLine1=" view = ((LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE)).inflate(R.layout.qhybrid_app_view, null);"
+ errorLine2=" ~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridAppChoserActivity.java"
+ line="188"
+ column="120"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_app_delete_cache:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="589"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_app_delete:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="592"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_app_start:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="595"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_app_download:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="599"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_app_reinstall:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="603"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_app_share:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="607"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_health_activate:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="627"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_hrm_activate:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="630"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_weather_activate:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="633"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_health_deactivate:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="636"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_weather_install_provider:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="641"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_app_configure:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="644"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_app_openinstore:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="653"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.appmanager_app_edit:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="659"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.charts_fetch_activity_data:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractChartsActivity.java"
+ line="273"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.prefs_charts_menu:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractChartsActivity.java"
+ line="276"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.activity_action_manage_timestamp:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java"
+ line="135"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.activity_action_filter:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java"
+ line="139"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.activity_action_delete:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java"
+ line="236"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.activity_action_export:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java"
+ line="258"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.activity_action_select_all:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java"
+ line="278"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.activity_action_addto_filter:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java"
+ line="283"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.activity_action_take_screenshot:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java"
+ line="563"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.activity_action_show_gpx:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java"
+ line="566"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.activity_action_share_gpx:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java"
+ line="569"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.activity_action_dev_share_raw_summary:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java"
+ line="572"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.activity_action_dev_share_raw_details:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java"
+ line="575"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.check_all_applications:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AppBlacklistActivity.java"
+ line="82"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.uncheck_all_applications:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AppBlacklistActivity.java"
+ line="85"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.action_settings:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="430"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.action_debug:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="434"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.action_data_management:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="438"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.action_notification_management:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="442"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.device_action_discover:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="446"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.action_quit:"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="449"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.donation_link:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="452"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.external_changelog:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="457"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.about:"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="469"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.controlcenter_device_submenu_connect:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java"
+ line="853"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.controlcenter_device_submenu_disconnect:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java"
+ line="859"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.controlcenter_device_submenu_set_alias:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java"
+ line="866"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.controlcenter_device_submenu_set_preferences:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java"
+ line="869"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.controlcenter_device_submenu_remove:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java"
+ line="872"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.controlcenter_device_submenu_show_details:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java"
+ line="875"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.controlcenter_device_submenu_set_parent_folder:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java"
+ line="893"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.controlcenter_device_submenu_installer:"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java"
+ line="896"
+ column="26"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.alarm1:"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetAlarmsActivity.java"
+ line="87"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.alarm2:"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetAlarmsActivity.java"
+ line="90"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.alarm3:"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetAlarmsActivity.java"
+ line="93"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.alarm4:"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetAlarmsActivity.java"
+ line="96"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="NonConstantResourceId"
+ message="Resource IDs will be non-final by default in Android Gradle Plugin version 8.0, avoid using them in switch case statements"
+ errorLine1=" case R.id.alarm5:"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetAlarmsActivity.java"
+ line="99"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="VectorRaster"
+ message="Limit vector icons sizes to 200脳200 to keep icon drawing fast; see https://developer.android.com/studio/write/vector-asset-studio#when for more"
+ errorLine1=" android:height="300dp""
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/res/drawable/gadgetbridge_img.xml"
+ line="4"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="LockedOrientationActivity"
+ message="Expecting `android:screenOrientation="unspecified"` or `"fullSensor"` for this activity so the user can use the application in any orientation and provide a great experience on Chrome OS devices"
+ errorLine1=" android:screenOrientation="portrait""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="513"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="LockedOrientationActivity"
+ message="Expecting `android:screenOrientation="unspecified"` or `"fullSensor"` for this activity so the user can use the application in any orientation and provide a great experience on Chrome OS devices"
+ errorLine1=" android:screenOrientation="portrait" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="614"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="LockedOrientationActivity"
+ message="Expecting `android:screenOrientation="unspecified"` or `"fullSensor"` for this activity so the user can use the application in any orientation and provide a great experience on Chrome OS devices"
+ errorLine1=" android:screenOrientation="portrait""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="619"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="LockedOrientationActivity"
+ message="Expecting `android:screenOrientation="unspecified"` or `"fullSensor"` for this activity so the user can use the application in any orientation and provide a great experience on Chrome OS devices"
+ errorLine1=" android:screenOrientation="portrait""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="625"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="LockedOrientationActivity"
+ message="Expecting `android:screenOrientation="unspecified"` or `"fullSensor"` for this activity so the user can use the application in any orientation and provide a great experience on Chrome OS devices"
+ errorLine1=" android:screenOrientation="portrait""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="631"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="LockedOrientationActivity"
+ message="Expecting `android:screenOrientation="unspecified"` or `"fullSensor"` for this activity so the user can use the application in any orientation and provide a great experience on Chrome OS devices"
+ errorLine1=" android:screenOrientation="portrait""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="637"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="LockedOrientationActivity"
+ message="Expecting `android:screenOrientation="unspecified"` or `"fullSensor"` for this activity so the user can use the application in any orientation and provide a great experience on Chrome OS devices"
+ errorLine1=" android:screenOrientation="portrait""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="786"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="LockedOrientationActivity"
+ message="Expecting `android:screenOrientation="unspecified"` or `"fullSensor"` for this activity so the user can use the application in any orientation and provide a great experience on Chrome OS devices"
+ errorLine1=" android:screenOrientation="portrait""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="813"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="RestrictedApi"
+ message="MenuItemImpl.setVisible can only be called from within the same library group prefix (referenced groupId=`androidx.appcompat` with prefix androidx from groupId=`Gadgetbridge`)"
+ errorLine1=" if (v != null) v.setVisible(false);"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="215"
+ column="30"/>
+ </issue>
+
+ <issue
+ id="PrivateResource"
+ message="Overriding `@string/not_set` which is marked as private in androidx.preference:preference:1.2.1. If deliberate, use tools:override="true", otherwise pick a different name."
+ errorLine1=" <string name="not_set">鏈缃�</string>"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/res/values-zh-rCN/strings.xml"
+ line="2578"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="PrivateResource"
+ message="Overriding `@string/not_set` which is marked as private in androidx.preference:preference:1.2.1. If deliberate, use tools:override="true", otherwise pick a different name."
+ errorLine1=" <string name="not_set">No fijado</string>"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/res/values-es/strings.xml"
+ line="2580"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="PrivateResource"
+ message="Overriding `@string/not_set` which is marked as private in androidx.preference:preference:1.2.1. If deliberate, use tools:override="true", otherwise pick a different name."
+ errorLine1=" <string name="not_set">Non configur茅</string>"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/res/values-fr/strings.xml"
+ line="2585"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="PrivateResource"
+ message="Overriding `@string/not_set` which is marked as private in androidx.preference:preference:1.2.1. If deliberate, use tools:override="true", otherwise pick a different name."
+ errorLine1=" <string name="not_set">Niet ingesteld</string>"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/res/values-nl/strings.xml"
+ line="2587"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="PrivateResource"
+ message="Overriding `@string/not_set` which is marked as private in androidx.preference:preference:1.2.1. If deliberate, use tools:override="true", otherwise pick a different name."
+ errorLine1=" <string name="not_set">賱賲 賷鬲賲 鬲毓賷賷賳賴</string>"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/res/values-ar/strings.xml"
+ line="2591"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="PrivateResource"
+ message="Overriding `@string/not_set` which is marked as private in androidx.preference:preference:1.2.1. If deliberate, use tools:override="true", otherwise pick a different name."
+ errorLine1=" <string name="not_set">Nicht festgelegt</string>"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/res/values-de/strings.xml"
+ line="2592"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="PrivateResource"
+ message="Overriding `@string/not_set` which is marked as private in androidx.preference:preference:1.2.1. If deliberate, use tools:override="true", otherwise pick a different name."
+ errorLine1=" <string name="not_set">袧械 蟹邪写邪薪芯</string>"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/res/values-ru/strings.xml"
+ line="2597"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="PrivateResource"
+ message="Overriding `@string/not_set` which is marked as private in androidx.preference:preference:1.2.1. If deliberate, use tools:override="true", otherwise pick a different name."
+ errorLine1=" <string name="not_set">袧械 薪邪谢邪褕褌芯胁邪薪芯</string>"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/res/values-uk/strings.xml"
+ line="2604"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="PrivateResource"
+ message="Overriding `@string/not_set` which is marked as private in androidx.preference:preference:1.2.1. If deliberate, use tools:override="true", otherwise pick a different name."
+ errorLine1=" <string name="not_set">Nie ustawiono</string>"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/res/values-pl/strings.xml"
+ line="2618"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="PrivateResource"
+ message="Overriding `@string/not_set` which is marked as private in androidx.preference:preference:1.2.1. If deliberate, use tools:override="true", otherwise pick a different name."
+ errorLine1=" <string name="not_set">Not set</string>"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2638"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes"
+ errorLine1=" android:textSize="20dp" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="19"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes"
+ errorLine1=" android:textSize="20dp" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="26"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes"
+ errorLine1=" android:textSize="20dp" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="33"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes"
+ errorLine1=" android:textSize="20dp" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="46"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes"
+ errorLine1=" android:textSize="20dp" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="53"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes"
+ errorLine1=" android:textSize="20dp" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="60"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes"
+ errorLine1=" android:textSize="20dp" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="67"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes (`@dimen/um25_value_text_size` is defined as `60dp` in `values/dimens.xml`"
+ errorLine1=" android:textSize="@dimen/um25_value_text_size" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_um25_data.xml"
+ line="18"
+ column="13"/>
+ <location
+ file="src/main/res/values/dimens.xml"
+ line="17"
+ column="40"
+ message="This dp dimension is used as a text size"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes (`@dimen/um25_value_text_size` is defined as `60dp` in `values/dimens.xml`"
+ errorLine1=" android:textSize="@dimen/um25_value_text_size" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_um25_data.xml"
+ line="32"
+ column="17"/>
+ <location
+ file="src/main/res/values/dimens.xml"
+ line="17"
+ column="40"
+ message="This dp dimension is used as a text size"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes (`@dimen/um25_value_text_size_small` is defined as `30dp` in `values/dimens.xml`"
+ errorLine1=" android:textSize="@dimen/um25_value_text_size_small" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_um25_data.xml"
+ line="39"
+ column="17"/>
+ <location
+ file="src/main/res/values/dimens.xml"
+ line="18"
+ column="46"
+ message="This dp dimension is used as a text size"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes (`@dimen/um25_value_text_size` is defined as `60dp` in `values/dimens.xml`"
+ errorLine1=" android:textSize="@dimen/um25_value_text_size" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_um25_data.xml"
+ line="56"
+ column="17"/>
+ <location
+ file="src/main/res/values/dimens.xml"
+ line="17"
+ column="40"
+ message="This dp dimension is used as a text size"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes (`@dimen/um25_value_text_size_small` is defined as `30dp` in `values/dimens.xml`"
+ errorLine1=" android:textSize="@dimen/um25_value_text_size_small" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_um25_data.xml"
+ line="63"
+ column="17"/>
+ <location
+ file="src/main/res/values/dimens.xml"
+ line="18"
+ column="46"
+ message="This dp dimension is used as a text size"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes (`@dimen/um25_value_text_size` is defined as `60dp` in `values/dimens.xml`"
+ errorLine1=" android:textSize="@dimen/um25_value_text_size" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_um25_data.xml"
+ line="74"
+ column="13"/>
+ <location
+ file="src/main/res/values/dimens.xml"
+ line="17"
+ column="40"
+ message="This dp dimension is used as a text size"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes (`@dimen/um25_value_text_size_small` is defined as `30dp` in `values/dimens.xml`"
+ errorLine1=" android:textSize="@dimen/um25_value_text_size_small""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_um25_data.xml"
+ line="87"
+ column="17"/>
+ <location
+ file="src/main/res/values/dimens.xml"
+ line="18"
+ column="46"
+ message="This dp dimension is used as a text size"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes (`@dimen/um25_value_text_size_small` is defined as `30dp` in `values/dimens.xml`"
+ errorLine1=" android:textSize="@dimen/um25_value_text_size_small" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_um25_data.xml"
+ line="95"
+ column="17"/>
+ <location
+ file="src/main/res/values/dimens.xml"
+ line="18"
+ column="46"
+ message="This dp dimension is used as a text size"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes"
+ errorLine1=" android:textSize="20dp" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_weather_notification.xml"
+ line="14"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes"
+ errorLine1=" android:textSize="25dp""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fossil_hr_row_installed_app.xml"
+ line="10"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes"
+ errorLine1=" android:textSize="20dp""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_app_view.xml"
+ line="19"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="SpUsage"
+ message="Should use "`sp`" instead of "`dp`" for text sizes"
+ errorLine1=" android:textSize="20dp""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_package_settings_item.xml"
+ line="19"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="SwitchIntDef"
+ message="Switch statement on an `int` with known associated constant missing case `PlaybackState.STATE_BUFFERING`, `PlaybackState.STATE_CONNECTING`, `PlaybackState.STATE_ERROR`, `PlaybackState.STATE_FAST_FORWARDING`, `PlaybackState.STATE_NONE`, `PlaybackState.STATE_REWINDING`, `PlaybackState.STATE_SKIPPING_TO_NEXT`, `PlaybackState.STATE_SKIPPING_TO_PREVIOUS`, `PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM`"
+ errorLine1=" switch (s.getState()) {"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/MediaManager.java"
+ line="151"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="DiscouragedApi"
+ message="Use of this function is discouraged because resource reflection makes it harder to perform build optimizations and compile-time verification of code. It is much more efficient to retrieve resources by identifier (e.g. `R.foo.bar`) than by name (e.g. `getIdentifier("bar", "foo", null)`)."
+ errorLine1=" int resId = getResources().getIdentifier(aString, "string", packageName);"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java"
+ line="547"
+ column="36"/>
+ </issue>
+
+ <issue
+ id="DiscouragedApi"
+ message="Use of this function is discouraged because resource reflection makes it harder to perform build optimizations and compile-time verification of code. It is much more efficient to retrieve resources by identifier (e.g. `R.foo.bar`) than by name (e.g. `getIdentifier("bar", "foo", null)`)."
+ errorLine1=" final int stringId = context.getResources().getIdentifier(stringName, "string", context.getPackageName());"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/headphones/protocol/impl/v1/params/NoiseCancellingOptimizerStatus.java"
+ line="43"
+ column="53"/>
+ </issue>
+
+ <issue
+ id="DiscouragedApi"
+ message="Use of this function is discouraged because resource reflection makes it harder to perform build optimizations and compile-time verification of code. It is much more efficient to retrieve resources by identifier (e.g. `R.foo.bar`) than by name (e.g. `getIdentifier("bar", "foo", null)`)."
+ errorLine1=" final int stringId = getContext().getResources().getIdentifier("activity_type_" + workoutType, "string", getContext().getPackageName());"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/WithingsSteelHRDeviceSupport.java"
+ line="692"
+ column="58"/>
+ </issue>
+
+ <issue
+ id="UseCompatLoadingForDrawables"
+ message="Use `ResourcesCompat.getDrawable()`"
+ errorLine1=" entries.add(new PieEntry((float) value, context.getResources().getDrawable(R.drawable.ic_star_gold)));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingAdapter.java"
+ line="165"
+ column="49"/>
+ </issue>
+
+ <issue
+ id="UseCompatLoadingForDrawables"
+ message="Use `ResourcesCompat.getDrawable()`"
+ errorLine1=" images.add(new NotificationImage("icIncomingCall.icon", NotificationImage.getEncodedIconFromDrawable(getContext().getResources().getDrawable(R.drawable.ic_phone_outline)), 24, 24));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java"
+ line="580"
+ column="110"/>
+ </issue>
+
+ <issue
+ id="UseCompatLoadingForDrawables"
+ message="Use `ResourcesCompat.getDrawable()`"
+ errorLine1=" images.add(new NotificationImage("icMissedCall.icon", NotificationImage.getEncodedIconFromDrawable(getContext().getResources().getDrawable(R.drawable.ic_phone_missed_outline)), 24, 24));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java"
+ line="581"
+ column="108"/>
+ </issue>
+
+ <issue
+ id="UseCompatLoadingForDrawables"
+ message="Use `ResourcesCompat.getDrawable()`"
+ errorLine1=" images.add(new NotificationImage("icMessage.icon", NotificationImage.getEncodedIconFromDrawable(getContext().getResources().getDrawable(R.drawable.ic_message_outline)), 24, 24));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java"
+ line="582"
+ column="105"/>
+ </issue>
+
+ <issue
+ id="UseCompatLoadingForDrawables"
+ message="Use `ResourcesCompat.getDrawable()`"
+ errorLine1=" images.add(new NotificationImage("general_white.bin", NotificationImage.getEncodedIconFromDrawable(getContext().getResources().getDrawable(R.drawable.ic_alert_circle_outline)), 24, 24));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java"
+ line="583"
+ column="108"/>
+ </issue>
+
+ <issue
+ id="UseCompatLoadingForDrawables"
+ message="Use `ResourcesCompat.getDrawable()`"
+ errorLine1=" NotificationImage quickReplyIcon = new NotificationImage("icMessage.icon", NotificationImage.getEncodedIconFromDrawable(getContext().getResources().getDrawable(R.drawable.ic_message_outline)), 24, 24);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java"
+ line="625"
+ column="133"/>
+ </issue>
+
+ <issue
+ id="UseCompatLoadingForDrawables"
+ message="Use `ResourcesCompat.getDrawable()`"
+ errorLine1=" entries.add(new PieEntry((float) value, context.getResources().getDrawable(R.drawable.ic_star_gold)));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java"
+ line="1388"
+ column="49"/>
+ </issue>
+
+ <issue
+ id="UseCompatLoadingForDrawables"
+ message="Use `ResourcesCompat.getDrawable()`"
+ errorLine1=" icon = sourcePackageContext.getResources().getDrawable(notificationSpec.iconId);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/NotificationRequestHandler.java"
+ line="81"
+ column="32"/>
+ </issue>
+
+ <issue
+ id="UseCompatLoadingForDrawables"
+ message="Use `AppCompatResources.getDrawable()`"
+ errorLine1=" Drawable drawable = getContext().getDrawable(drawableId);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/WithingsSteelHRDeviceSupport.java"
+ line="703"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="UsingOnClickInXml"
+ message="Use databinding or explicit wiring of click listener in code"
+ errorLine1=" android:onClick="onClick""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/widget_alarms_activity_list.xml"
+ line="63"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UsingOnClickInXml"
+ message="Use databinding or explicit wiring of click listener in code"
+ errorLine1=" android:onClick="onClick""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/widget_alarms_activity_list.xml"
+ line="71"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UsingOnClickInXml"
+ message="Use databinding or explicit wiring of click listener in code"
+ errorLine1=" android:onClick="onClick""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/widget_alarms_activity_list.xml"
+ line="79"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UsingOnClickInXml"
+ message="Use databinding or explicit wiring of click listener in code"
+ errorLine1=" android:onClick="onClick""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/widget_alarms_activity_list.xml"
+ line="87"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UsingOnClickInXml"
+ message="Use databinding or explicit wiring of click listener in code"
+ errorLine1=" android:onClick="onClick""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/widget_alarms_activity_list.xml"
+ line="95"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="StringFormatInvalid"
+ message="Format string '`fw_upgrade_notice_amazfitcor2`' is not a valid format string so it should not be passed to `String.format`"
+ errorLine1=" return mContext.getString(R.string.fw_upgrade_notice_amazfitcor2, helper.getHumanFirmwareVersion());"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2FWInstallHandler.java"
+ line="37"
+ column="16"/>
+ <location
+ file="src/main/res/values-ja/strings.xml"
+ line="612"
+ column="5"
+ message="This definition does not require arguments"/>
+ </issue>
+
+ <issue
+ id="StringFormatInvalid"
+ message="Incorrect formatting string `notif_battery_low_extended`; missing conversion character in '`%1$ a`'?"
+ errorLine1=" <string name="notif_battery_low_extended">%1$ akkumul谩tora mer眉l: %2$s</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-hu/strings.xml"
+ line="462"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="StringFormatInvalid"
+ message="Incorrect formatting string `withings_calibration_text_activity_target`; missing conversion character in '`% a`'?"
+ errorLine1=" <string name="withings_calibration_text_activity_target">Zum Schluss richte den Aktivit盲tszeiger auf 100% aus. Bitte beachte, dass sich dieser Zeiger nur im Uhrzeigersinn bewegt.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-de/strings.xml"
+ line="2340"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="StringFormatMatches"
+ message="Wrong argument type for formatting argument '#1' in `notif_battery_low_extended`: conversion is '`a`', received `String` (argument #2 in method call)"
+ errorLine1=" context.getString(R.string.notif_battery_low_extended, gbDevice.getAliasOrName(),"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java"
+ line="515"
+ column="88"/>
+ <location
+ file="src/main/res/values-hu/strings.xml"
+ line="462"
+ column="5"
+ message="Conflicting argument declaration here"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message="Did you mean "custom_!" instead of "custom_1"?"
+ errorLine1=" <item>custom_1</item>"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values/arrays.xml"
+ line="3347"
+ column="15"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message="Did you mean "after_!" instead of "after_1"?"
+ errorLine1=" <item>after_1_hour</item>"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values/arrays.xml"
+ line="3363"
+ column="15"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message=""pertubar" is a common misspelling; did you mean "perturbar"?"
+ errorLine1=" <string name="pref_summary_notification_filter">Bloqueia todas as notifica莽玫es quando o modo N茫o pertubar est谩 ativo no telefone</string>"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-pt-rBR/strings.xml"
+ line="71"
+ column="102"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message=""Ziffernblatt" is a common misspelling; did you mean "Zifferblatt"?"
+ errorLine1=" <string name="kind_watchface">Ziffernblatt</string>"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-de/strings.xml"
+ line="515"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message=""despues" is a common misspelling; did you mean "despu茅s"?"
+ errorLine1="\nAseg煤rese de instalar el archivo .fw y, a continuaci贸n, el archivo .res. La pulsera se reiniciar谩 despues de instalar el archivo .fw. "
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-es/strings.xml"
+ line="677"
+ column="101"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message=""ultimo" is a common misspelling; did you mean "煤ltimo"?"
+ errorLine1="\nPor favor, aseg煤rese de instalar el archivo .fw primero, el archivo .res a continuaci贸n y por ultimo el archivo .gps. Su reloj se reiniciara despu茅s de instalar el archivo .fw."
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-es/strings.xml"
+ line="793"
+ column="97"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message=""Inicio" is a common misspelling; did you mean "In铆cio"?"
+ errorLine1=" <string name="activity_detail_start_label">Inicio</string>"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-pt/strings.xml"
+ line="939"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message=""Ukranian" is a common misspelling; did you mean "Ukrainian"?"
+ errorLine1=" <string name="ukranian">Ukranian</string>"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1055"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message=""camera" is a common misspelling; did you mean "c芒mera"?"
+ errorLine1=" <string name="menuitem_takephoto">Controlo remoto de camera</string>"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-pt/strings.xml"
+ line="1110"
+ column="58"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message=""construida" is a common misspelling; did you mean "constru铆da"?"
+ errorLine1=" <string name="about_description_banglejs_nopebble">App de Android para Bangle.js construida sobre o projeto Gadgetbridge, com acesso a Internet adicionado."
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-pt-rBR/strings.xml"
+ line="1484"
+ column="86"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message=""construida" is a common misspelling; did you mean "constru铆da"?"
+ errorLine1=" <string name="about_description_banglejs_main">App de Android para Bangle.js construida sobre o projeto Gadgetbridge, com acesso a Internet adicionado."
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-pt-rBR/strings.xml"
+ line="1487"
+ column="82"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message=""construida" is a common misspelling; did you mean "constru铆da"?"
+ errorLine1=" <string name="about_description_banglejs_main">Aplica莽茫o Android para Bangle.js, construida com base no projecto Gadgetbridge, com acesso 脿 Internet."
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-pt/strings.xml"
+ line="1576"
+ column="86"/>
+ </issue>
+
+ <issue
+ id="Typos"
+ message=""construida" is a common misspelling; did you mean "constru铆da"?"
+ errorLine1=" <string name="about_description_banglejs_nightly">Aplica莽茫o Android para Bangle.js, construida com base no projecto Gadgetbridge, com acesso 脿 Internet."
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-pt/strings.xml"
+ line="1586"
+ column="89"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "hr" (Croatian) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-hr/strings.xml"
+ line="8"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "id" (Indonesian) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-id/strings.xml"
+ line="27"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "zh" (Chinese) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-zh-rTW/strings.xml"
+ line="28"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "fi" (Finnish) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-fi/strings.xml"
+ line="38"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "my" (Burmese) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-my/strings.xml"
+ line="39"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "sv" (Swedish) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-sv/strings.xml"
+ line="46"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "lt" (Lithuanian) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-lt/strings.xml"
+ line="62"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "fa" (Persian) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-fa/strings.xml"
+ line="125"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "ro" (Romanian) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ro/strings.xml"
+ line="129"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "vi" (Vietnamese) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-vi/strings.xml"
+ line="189"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "et" (Estonian) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-et/strings.xml"
+ line="279"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "icon_placeholder" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="icon_placeholder">Simge</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="333"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="402"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "hu" (Hungarian) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-hu/strings.xml"
+ line="464"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "uk" (Ukrainian) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-uk/strings.xml"
+ line="473"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "bg" (Bulgarian) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-bg/strings.xml"
+ line="483"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "sk" (Slovak) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-sk/strings.xml"
+ line="491"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_button_action_disabled_value" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_button_action_disabled_value">Bilinmeyen</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="491"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "gl" (Galician) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-gl/strings.xml"
+ line="515"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_media_forward_value" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_media_forward_value">SONRAK陌</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="524"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_media_next_value" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_media_next_value">SONRAK陌</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="526"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_media_pause_value" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_media_pause_value">DURAKLAT</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="528"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_media_play_value" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_media_play_value">OYNAT</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="530"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_media_playpause_value" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_media_playpause_value">A脟KAPAT</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="532"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_media_previous_value" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_media_previous_value">脰NCEK陌</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="534"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_media_rewind_value" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_media_rewind_value">GER陌SAR</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="536"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_media_volumedown_value" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_media_volumedown_value">SESD脺艦脺R</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="538"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_media_volumeup_value" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_media_volumeup_value">SESY脺KSELT</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="540"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "pl" (Polish) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-pl/strings.xml"
+ line="551"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "pt" (Portuguese) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-pt/strings.xml"
+ line="556"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_theme_value_dark" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_theme_value_dark">karanl谋k</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="599"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "pref_theme_value_light" has been marked as translatable="false" elsewhere (usually in the `values` folder), but is translated to "tr" (Turkish) here"
+ errorLine1=" <string name="pref_theme_value_light">ayd谋nl谋k</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="600"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "ko" (Korean) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ko/strings.xml"
+ line="606"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "ru" (Russian) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ru/strings.xml"
+ line="626"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "ca" (Catalan) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ca/strings.xml"
+ line="704"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "it" (Italian) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-it/strings.xml"
+ line="715"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "el" (Greek) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-el/strings.xml"
+ line="741"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "he" (Hebrew) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-he/strings.xml"
+ line="790"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "cs" (Czech) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-cs/strings.xml"
+ line="791"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "zh" (Chinese) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-zh-rCN/strings.xml"
+ line="791"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "nl" (Dutch) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-nl/strings.xml"
+ line="792"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "nb" (Norwegian Bokm氓l) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-nb-rNO/strings.xml"
+ line="795"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "pt" (Portuguese) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-pt-rBR/strings.xml"
+ line="798"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="Untranslatable"
+ message="The resource string "mi2_prefs_button_press_broadcast_default_value" is marked as translatable="false", but is translated to "de" (German) here"
+ errorLine1=" <string name="mi2_prefs_button_press_broadcast_default_value" translatable="false">nodomain.freeyourgadget.gadgetbridge.ButtonPressed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-de/strings.xml"
+ line="801"
+ column="67"/>
+ </issue>
+
+ <issue
+ id="PluralsCandidate"
+ message="Formatting %d followed by words ("minutes"): This should probably be a plural rather than a string"
+ errorLine1=" <string name="pref_auto_fetch_limit_fetches_summary">Fetches every %d minutes</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-en-rGB/strings.xml"
+ line="307"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="PluralsCandidate"
+ message="Formatting %d followed by words ("hour"): This should probably be a plural rather than a string"
+ errorLine1=" <string name="pref_summary_auto_export_interval">Export every %d hour</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-en-rGB/strings.xml"
+ line="359"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="PluralsCandidate"
+ message="Formatting %d followed by words ("hour"): This should probably be a plural rather than a string"
+ errorLine1=" <string name="pref_summary_auto_export_interval">Export every %d hour</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="564"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="PluralsCandidate"
+ message="Formatting %d followed by words ("minutes"): This should probably be a plural rather than a string"
+ errorLine1=" <string name="pref_auto_fetch_limit_fetches_summary">Fetches every %d minutes</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="570"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="PluralsCandidate"
+ message="Formatting %d followed by words ("devices"): This should probably be a plural rather than a string"
+ errorLine1=" <string name="notification_gps_text">Sending GPS location to %1$d devices</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1554"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="PluralsCandidate"
+ message="Formatting %d followed by words ("activities"): This should probably be a plural rather than a string"
+ errorLine1=" <string name="sports_activity_confirm_delete_title">Delete %d activities</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1948"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="PluralsCandidate"
+ message="Formatting %d followed by words ("activities"): This should probably be a plural rather than a string"
+ errorLine1=" <string name="sports_activity_confirm_delete_description">Are you sure you want to delete %d activities?</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1949"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="PluralsCandidate"
+ message="Formatting %d followed by words ("devices"): This should probably be a plural rather than a string"
+ errorLine1=" <string name="info_connected_count">%d devices connected</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2378"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="PluralsCandidate"
+ message="Formatting %d followed by words ("steps"): This should probably be a plural rather than a string"
+ errorLine1=" <string name="steps_streaks_total_steps_average_hint">Total average %d steps per day</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2411"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="PluralsCandidate"
+ message="Formatting %d followed by words ("loyalty"): This should probably be a plural rather than a string"
+ errorLine1=" <string name="loyalty_cards_syncing">Syncing %d loyalty cards to device</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2499"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="StringFormatCount"
+ message="Inconsistent number of arguments in formatting string `fw_upgrade_notice_amazfitcor2`; found both 1 here and 0 in values-ja/strings.xml"
+ errorLine1=" <string name="fw_upgrade_notice_amazfitcor2">You are about to install the %s firmware on your Amazfit Cor 2."
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="142"
+ column="5"/>
+ <location
+ file="src/main/res/values-ja/strings.xml"
+ line="612"
+ column="5"
+ message="Conflicting number of arguments (0) here"/>
+ </issue>
+
+ <issue
+ id="StringFormatCount"
+ message="Inconsistent number of arguments in formatting string `withings_calibration_text_activity_target`; found both 1 here and 0 in values-zh-rCN/strings.xml"
+ errorLine1=" <string name="withings_calibration_text_activity_target">Zum Schluss richte den Aktivit盲tszeiger auf 100% aus. Bitte beachte, dass sich dieser Zeiger nur im Uhrzeigersinn bewegt.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-de/strings.xml"
+ line="2340"
+ column="5"/>
+ <location
+ file="src/main/res/values-zh-rCN/strings.xml"
+ line="2327"
+ column="5"
+ message="Conflicting number of arguments (0) here"/>
+ </issue>
+
+ <issue
+ id="UnusedQuantity"
+ message="For language "zh" (Chinese) the following quantities are not relevant: `one`"
+ errorLine1=" <plurals name="widget_alarm_target_hours">"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-zh-rTW/strings.xml"
+ line="119"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="UnusedQuantity"
+ message="For language "pt" (Portuguese) the following quantities are not relevant: `many`"
+ errorLine1=" <plurals name="widget_alarm_target_hours">"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-pt/strings.xml"
+ line="557"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="UnusedQuantity"
+ message="For language "es" (Spanish) the following quantities are not relevant: `many`"
+ errorLine1=" <plurals name="widget_alarm_target_hours">"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-es/strings.xml"
+ line="750"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="UnusedQuantity"
+ message="For language "pt" (Portuguese) the following quantities are not relevant: `many`"
+ errorLine1=" <plurals name="widget_alarm_target_hours">"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-pt-rBR/strings.xml"
+ line="754"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="UnusedQuantity"
+ message="For language "it" (Italian) the following quantities are not relevant: `many`"
+ errorLine1=" <plurals name="widget_alarm_target_hours">"
+ errorLine2=" ^">
+ <location
+ file="src/main/res/values-it/strings.xml"
+ line="825"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="SetJavaScriptEnabled"
+ message="Using `setJavaScriptEnabled` can introduce XSS vulnerabilities into your application, review carefully"
+ errorLine1=" settings.setJavaScriptEnabled(true);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java"
+ line="214"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="ExportedContentProvider"
+ message="Exported content providers can provide access to potentially sensitive data"
+ errorLine1=" <provider"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="652"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ExportedReceiver"
+ message="Exported receiver does not require permission"
+ errorLine1=" <receiver"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="446"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="SystemPermissionTypo"
+ message="Did you mean `android.permission.BLUETOOTH_ADMIN`?"
+ errorLine1=" android:permission="android.permission.BLUETOOTH,android.permission.BLUETOOTH_ADMIN">"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="476"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="DataExtractionRules"
+ message="The attribute `android:allowBackup` is deprecated from Android 12 and higher and may be removed in future versions. Consider adding the attribute `android:dataExtractionRules` specifying an `@xml` resource which configures cloud backups and device transfers on Android 12 and higher."
+ errorLine1=" android:allowBackup="false""
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="105"
+ column="30"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" mGBDeviceAppAdapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="224"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" mGBDeviceAppAdapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java"
+ line="251"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppBlacklistAdapter.java"
+ line="219"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppBlacklistAdapter.java"
+ line="225"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" adapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppBlacklistAdapter.java"
+ line="303"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" adapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppSpecificNotificationSettingsAppListAdapter.java"
+ line="248"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" actionsListAdapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CommuteActionsActivity.java"
+ line="86"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" mGBAlarmListAdapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureAlarms.java"
+ line="113"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" mGBContactListAdapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureContacts.java"
+ line="142"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" mGBReminderListAdapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureReminders.java"
+ line="173"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" mGBWorldClockListAdapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureWorldClocks.java"
+ line="131"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" mGBWorldClockListAdapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureWorldClocks.java"
+ line="152"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" mGBWorldClockListAdapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureWorldClocks.java"
+ line="188"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" mGBDeviceAdapter.notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="491"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="NotifyDataSetChanged"
+ message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
+ errorLine1=" notifyDataSetChanged();"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java"
+ line="230"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="Recycle"
+ message="This `Cursor` should be freed up after use with `#close()`"
+ errorLine1=" .query(uri, projection, selection, selectionArgs, null);"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/AndroidUtils.java"
+ line="257"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="Recycle"
+ message="This `Cursor` should be freed up after use with `#close()`"
+ errorLine1=" Cursor cursor = getContentResolver().query("
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java"
+ line="232"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="Recycle"
+ message="This `Cursor` should be freed up after use with `#close()`"
+ errorLine1=" final Cursor c = mContext.getContentResolver().query(SETTINGS_URI, SETTINGS_PROJECTION,"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/OmniJawsObserver.java"
+ line="166"
+ column="56"/>
+ </issue>
+
+ <issue
+ id="Recycle"
+ message="This `Cursor` should be freed up after use with `#close()`"
+ errorLine1=" Cursor cursor = requireContext().getContentResolver().query("
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java"
+ line="552"
+ column="75"/>
+ </issue>
+
+ <issue
+ id="ObsoleteLayoutParam"
+ message="Invalid layout param in a `RelativeLayout`: `layout_weight`"
+ errorLine1=" android:layout_weight="1""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_commute_actions.xml"
+ line="17"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="ObsoleteLayoutParam"
+ message="Invalid layout param in a `LinearLayout`: `layout_alignStart`"
+ errorLine1=" android:layout_alignStart="@+id/watchface_invert_colors""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="60"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="ObsoleteLayoutParam"
+ message="Invalid layout param in a `LinearLayout`: `layout_alignStart`"
+ errorLine1=" android:layout_alignStart="@+id/watchface_rotate_left""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="67"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="ObsoleteLayoutParam"
+ message="Invalid layout param in a `LinearLayout`: `layout_alignStart`"
+ errorLine1=" android:layout_alignStart="@+id/watchface_rotate_right""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="75"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="ObsoleteLayoutParam"
+ message="Invalid layout param in a `RelativeLayout`: `layout_weight`"
+ errorLine1=" android:layout_weight="20""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_statschart.xml"
+ line="11"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AppSpecificNotificationSettingDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BangleJSActivitySampleDao.java"
+ line="63"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"BATTERY_INDEX\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/BatteryLevelDao.java"
+ line="58"
+ column="64"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CasioGBX100ActivitySampleDao.java"
+ line="61"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfActivitySampleDao.java"
+ line="67"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfHeartRateSampleDao.java"
+ line="57"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSleepSessionSampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSleepStageSampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfSpo2SampleDao.java"
+ line="57"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfStressSampleDao.java"
+ line="57"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/CmfWorkoutGpsSampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/FemometerVinca2TemperatureSampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/FitProActivitySampleDao.java"
+ line="73"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HPlusHealthActivityOverlayDao.java"
+ line="63"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"RAW_KIND\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HPlusHealthActivitySampleDao.java"
+ line="70"
+ column="59"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiExtendedActivitySampleDao.java"
+ line="71"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateManualSampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateMaxSampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiHeartRateRestingSampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiPaiSampleDao.java"
+ line="73"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiSleepRespiratoryRateSampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiSpo2SampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuamiStressSampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"SOURCE\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiActivitySampleDao.java"
+ line="75"
+ column="57"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"TIMESTAMP\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiWorkoutDataSampleDao.java"
+ line="83"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"TYPE\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HuaweiWorkoutPaceSampleDao.java"
+ line="60"
+ column="55"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/HybridHRActivitySampleDao.java"
+ line="71"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ID115ActivitySampleDao.java"
+ line="65"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/JYouActivitySampleDao.java"
+ line="67"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunActivitySampleDao.java"
+ line="65"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunBiometricSampleDao.java"
+ line="61"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/LefunSleepSampleDao.java"
+ line="57"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/MakibesHR3ActivitySampleDao.java"
+ line="61"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/MiBandActivitySampleDao.java"
+ line="63"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/No1F1ActivitySampleDao.java"
+ line="63"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleHealthActivityOverlayDao.java"
+ line="63"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleHealthActivitySampleDao.java"
+ line="63"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleMisfitSampleDao.java"
+ line="57"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PebbleMorpheuzSampleDao.java"
+ line="57"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/PineTimeActivitySampleDao.java"
+ line="61"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pinetime/PineTimeJFSupport.java"
+ line="524"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/SonySWR12SampleDao.java"
+ line="63"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/TLW64ActivitySampleDao.java"
+ line="61"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/TransactionBuilder.java"
+ line="79"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/VivomoveHrActivitySampleDao.java"
+ line="67"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"RAW_KIND\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WatchXPlusActivitySampleDao.java"
+ line="70"
+ column="59"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WatchXPlusHealthActivityOverlayDao.java"
+ line="63"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3ActivitySampleDao.java"
+ line="61"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3BehaviorSampleDao.java"
+ line="61"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3CaloriesSampleDao.java"
+ line="57"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3EnergySampleDao.java"
+ line="57"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3HeartRateSampleDao.java"
+ line="57"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3StressSampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DATAPOINT\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/Wena3Vo2SampleDao.java"
+ line="60"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/WithingsSteelHRActivitySampleDao.java"
+ line="69"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XWatchActivitySampleDao.java"
+ line="63"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiActivitySampleDao.java"
+ line="67"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiDailySummarySampleDao.java"
+ line="105"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiManualSampleDao.java"
+ line="59"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiSleepStageSampleDao.java"
+ line="57"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/XiaomiSleepTimeSampleDao.java"
+ line="69"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="Unnecessary; SDK_INT is always >= 21"
+ errorLine1=" "\"DEVICE_ID\" ) ON CONFLICT REPLACE)" + ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ? " WITHOUT ROWID;" : ";")"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/ZeTimeActivitySampleDao.java"
+ line="69"
+ column="60"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="This folder configuration (`v21`) is unnecessary; `minSdkVersion` is 21. Merge all the resources in this folder into `drawable`.">
+ <location
+ file="src/main/res/drawable-v21"/>
+ </issue>
+
+ <issue
+ id="ObsoleteSdkInt"
+ message="This folder configuration (`v14`) is unnecessary; `minSdkVersion` is 21. Merge all the resources in this folder into `values`.">
+ <location
+ file="src/main/res/values-v14"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="This `AsyncTask` class should be static or leaks might occur (nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport.StoreDataTask)"
+ errorLine1=" public class StoreDataTask extends DBAccess {"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java"
+ line="704"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="This `AsyncTask` class should be static or leaks might occur (nodomain.freeyourgadget.gadgetbridge.activities.charts.ActivityListingDashboard.RefreshTask)"
+ errorLine1=" public class RefreshTask extends DBAccess {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingDashboard.java"
+ line="291"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="This `AsyncTask` class should be static or leaks might occur (nodomain.freeyourgadget.gadgetbridge.activities.ActivitySummariesChartFragment.RefreshTask)"
+ errorLine1=" public class RefreshTask extends DBAccess {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesChartFragment.java"
+ line="179"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="This `AsyncTask` class should be static or leaks might occur (nodomain.freeyourgadget.gadgetbridge.activities.BatteryInfoChartFragment.RefreshTask)"
+ errorLine1=" public class RefreshTask extends DBAccess {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BatteryInfoChartFragment.java"
+ line="213"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="This `AsyncTask` class should be static or leaks might occur (nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2.RefreshTask)"
+ errorLine1=" public class RefreshTask extends DBAccess {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java"
+ line="674"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="This field leaks a context object"
+ errorLine1=" private final Context mContext;"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBAccess.java"
+ line="29"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="Do not place Android context classes in static fields (static reference to `LineageWeatherManager` which has field `mContext` pointing to `Context`); this is a memory leak"
+ errorLine1=" private static LineageWeatherManager sInstance;"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/lineageos/weather/LineageWeatherManager.java"
+ line="51"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="This `AsyncTask` class should be static or leaks might occur (nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter.RefreshTask)"
+ errorLine1=" public class RefreshTask extends DBAccess {"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicExporter.java"
+ line="95"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="This `AsyncTask` class should be static or leaks might occur (nodomain.freeyourgadget.gadgetbridge.activities.charts.StepStreaksDashboard.TaskCalculateLatestStepsStreak)"
+ errorLine1=" public class TaskCalculateLatestStepsStreak extends DBAccess {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java"
+ line="241"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="StaticFieldLeak"
+ message="Do not place Android context classes in static fields (static reference to `WebViewSingleton` which has field `webView` pointing to `WebView`); this is a memory leak"
+ errorLine1=" private static final WebViewSingleton instance = new WebViewSingleton();"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java"
+ line="59"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UseCompoundDrawables"
+ message="This tag and its children can be replaced by one `<TextView/>` and a compound drawable"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="86"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="UseCompoundDrawables"
+ message="This tag and its children can be replaced by one `<TextView/>` and a compound drawable"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="123"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="UseCompoundDrawables"
+ message="This tag and its children can be replaced by one `<TextView/>` and a compound drawable"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="153"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="UseCompoundDrawables"
+ message="This tag and its children can be replaced by one `<TextView/>` and a compound drawable"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="183"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="UseCompoundDrawables"
+ message="This tag and its children can be replaced by one `<TextView/>` and a compound drawable"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="213"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="UseCompoundDrawables"
+ message="This tag and its children can be replaced by one `<TextView/>` and a compound drawable"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_gps.xml"
+ line="7"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UseCompoundDrawables"
+ message="This tag and its children can be replaced by one `<TextView/>` and a compound drawable"
+ errorLine1="<LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/spinner_with_image_layout.xml"
+ line="1"
+ column="2"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (3438 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M391.439 251.5c-16.53-0.137-32.726-5.052-49-7.576-31.323-4.857-62.605-10.178-94-14.554-11.34-1.581-22.657-3.315-34-4.87-3.544-0.486-9.985-0.271-11.683-4.148-2.661-6.074 3.215-15.79 5.989-20.852 9.17-16.735 19.424-32.9 29.64-49 26.59-41.901 56.624-85.123 95.054-117.08 25.306-21.043 54.596-33.544 87-20.467 13.073 5.276 24.874 14.036 34.155 24.548 7.445 8.431 14.372 18.176 17.816 29 4.549 14.293 11.03 39.431 4.977 53.999-5.502 13.241-20.4 23.207-29.662 34-15.78 18.385-28.437 38.888-40.147 60-5.488 9.893-15.517 24.17-14.96 35.981 0.352 7.455 22.806 13.067 28.82 16.328 21.17 11.476 40.696 28.364 50.689 50.691 18.396 41.106-0.666 100.512-34.69 128.535-10.353 8.529-23.217 13.179-35.998 16.72-35.687 9.887-74.001 0.29-110-1.255-1.397 17.704-4.591 36.596 0.774 54 9.793 31.77 49.168 55.388 82.226 47.402 32.92-7.953 54.022-37.177 77.089-59.294 4.322-4.143 10.976-4.135 15.068-8.032 5.05-4.808 6.756-15.863 10.027-22.076 5.03-9.552 12.956-18.21 19.237-27 38.268-53.553 71.679-110.568 103.719-168 8.826-15.82 17.512-31.797 25.613-48 2.81-5.62 4.46-16.206 8.76-20.486 4.169-4.15 13.16-3.887 18.487-6.306 37.106-16.857 75.799-10.058 105 18.796 6.41 6.338 13.31 12.628 17.1 20.996 16.27 36.011-0.86 79.314-13.68 113-22.504 59.17-57.511 114.154-93.395 166-5.988 8.652-6.82 20.868-12.868 28.725-3.998 5.193-12.115 6-16.718 11.29-10.773 12.382-20.651 27.824-34.44 37.135-26.993 18.228-73.226 17.881-101 2.538-26.892-14.858-32.244-45.823-33.624-73.688-0.5-10.078 3.588-22.7-1.375-32m12-379c17.823-3.679 32.752-13.717 51-16.56 38.387-5.984 78.04 16.747 92.547 52.56 7.349 18.142 5.142 37.032 10.453 55m21 344c8.3 11.537 3.86 33.142 7.746 47 11.464 40.89 48.099 57.862 88.254 49.331 56.57-12.018 97.41-63.857 127.67-109.33 6.99-10.508 12.44-24.387 20.96-33.61 2.88-3.113 10.21-7.579 14.05-4.03 5.94 5.48 3.32 26.012 3.32 33.64 0 32.984 0.29 66.192-2.83 99-5.99 62.92-7.24 134.76-42.22 190-11.86 18.727-29.51 34.486-45.95 49.165-32.43 28.959-66.94 55.856-103 80.164-15.844 10.68-34.304 20-48.891 32.28-5.808 4.89-4.11 13.58-4.11 20.39v54c0 5.29 2.608 20.69-2.317 23.97-4.938 3.3-16.896 1.03-22.682 1.03h-342c-6.208 0-19.404 2.49-24.682-1.03-5.401-3.6-2.318-23.03-2.318-28.97 0-21.69 5.279-56.45-8.804-74-71.457-89.076-142.18-180.028-199.62-279-24.185-41.671-53.67-88.059-58.406-137-9.294-96.034 42.012-191.579 91.777-270 12.162-19.166 25.178-38.033 39.064-56 4.758-6.156 9.917-16.188 17.004-19.821 11.35-5.818 31.627 1.813 43.985 1.821m576 99c44.15-9.108 86.54-30.774 128 0.375 7.04 5.292 12.9 11.496 18.08 18.625 6.29 8.669 10.42 17.756 13.34 28 3.2 11.222 6.24 23.209 5.49 35-1.67 26.166-15.3 46.19-24.44 70-3.25 8.446-3.75 18.156-5.47 27m-746-75c27.09 12.008 57.317 19.456 86 26.627 12.377 3.094 29.002 4.03 40 10.373m-16 622c-12.048 6.43-35.402 2-49 2-3.938 0-14.254-1.83-16.972 1.6-1.8 2.28-1.028 6.71-1.028 9.4v149c0 2.88-0.841 7.84 2.318 9.4 9.22 4.53 29.38 0.6 39.682 0.6h458c11.273 0 27.059 2.89 37.852-0.32 4.03-1.19 3.147-6.39 3.148-9.68v-149c0-2.69 0.772-7.12-1.028-9.4-3.055-3.85-16.446-1.6-20.972-1.6-17.678 0-41.071 4-58-1m-324 40.42c20.064-2.63 41.76-0.42 62-0.42h185c12.293 0 26.03-1.26 38 2.06 8.262 2.29 11.04 9.28 12.385 16.94 2.832 16.13 2.326 37.74 0.329 54-0.937 7.63-2.603 15.93-10.714 18.97-8.04 3.01-17.597 2.03-26 2.03h-251c-8.672 0-18.87 1.13-26.985-2.65-11.262-5.25-9.015-23.28-9.015-33.35 0-13.97-5.538-43.55 7.105-53.2 5.35-4.08 12.57-3.54 18.895-4.38m-63 131.58v127l1.028 10.4 20.972 1.6h343l20.397-1.6 1.603-10.4v-127""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/gadgetbridge_img.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (902 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="m21.9184,3.7795c-1.7312,0 -3.1175,1.3863 -3.1175,3.1175 0,1.7312 1.3863,3.1175 3.1175,3.1175 1.7312,0 3.1175,-1.3863 3.1175,-3.1175 0,-1.7312 -1.3863,-3.1175 -3.1175,-3.1175zM11.3487,9.7812C10.5363,9.8097 9.8221,10.1506 9.5939,10.9207L6.5508,21.9333c-0.613,2.3209 4.2012,3.7931 4.9501,1.4641L14.3851,12.2833C14.8084,10.8183 12.8998,9.7267 11.3487,9.7812ZM18.3174,10.4101c-0.3116,0 -0.5886,0.0521 -0.8656,0.1386 -1.82,0.2388 -2.6137,4.4328 -2.6137,4.4328L8.069,41.5207h3.1175l4.967,-14.0388 4.6627,0.8622 1.7075,7.8072 2.9518,-0.6221 -1.5452,-9.6263 -4.9366,-3.3373 1.2612,-4.9704 1.8698,3.1513 6.6948,-2.0761 3.415,15.1411 0.7134,-0.1454 -3.5165,-15.1851 0.4666,-0.1454 -0.6695,-2.8774 -0.4632,0.142 -0.186,-0.798 -0.6019,0.142 0.1893,0.8419 -4.1285,1.2713 -3.1919,-5.1936c-0.5194,-0.8656 -1.4558,-1.4539 -2.5292,-1.4539zM38.8583,29.9603 L33.5194,34.5419 21.462,37.0981 15.8931,41.5748 38.8583,41.5207z" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_activity_climbing.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1632 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M22 21c-1.11 0-1.73-0.37-2.18-0.64-0.37-0.22-0.6-0.36-1.15-0.36-0.56 0-0.78 0.13-1.15 0.36-0.46 0.27-1.07 0.64-2.18 0.64s-1.73-0.37-2.18-0.64c-0.37-0.22-0.6-0.36-1.15-0.36-0.56 0-0.78 0.13-1.15 0.36C10.4 20.63 9.78 21 8.67 21c-1.11 0-1.73-0.37-2.18-0.64C6.12 20.13 5.89 20 5.34 20s-0.78 0.13-1.15 0.36C3.73 20.63 3.11 21 2 21v-2c0.56 0 0.78-0.13 1.15-0.36C3.61 18.37 4.23 18 5.34 18s1.73 0.37 2.18 0.64C7.89 18.87 8.11 19 8.67 19c0.56 0 0.78-0.13 1.15-0.36 0.46-0.27 1.08-0.64 2.19-0.64 1.11 0 1.73 0.37 2.18 0.64 0.37 0.22 0.6 0.36 1.15 0.36s0.78-0.13 1.15-0.36c0.45-0.27 1.07-0.64 2.18-0.64s1.73 0.37 2.18 0.64C21.22 18.87 21.44 19 22 19v2zm0-4.5c-1.11 0-1.73-0.37-2.18-0.64-0.37-0.22-0.6-0.36-1.15-0.36-0.56 0-0.78 0.13-1.15 0.36-0.45 0.27-1.07 0.64-2.18 0.64s-1.73-0.37-2.18-0.64c-0.37-0.22-0.6-0.36-1.15-0.36-0.56 0-0.78 0.13-1.15 0.36-0.45 0.27-1.07 0.64-2.18 0.64s-1.73-0.37-2.18-0.64c-0.37-0.22-0.6-0.36-1.15-0.36s-0.78 0.13-1.15 0.36C3.73 16.13 3.11 16.5 2 16.5v-2c0.56 0 0.78-0.13 1.15-0.36 0.45-0.27 1.07-0.64 2.18-0.64s1.73 0.37 2.18 0.64c0.37 0.22 0.6 0.36 1.15 0.36 0.56 0 0.78-0.13 1.15-0.36 0.45-0.27 1.07-0.64 2.18-0.64s1.73 0.37 2.18 0.64c0.37 0.22 0.6 0.36 1.15 0.36s0.78-0.13 1.15-0.36c0.45-0.27 1.07-0.64 2.18-0.64s1.73 0.37 2.18 0.64c0.37 0.22 0.6 0.36 1.15 0.36v2zM8.67 12c0.56 0 0.78-0.13 1.15-0.36 0.46-0.27 1.08-0.64 2.19-0.64 1.11 0 1.73 0.37 2.18 0.64 0.37 0.22 0.6 0.36 1.15 0.36s0.78-0.13 1.15-0.36c0.12-0.07 0.26-0.15 0.41-0.23L10.48 5C8.93 3.45 7.5 2.99 5 3v2.5C6.82 5.49 7.89 5.89 9 7l1 1-3.25 3.25c0.31 0.12 0.56 0.27 0.77 0.39C7.89 11.87 8.11 12 8.67 12zM14 5.5a2.5 2.5 0 1 1 5 0 2.5 2.5 0 1 1-5 0" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_activity_swimming.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (871 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" <path android:fillColor="#000000" android:pathData="M2.873 9.063C0.861 8.728-0.35 6.263 0.452 4.14c0.698-1.85 1.22-2.386 2.716-2.793C3.791 1.177 6.401 1.146 7.05 1.3c1.278 0.302 1.887 0.728 2.37 1.655 0.707 1.36 0.896 2.223 0.719 3.275C9.88 7.76 8.846 8.841 7.416 9.075 6.858 9.166 3.437 9.157 2.872 9.063zm4.644-0.155c0.604-0.11 1.305-0.487 1.655-0.887 0.307-0.353 0.686-1.108 0.768-1.532l0.053-0.274-0.229 0.244c-0.332 0.355-0.774 0.623-1.34 0.81-1.136 0.378-5.214 0.387-6.41 0.015C1.456 7.11 0.82 6.728 0.58 6.422L0.417 6.215l0.047 0.274c0.07 0.417 0.462 1.196 0.773 1.54 0.331 0.367 0.958 0.724 1.5 0.856 0.506 0.123 4.14 0.14 4.78 0.023zM8.082 7.24c0.917-0.232 1.683-0.775 1.9-1.348 0.209-0.547-0.02-1.558-0.622-2.756-0.47-0.93-1.06-1.376-2.2-1.665-0.677-0.172-3.41-0.148-4.066 0.036-1.07 0.3-1.649 0.765-2.081 1.677-0.995 2.096-0.885 3.04 0.434 3.73 0.83 0.434 1.469 0.509 4.07 0.476C7.078 7.37 7.711 7.333 8.08 7.24z" android:strokeWidth="0.4" android:strokeColor="#000000"/>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_buds_pro_case.xml"
+ line="2"
+ column="57"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (928 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M4.654 2.265h0.368v2.423h1.324V5H4.654zM4.97 9.917c0.357-0.15 0.823-0.536 0.969-0.805C6.01 8.978 5.964 8.977 5.463 9.1 4.48 9.34 3.784 9.109 3.157 8.33c-0.26-0.324-0.296-0.445-0.34-1.178l-0.05-0.817L2.512 6.64C1.69 7.62 2.051 9.288 3.213 9.862c0.489 0.243 1.251 0.266 1.757 0.055zm-1.33 0.329c-0.542-0.13-0.867-0.328-1.226-0.747-0.606-0.708-0.727-1.776-0.3-2.636C2.25 6.592 2.438 6.329 2.53 6.279c0.158-0.085 0.16-0.12 0.026-0.496-0.352-0.992-0.21-2.516 0.328-3.504 0.33-0.604 1.043-1.243 1.604-1.434 1.115-0.38 2.68 0.042 3.461 0.935 1.212 1.384 1.528 3.218 0.817 4.731C8.528 7.02 7.551 8.008 6.84 8.461c-0.343 0.22-0.564 0.444-0.677 0.69-0.372 0.803-1.569 1.322-2.523 1.095zm2.199-1.497c0.978-0.372 2.47-1.672 2.795-2.435 0.783-1.84-0.119-4.304-1.87-5.11-0.807-0.37-1.79-0.385-2.471-0.037C2.842 1.907 2.187 4.02 2.829 5.89c0.096 0.281 0.176 0.771 0.177 1.088 0.005 1.232 0.672 1.948 1.818 1.949 0.305 0 0.75-0.078 1.015-0.179z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_buds_pro_left.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1231 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M5.69 3.701q0.117 0.04 0.228 0.172 0.113 0.132 0.226 0.363l0.373 0.748H6.122L5.775 4.282Q5.64 4.007 5.513 3.917q-0.126-0.09-0.344-0.09h-0.4v1.157H4.4V2.248h0.83q0.466 0 0.695 0.196 0.229 0.196 0.229 0.592 0 0.258-0.12 0.429Q5.917 3.635 5.689 3.7zM4.768 2.552v0.971H5.23q0.265 0 0.4-0.122 0.137-0.125 0.137-0.365T5.63 2.676q-0.135-0.124-0.4-0.124zm1.26 7.365C5.672 9.767 5.206 9.381 5.06 9.112 4.99 8.978 5.036 8.977 5.537 9.1c0.984 0.24 1.68 0.009 2.306-0.77 0.26-0.324 0.296-0.445 0.34-1.178l0.05-0.817 0.256 0.306c0.822 0.98 0.46 2.647-0.702 3.221-0.489 0.243-1.251 0.266-1.757 0.055zM5.162 8.749c-0.978-0.372-2.47-1.672-2.795-2.435-0.783-1.84 0.119-4.304 1.87-5.11 0.807-0.37 1.79-0.385 2.471-0.037 1.45 0.74 2.105 2.853 1.463 4.723-0.096 0.282-0.176 0.77-0.177 1.089-0.005 1.232-0.672 1.947-1.818 1.949-0.305 0-0.75-0.078-1.015-0.18zm2.2 1.497c0.541-0.13 0.866-0.328 1.225-0.747 0.606-0.708 0.727-1.776 0.3-2.636C8.75 6.592 8.562 6.329 8.47 6.279c-0.158-0.085-0.16-0.12-0.026-0.496 0.352-0.992 0.21-2.516-0.328-3.504-0.33-0.604-1.043-1.243-1.604-1.434-1.115-0.38-2.68 0.042-3.461 0.935-1.212 1.384-1.528 3.218-0.817 4.731 0.238 0.508 1.215 1.497 1.926 1.95 0.343 0.22 0.564 0.444 0.677 0.69 0.372 0.803 1.569 1.322 2.523 1.095z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_buds_pro_right.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (945 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M19 3H5C3.9 3 3 3.9 3 5v14c0 1.1 0.9 2 2 2h14c1.1 0 2-0.9 2-2V5c0-1.1-0.9-2-2-2zm-5.44 3.53c0.29-0.29 0.77-0.29 1.06 0l0.88 0.88 0.88-0.88c0.29-0.29 0.77-0.29 1.06 0 0.29 0.29 0.29 0.77 0 1.06l-0.88 0.88 0.88 0.88c0.29 0.29 0.29 0.77 0 1.06-0.29 0.29-0.77 0.29-1.06 0L15.5 9.54l-0.88 0.88c-0.29 0.29-0.77 0.29-1.06 0-0.29-0.29-0.29-0.77 0-1.06l0.88-0.88-0.88-0.88c-0.3-0.3-0.3-0.78 0-1.07zM7 7.72h3.5c0.41 0 0.75 0.34 0.75 0.75s-0.34 0.75-0.75 0.75H7c-0.41 0-0.75-0.34-0.75-0.75S6.59 7.72 7 7.72zM10.75 16H9.5v1.25C9.5 17.66 9.16 18 8.75 18S8 17.66 8 17.25V16H6.75C6.34 16 6 15.66 6 15.25s0.34-0.75 0.75-0.75H8v-1.25c0-0.41 0.34-0.75 0.75-0.75s0.75 0.34 0.75 0.75v1.25h1.25c0.41 0 0.75 0.34 0.75 0.75S11.16 16 10.75 16zm6.5 1.25h-3.5c-0.41 0-0.75-0.34-0.75-0.75s0.34-0.75 0.75-0.75h3.5c0.41 0 0.75 0.34 0.75 0.75s-0.34 0.75-0.75 0.75zm0-2.5h-3.5C13.34 14.75 13 14.41 13 14s0.34-0.75 0.75-0.75h3.5c0.41 0 0.75 0.34 0.75 0.75s-0.34 0.75-0.75 0.75z" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_calculate.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1901 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M18.895 15.63l0.248 0.042v-0.25h0.208l0.041-0.248H19.6v-0.207h0.25v-0.25h0.207v-0.206h0.248v-0.25h0.208v-0.207h0.248v-0.248h0.25v-0.208h0.207V13.35h0.248v-0.208h0.208v-0.249h0.248v-0.207h0.208v-0.249h0.249v-0.912H22.17v-0.207h-0.249l-0.248-0.042h-0.208V11.07h-2.073v0.207h-0.456v0.25H18.48v0.206h-0.456v-0.207h-0.249v-0.497h-0.207V10.57h-0.249v-0.207h-0.207v-0.249h-0.25V9.908h-0.206V9.659H16.2V9.41h-0.457V9.203H15.04V8.954h-1.161V8.747L13.67 8.706h-3.234v0.248H9.524v0.208H8.818V9.41H8.613v0.208H8.156L8.114 9.867H7.907l-0.25-0.042v0.249H7.452v0.249H7.2v0.207H6.996v0.249h-0.25v0.207H6.539l-0.041 0.249v0.207H6.29v0.456H6.04v0.456H5.835v0.706H6.04v-0.458h0.25v-0.455h0.207v-0.456h0.249v-0.456h0.249v-0.249h0.207v-0.207H7.45v-0.25h0.207v-0.206h0.249v-0.25h0.457V9.868h0.456v-0.25h2.778V9.41H9.731V9.162h0.954V8.954h2.986v0.25h0.207V9.41h0.248V9.203h0.208V9.41h1.161v0.457h0.207l0.249 0.041h0.456v0.207h0.249v0.25h0.207v0.206h0.249v0.25h0.207v0.662h-0.207l-0.042 0.248h0.705v0.459h0.456v-0.21h0.456v-0.249h0.456v-0.207h0.498v-0.249h1.576v0.25h0.704v0.248h0.208v0.207h-0.912v0.249h-0.664v0.207h-0.497v0.25h-0.208L19.6 12.644v0.248h-0.207v0.25h-0.249v0.207h-0.207v0.248h-0.249v0.208H18.48l-0.042 0.248h-0.207v0.208h-0.249v0.249h-0.207v0.207h-0.249v0.249H17.32v0.207h-0.25v0.249h-0.249v0.207h-0.207v0.249h-0.249v0.207h-0.207v0.249h-0.249v0.207h-0.207v0.249h-0.456l-0.042 0.207h-0.456v0.25h-0.663v0.206h-1.162v0.25h5.972v-0.208h-2.737v-0.498h0.207V16.79h0.25v-0.249h0.206v-0.207h1.618v0.249h0.911v-0.25h2.529l0.249 0.042v0.208h0.457v0.705h-0.249v0.207H22.13v0.249h-0.25v0.207h-0.207V18.2h-0.456l-0.042 0.207h-0.663v0.249h-0.706v0.207h-0.662l-0.041 0.249h-0.914v0.207h-0.91v0.25H16.82l-0.248-0.042v0.248h-0.208v0.25h1.617v-0.25h0.459V19.32h0.91v-0.207h0.705v-0.249h0.664v-0.207h0.705v-0.249h0.456v-0.207h0.249v-0.249h0.207v-0.207h0.249v-0.25h0.207v-0.662l0.042-0.248v-0.208h-0.25v-0.248h-3.896v-0.25h0.208z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_flipper.xml"
+ line="56"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1901 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M20.056 14.718V14.51h0.248v-0.25h0.208v-0.207h0.248v-0.248h0.25v-0.208h0.207V13.35h0.248v-0.208h0.208v-0.249h0.248v-0.207h0.208v-0.249h0.249v-0.912H22.17v-0.207h-0.249l-0.248-0.042h-0.208V11.07h-2.073v0.207h-0.456v0.25H18.48v0.206h-0.456v-0.207h-0.249v-0.497h-0.207V10.57h-0.249v-0.207h-0.207v-0.249h-0.25V9.908h-0.206V9.659H16.2V9.41h-0.457V9.203H15.04V8.954h-1.161V8.747L13.67 8.706h-3.234v0.248H9.524v0.208H8.818V9.41H8.613v0.208H8.156L8.114 9.867H7.907l-0.25-0.042v0.249H7.452v0.249H7.2v0.207H6.996v0.249h-0.25v0.207H6.539l-0.041 0.249v0.207H6.29v0.456H6.04v0.456H5.835v0.706H6.04v-0.458h0.25v-0.455h0.207v-0.456h0.249v-0.456h0.249v-0.249h0.207v-0.207H7.45v-0.25h0.207v-0.206h0.249v-0.25h0.457V9.868h0.456v-0.25h2.778V9.41H9.731V9.162h0.954V8.954h2.986v0.25h0.207V9.41h0.248V9.203h0.208V9.41h1.161v0.457h0.207l0.249 0.041h0.456v0.207h0.249v0.25h0.207v0.206h0.249v0.25h0.207v0.662h-0.207l-0.042 0.248h0.705v0.459h0.456v-0.21h0.456v-0.249h0.456v-0.207h0.498v-0.249h1.576v0.25h0.704v0.248h0.208v0.207h-0.912v0.249h-0.664v0.207h-0.497v0.25h-0.208L19.6 12.644v0.248h-0.207v0.25h-0.249v0.207h-0.207v0.248h-0.249v0.208H18.48l-0.042 0.248h-0.207v0.208h-0.249v0.249h-0.207v0.207h-0.249v0.249H17.32v0.207h-0.25v0.249h-0.249v0.207h-0.207v0.249h-0.249v0.207h-0.207v0.249h-0.249v0.207h-0.207v0.249h-0.456l-0.042 0.207h-0.456v0.25h-0.663v0.206h-1.162v0.25h5.972v-0.208h-2.737v-0.498h0.207V16.79h0.25v-0.249h0.206v-0.207h1.618v0.249h0.911v-0.25h2.529l0.249 0.042v0.208h0.457v0.705h-0.249v0.207H22.13v0.249h-0.25v0.207h-0.207V18.2h-0.456l-0.042 0.207h-0.663v0.249h-0.706v0.207h-0.662l-0.041 0.249h-0.914v0.207h-0.91v0.25H16.82l-0.248-0.042v0.248h-0.208v0.25h1.617v-0.25h0.459V19.32h0.91v-0.207h0.705v-0.249h0.664v-0.207h0.705v-0.249h0.456v-0.207h0.249v-0.249h0.207v-0.207h0.249v-0.25h0.207v-0.662l0.042-0.248v-0.208h-0.25v-0.248h-3.896v-0.25h0.208V15.63l0.248 0.041v-0.248h0.208l0.041-0.25H19.6v-0.206h0.25v-0.25z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_flipper_disabled.xml"
+ line="61"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1309 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M6.8,6.95C6.22,6.95 5.75,7.42 5.75,8L5.75,20.92C5.73,21 5.74,21.08 5.77,21.16C5.87,21.63 6.3,22 6.8,22L20.45,22C20.97,22 21.4,21.61 21.48,21.1C21.48,21.08 21.49,21.06 21.49,21.04C21.49,21.03 21.49,21.02 21.49,21.02L21.5,20.96C21.5,20.96 21.5,20.95 21.5,20.95L23.24,11.57L23.25,11.53L23.25,11.5C23.25,10.92 22.78,10.45 22.2,10.45L22.2,9.4C22.2,8.82 21.73,8.35 21.15,8.35L12.06,8.35C12.06,8.35 12.06,8.35 12.05,8.35C12.04,8.34 12.01,8.32 11.95,8.25C11.87,8.15 11.77,7.99 11.66,7.81C11.55,7.64 11.43,7.45 11.28,7.29C11.14,7.13 10.94,6.95 10.65,6.95ZM6.8,7.65L10.65,7.65C10.63,7.65 10.67,7.65 10.76,7.75C10.85,7.85 10.95,8.01 11.05,8.19C11.16,8.36 11.28,8.55 11.43,8.71C11.57,8.88 11.76,9.05 12.05,9.05L21.15,9.05C21.35,9.05 21.5,9.2 21.5,9.4L21.5,10.45L8.55,10.45C8,10.45 7.56,10.89 7.52,11.43L7.51,11.43L7.5,11.5L6.45,17.17L6.45,8C6.45,7.8 6.6,7.65 6.8,7.65ZM8.55,11.15L22.2,11.15C22.4,11.15 22.55,11.3 22.55,11.5L20.83,20.79L20.82,20.81C20.82,20.82 20.81,20.84 20.81,20.85C20.81,20.87 20.8,20.88 20.8,20.9C20.8,20.9 20.8,20.91 20.8,20.92C20.8,20.92 20.8,20.92 20.8,20.93C20.79,20.95 20.79,20.96 20.79,20.98C20.79,20.99 20.79,21 20.79,21.02C20.79,21.03 20.79,21.04 20.79,21.06C20.75,21.2 20.61,21.3 20.45,21.3L6.8,21.3C6.6,21.3 6.45,21.15 6.45,20.95L8.19,11.57L8.2,11.53L8.2,11.5C8.2,11.3 8.35,11.15 8.55,11.15Z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_folder.xml"
+ line="21"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1309 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M6.8,6.95C6.22,6.95 5.75,7.42 5.75,8L5.75,20.92C5.73,21 5.74,21.08 5.77,21.16C5.87,21.63 6.3,22 6.8,22L20.45,22C20.97,22 21.4,21.61 21.48,21.1C21.48,21.08 21.49,21.06 21.49,21.04C21.49,21.03 21.49,21.02 21.49,21.02L21.5,20.96C21.5,20.96 21.5,20.95 21.5,20.95L23.24,11.57L23.25,11.53L23.25,11.5C23.25,10.92 22.78,10.45 22.2,10.45L22.2,9.4C22.2,8.82 21.73,8.35 21.15,8.35L12.06,8.35C12.06,8.35 12.06,8.35 12.05,8.35C12.04,8.34 12.01,8.32 11.95,8.25C11.87,8.15 11.77,7.99 11.66,7.81C11.55,7.64 11.43,7.45 11.28,7.29C11.14,7.13 10.94,6.95 10.65,6.95ZM6.8,7.65L10.65,7.65C10.63,7.65 10.67,7.65 10.76,7.75C10.85,7.85 10.95,8.01 11.05,8.19C11.16,8.36 11.28,8.55 11.43,8.71C11.57,8.88 11.76,9.05 12.05,9.05L21.15,9.05C21.35,9.05 21.5,9.2 21.5,9.4L21.5,10.45L8.55,10.45C8,10.45 7.56,10.89 7.52,11.43L7.51,11.43L7.5,11.5L6.45,17.17L6.45,8C6.45,7.8 6.6,7.65 6.8,7.65ZM8.55,11.15L22.2,11.15C22.4,11.15 22.55,11.3 22.55,11.5L20.83,20.79L20.82,20.81C20.82,20.82 20.81,20.84 20.81,20.85C20.81,20.87 20.8,20.88 20.8,20.9C20.8,20.9 20.8,20.91 20.8,20.92C20.8,20.92 20.8,20.92 20.8,20.93C20.79,20.95 20.79,20.96 20.79,20.98C20.79,20.99 20.79,21 20.79,21.02C20.79,21.03 20.79,21.04 20.79,21.06C20.75,21.2 20.61,21.3 20.45,21.3L6.8,21.3C6.6,21.3 6.45,21.15 6.45,20.95L8.19,11.57L8.2,11.53L8.2,11.5C8.2,11.3 8.35,11.15 8.55,11.15Z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_folder_disabled.xml"
+ line="30"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1379 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="m21.2919,23.2015c0.6342,-0.1548 1.0149,-0.392 1.4344,-0.894 0.7094,-0.8486 0.8512,-2.1283 0.3502,-3.158 -0.1582,-0.3251 -0.377,-0.6401 -0.4861,-0.6999 -0.1847,-0.1012 -0.1868,-0.1424 -0.0305,-0.5933 0.4121,-1.1887 0.2451,-3.014 -0.384,-4.1977 -0.3848,-0.724 -1.2208,-1.4895 -1.8766,-1.7183 -1.3054,-0.4555 -3.136,0.051 -4.0509,1.1204 -1.418,1.6577 -1.7887,3.8542 -0.9565,5.6672 0.2793,0.6085 1.4226,1.7934 2.2541,2.3362 0.402,0.2624 0.6604,0.5313 0.7933,0.8255 0.4347,0.9623 1.8352,1.5845 2.9527,1.3118zM19.7347,22.8078c-0.4177,-0.1787 -0.9625,-0.6424 -1.1335,-0.9646 -0.0846,-0.1596 -0.0299,-0.161 0.5561,-0.0144 1.1521,0.2882 1.9675,0.0109 2.6995,-0.9215 0.3051,-0.3883 0.346,-0.5332 0.3978,-1.4115l0.0577,-0.9789 0.3002,0.3663c0.9623,1.1744 0.5377,3.1707 -0.821,3.8594 -0.5724,0.2901 -1.4649,0.3184 -2.0568,0.0651zM18.7182,21.4086c-1.1454,-0.4457 -2.8916,-2.0023 -3.2716,-2.9163 -0.9165,-2.2045 0.1396,-5.1562 2.19,-6.1208 0.9436,-0.4439 2.0937,-0.462 2.8908,-0.0454 1.6982,0.8873 2.465,3.4189 1.7138,5.6589 -0.1131,0.3371 -0.2067,0.9238 -0.208,1.3038 -0.0056,1.476 -0.786,2.3326 -2.1276,2.3341 -0.3572,0.0004 -0.8771,-0.0933 -1.1874,-0.2141zM18.7696,18.7483c0.0941,-0.1471 0.1413,-0.347 0.1049,-0.4442 -0.0365,-0.0973 -0.4674,-0.3424 -0.9577,-0.5448 -0.994,-0.4103 -1.2506,-0.3717 -1.2506,0.1881 0,0.2954 0.0735,0.3564 0.8303,0.6882 1.0117,0.4436 1.0587,0.4478 1.2731,0.1126z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_galaxy_buds_pro.xml"
+ line="23"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1376 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="m7.8637,17.6849c-0.6433,-0.1548 -1.0294,-0.392 -1.455,-0.894 -0.7195,-0.8486 -0.8635,-2.1283 -0.3552,-3.158 0.1605,-0.3251 0.3824,-0.6401 0.4931,-0.6999 0.1874,-0.1012 0.1895,-0.1424 0.031,-0.5932 -0.418,-1.1887 -0.2486,-3.014 0.3895,-4.1977 0.3903,-0.724 1.2383,-1.4895 1.9035,-1.7183 1.3241,-0.4555 3.181,0.0509 4.1091,1.1204 1.4383,1.6578 1.8143,3.8542 0.9702,5.6672 -0.2833,0.6085 -1.443,1.7934 -2.2864,2.3362 -0.4078,0.2624 -0.6699,0.5313 -0.8047,0.8255 -0.4409,0.9623 -1.8616,1.5845 -2.995,1.3118zM9.4431,17.2912c0.4237,-0.1787 0.9763,-0.6423 1.1497,-0.9646 0.0858,-0.1596 0.0303,-0.161 -0.5641,-0.0144 -1.1687,0.2882 -1.9957,0.0108 -2.7382,-0.9215 -0.3095,-0.3882 -0.3509,-0.5332 -0.4035,-1.4115l-0.0586,-0.9789 -0.3045,0.3663c-0.9761,1.1744 -0.5454,3.1707 0.8327,3.8594 0.5806,0.2901 1.4859,0.3184 2.0864,0.0652zM10.4743,15.892c1.1618,-0.4457 2.9331,-2.0023 3.3186,-2.9163C14.7225,10.7711 13.6513,7.8195 11.5715,6.8549 10.6144,6.411 9.4477,6.3929 8.6392,6.8094c-1.7226,0.8873 -2.5003,3.419 -1.7384,5.6589 0.1147,0.3371 0.2096,0.9238 0.211,1.3038 0.0052,1.4759 0.7973,2.3326 2.1581,2.3341 0.3623,0.0004 0.8897,-0.0933 1.2044,-0.2141zM10.4222,13.2317c-0.0955,-0.1471 -0.1434,-0.347 -0.1064,-0.4442 0.037,-0.0973 0.4741,-0.3424 0.9715,-0.5448 1.0082,-0.4103 1.2686,-0.3717 1.2686,0.1881 0,0.2954 -0.0746,0.3564 -0.8422,0.6882 -1.0262,0.4436 -1.0739,0.4478 -1.2914,0.1126z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_galaxy_buds_pro.xml"
+ line="28"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1379 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="m21.2919,23.2015c0.6342,-0.1548 1.0149,-0.392 1.4344,-0.894 0.7094,-0.8486 0.8512,-2.1283 0.3502,-3.158 -0.1582,-0.3251 -0.377,-0.6401 -0.4861,-0.6999 -0.1847,-0.1012 -0.1868,-0.1424 -0.0305,-0.5933 0.4121,-1.1887 0.2451,-3.014 -0.384,-4.1977 -0.3848,-0.724 -1.2208,-1.4895 -1.8766,-1.7183 -1.3054,-0.4555 -3.136,0.051 -4.0509,1.1204 -1.418,1.6577 -1.7887,3.8542 -0.9565,5.6672 0.2793,0.6085 1.4226,1.7934 2.2541,2.3362 0.402,0.2624 0.6604,0.5313 0.7933,0.8255 0.4347,0.9623 1.8352,1.5845 2.9527,1.3118zM19.7347,22.8078c-0.4177,-0.1787 -0.9625,-0.6424 -1.1335,-0.9646 -0.0846,-0.1596 -0.0299,-0.161 0.5561,-0.0144 1.1521,0.2882 1.9675,0.0109 2.6995,-0.9215 0.3051,-0.3883 0.346,-0.5332 0.3978,-1.4115l0.0577,-0.9789 0.3002,0.3663c0.9623,1.1744 0.5377,3.1707 -0.821,3.8594 -0.5724,0.2901 -1.4649,0.3184 -2.0568,0.0651zM18.7182,21.4086c-1.1454,-0.4457 -2.8916,-2.0023 -3.2716,-2.9163 -0.9165,-2.2045 0.1396,-5.1562 2.19,-6.1208 0.9436,-0.4439 2.0937,-0.462 2.8908,-0.0454 1.6982,0.8873 2.465,3.4189 1.7138,5.6589 -0.1131,0.3371 -0.2067,0.9238 -0.208,1.3038 -0.0056,1.476 -0.786,2.3326 -2.1276,2.3341 -0.3572,0.0004 -0.8771,-0.0933 -1.1874,-0.2141zM18.7696,18.7483c0.0941,-0.1471 0.1413,-0.347 0.1049,-0.4442 -0.0365,-0.0973 -0.4674,-0.3424 -0.9577,-0.5448 -0.994,-0.4103 -1.2506,-0.3717 -1.2506,0.1881 0,0.2954 0.0735,0.3564 0.8303,0.6882 1.0117,0.4436 1.0587,0.4478 1.2731,0.1126z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_galaxy_buds_pro_disabled.xml"
+ line="28"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1376 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="m7.8637,17.6849c-0.6433,-0.1548 -1.0294,-0.392 -1.455,-0.894 -0.7195,-0.8486 -0.8635,-2.1283 -0.3552,-3.158 0.1605,-0.3251 0.3824,-0.6401 0.4931,-0.6999 0.1874,-0.1012 0.1895,-0.1424 0.031,-0.5932 -0.418,-1.1887 -0.2486,-3.014 0.3895,-4.1977 0.3903,-0.724 1.2383,-1.4895 1.9035,-1.7183 1.3241,-0.4555 3.181,0.0509 4.1091,1.1204 1.4383,1.6578 1.8143,3.8542 0.9702,5.6672 -0.2833,0.6085 -1.443,1.7934 -2.2864,2.3362 -0.4078,0.2624 -0.6699,0.5313 -0.8047,0.8255 -0.4409,0.9623 -1.8616,1.5845 -2.995,1.3118zM9.4431,17.2912c0.4237,-0.1787 0.9763,-0.6423 1.1497,-0.9646 0.0858,-0.1596 0.0303,-0.161 -0.5641,-0.0144 -1.1687,0.2882 -1.9957,0.0108 -2.7382,-0.9215 -0.3095,-0.3882 -0.3509,-0.5332 -0.4035,-1.4115l-0.0586,-0.9789 -0.3045,0.3663c-0.9761,1.1744 -0.5454,3.1707 0.8327,3.8594 0.5806,0.2901 1.4859,0.3184 2.0864,0.0652zM10.4743,15.892c1.1618,-0.4457 2.9331,-2.0023 3.3186,-2.9163C14.7225,10.7711 13.6513,7.8195 11.5715,6.8549 10.6144,6.411 9.4477,6.3929 8.6392,6.8094c-1.7226,0.8873 -2.5003,3.419 -1.7384,5.6589 0.1147,0.3371 0.2096,0.9238 0.211,1.3038 0.0052,1.4759 0.7973,2.3326 2.1581,2.3341 0.3623,0.0004 0.8897,-0.0933 1.2044,-0.2141zM10.4222,13.2317c-0.0955,-0.1471 -0.1434,-0.347 -0.1064,-0.4442 0.037,-0.0973 0.4741,-0.3424 0.9715,-0.5448 1.0082,-0.4103 1.2686,-0.3717 1.2686,0.1881 0,0.2954 -0.0746,0.3564 -0.8422,0.6882 -1.0262,0.4436 -1.0739,0.4478 -1.2914,0.1126z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_galaxy_buds_pro_disabled.xml"
+ line="33"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (905 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M5.762 14.02l0.023-0.226c0.052-0.503 0.266-1.351 0.391-1.55 0.025-0.038 0.077-0.09 0.116-0.115 0.138-0.089 0.468-0.16 1.016-0.223 0.155-0.018 0.304-0.04 0.331-0.05 0.07-0.028 0.27-0.068 0.33-0.068 0.03 0 0.153-0.05 0.29-0.117 0.465-0.23 0.971-0.357 1.597-0.403 0.32-0.024 8.726-0.024 9.05 0 0.652 0.047 1.177 0.187 1.687 0.45l0.244 0.125 0.31 0.03c0.597 0.06 1.044 0.138 1.217 0.215 0.098 0.043 0.19 0.128 0.235 0.219 0.086 0.17 0.226 0.687 0.304 1.121 0.153 0.855 0.104 1.63-0.163 2.59-0.084 0.303-0.14 0.436-0.219 0.52-0.138 0.147-0.523 0.238-1.349 0.32l-0.335 0.032-0.277 0.14c-0.497 0.252-0.966 0.378-1.612 0.436-0.35 0.032-8.734 0.032-9.118 0-0.66-0.053-1.201-0.2-1.68-0.453C8.052 16.96 8 16.945 7.906 16.936c-0.066-0.006-0.168-0.025-0.227-0.043-0.059-0.017-0.265-0.051-0.46-0.075-0.767-0.095-0.967-0.168-1.08-0.392-0.052-0.103-0.187-0.563-0.247-0.844L5.851 15.39c-0.075-0.336-0.121-1.042-0.09-1.368z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_miband6.xml"
+ line="20"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (905 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M5.762 14.02l0.023-0.226c0.052-0.503 0.266-1.351 0.391-1.55 0.025-0.038 0.077-0.09 0.116-0.115 0.138-0.089 0.468-0.16 1.016-0.223 0.155-0.018 0.304-0.04 0.331-0.05 0.07-0.028 0.27-0.068 0.33-0.068 0.03 0 0.153-0.05 0.29-0.117 0.465-0.23 0.971-0.357 1.597-0.403 0.32-0.024 8.726-0.024 9.05 0 0.652 0.047 1.177 0.187 1.687 0.45l0.244 0.125 0.31 0.03c0.597 0.06 1.044 0.138 1.217 0.215 0.098 0.043 0.19 0.128 0.235 0.219 0.086 0.17 0.226 0.687 0.304 1.121 0.153 0.855 0.104 1.63-0.163 2.59-0.084 0.303-0.14 0.436-0.219 0.52-0.138 0.147-0.523 0.238-1.349 0.32l-0.335 0.032-0.277 0.14c-0.497 0.252-0.966 0.378-1.612 0.436-0.35 0.032-8.734 0.032-9.118 0-0.66-0.053-1.201-0.2-1.68-0.453C8.052 16.96 8 16.945 7.906 16.936c-0.066-0.006-0.168-0.025-0.227-0.043-0.059-0.017-0.266-0.051-0.46-0.075-0.767-0.095-0.967-0.168-1.08-0.392-0.052-0.103-0.187-0.563-0.247-0.844L5.851 15.39c-0.075-0.336-0.121-1.042-0.09-1.368z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_miband6_disabled.xml"
+ line="25"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1432 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M7.291 6.092L7.194 6.145C7.162 6.163 7.13 6.183 7.099 6.203l-0.1 0.067L6.89 6.35 6.815 6.405 6.74 6.466C6.691 6.507 6.642 6.55 6.595 6.595 6.547 6.64 6.5 6.685 6.455 6.734 6.41 6.78 6.365 6.83 6.323 6.88 6.28 6.93 6.24 6.983 6.2 7.035c-0.04 0.053-0.078 0.106-0.114 0.16C6.05 7.25 6.015 7.304 5.982 7.36L5.935 7.442C5.92 7.47 5.905 7.498 5.892 7.526c-0.037 0.071-0.07 0.14-0.1 0.206-0.03 0.065-0.058 0.128-0.083 0.19-0.025 0.061-0.047 0.12-0.067 0.18-0.01 0.03-0.02 0.059-0.028 0.088-0.01 0.03-0.017 0.058-0.025 0.088L5.567 8.366l-0.02 0.09c-0.011 0.06-0.021 0.12-0.03 0.184-0.008 0.063-0.015 0.13-0.02 0.198-0.007 0.069-0.01 0.14-0.014 0.217L5.477 9.219v0.157C5.478 9.428 5.48 9.478 5.483 9.527c0.003 0.05 0.007 0.099 0.012 0.147C5.5 9.722 5.505 9.77 5.513 9.818 5.52 9.866 5.529 9.914 5.538 9.961l0.033 0.144 0.042 0.146c0.015 0.05 0.031 0.1 0.05 0.15 0.018 0.051 0.037 0.103 0.059 0.156 0.02 0.053 0.044 0.108 0.068 0.163l0.079 0.173c0.028 0.059 0.058 0.12 0.09 0.182 0.031 0.063 0.065 0.128 0.1 0.196l0.113 0.209 0.125 0.224 0.19 0.325 0.188 0.312 0.184 0.3 0.182 0.287 0.18 0.275c0.059 0.09 0.117 0.177 0.176 0.262 0.058 0.086 0.116 0.17 0.173 0.251l0.17 0.24 0.169 0.227c0.055 0.074 0.11 0.146 0.165 0.217 0.054 0.07 0.108 0.138 0.162 0.205l0.008 0.01C8.02 14.582 7.132 13.792 6.67 13.21c-1.06-1.33-1.604-2.907-1.441-4.18 0.148-1.165 0.664-2.116 1.624-2.991C7.09 5.821 7.117 5.85 7.187 5.871 7.424 6.016 7.452 6.003 7.29 6.092z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_sony_wf_800n.xml"
+ line="19"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1981 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M12.193 17.066c0.625-0.132 1.334-0.604 1.68-1.12 0.53-0.787 0.661-1.615 0.413-2.61-0.445-1.784-1.737-5.003-2.422-6.035-0.49-0.738-1.147-1.226-1.985-1.474-0.284-0.084-0.55-0.118-0.97-0.127-0.244-0.005-0.653 0.037-1.041 0.174-0.486 0.169-0.64 0.23-0.95 0.458-0.406 0.296-0.789 0.73-1.013 1.175-0.29 0.576-0.392 0.938-0.419 1.547-0.04 0.9 0.118 1.412 0.816 2.655 1.003 1.681 1.225 2.02 2.372 3.434 0.232 0.3 0.63 0.757 0.883 1.016 0.373 0.381 0.524 0.502 0.787 0.633 0.599 0.296 1.271 0.396 1.85 0.274zm9.319-4.474c0.034 0.017 0.066 0.034 0.097 0.053 0.032 0.018 0.064 0.037 0.096 0.058 0.032 0.02 0.064 0.043 0.099 0.067l0.109 0.079 0.075 0.057 0.075 0.06c0.05 0.041 0.098 0.084 0.146 0.129 0.047 0.044 0.094 0.09 0.14 0.138 0.045 0.048 0.089 0.097 0.131 0.148 0.043 0.05 0.084 0.102 0.124 0.154 0.04 0.052 0.078 0.106 0.114 0.16 0.036 0.054 0.07 0.109 0.103 0.164l0.047 0.083 0.044 0.083c0.036 0.072 0.07 0.14 0.1 0.206 0.03 0.066 0.057 0.129 0.082 0.19 0.025 0.062 0.047 0.121 0.067 0.18l0.029 0.088 0.025 0.088 0.022 0.089 0.019 0.089c0.012 0.06 0.022 0.121 0.03 0.185 0.009 0.063 0.016 0.129 0.021 0.198 0.006 0.068 0.01 0.14 0.014 0.217l0.005 0.164v0.157c0 0.051-0.003 0.102-0.006 0.151-0.002 0.05-0.006 0.098-0.011 0.147-0.005 0.048-0.011 0.096-0.019 0.144-0.007 0.048-0.015 0.095-0.025 0.143-0.01 0.048-0.02 0.096-0.033 0.144-0.012 0.048-0.026 0.097-0.041 0.146-0.015 0.05-0.032 0.1-0.05 0.15-0.018 0.051-0.038 0.103-0.06 0.156-0.02 0.053-0.043 0.107-0.068 0.163-0.024 0.056-0.05 0.113-0.079 0.172-0.028 0.06-0.058 0.12-0.09 0.183-0.03 0.063-0.064 0.128-0.1 0.195l-0.113 0.21-0.124 0.224-0.19 0.325-0.188 0.312-0.185 0.3-0.182 0.286-0.179 0.275-0.176 0.263c-0.059 0.086-0.116 0.17-0.174 0.251l-0.17 0.24-0.168 0.227c-0.056 0.074-0.11 0.146-0.165 0.216-0.055 0.07-0.109 0.139-0.163 0.205l-0.008 0.01c0.224-0.032 1.112-0.822 1.575-1.403 1.06-1.332 1.603-2.909 1.44-4.182-0.148-1.165-0.664-2.116-1.623-2.991-0.239-0.218-0.265-0.188-0.334-0.167-0.238 0.144-0.265 0.132-0.105 0.22z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_sony_wf_800n.xml"
+ line="40"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1432 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M7.291 6.092L7.194 6.145C7.162 6.163 7.13 6.183 7.099 6.203l-0.1 0.067L6.89 6.35 6.815 6.405 6.74 6.466C6.691 6.507 6.642 6.55 6.595 6.595 6.547 6.64 6.5 6.685 6.455 6.734 6.41 6.78 6.365 6.83 6.323 6.88 6.28 6.93 6.24 6.983 6.2 7.035c-0.04 0.053-0.078 0.106-0.114 0.16C6.05 7.25 6.015 7.304 5.982 7.36L5.935 7.442C5.92 7.47 5.905 7.498 5.892 7.526c-0.037 0.071-0.07 0.14-0.1 0.206-0.03 0.065-0.058 0.128-0.083 0.19-0.025 0.061-0.047 0.12-0.067 0.18-0.01 0.03-0.02 0.059-0.028 0.088-0.01 0.03-0.017 0.058-0.025 0.088L5.567 8.366l-0.02 0.09c-0.011 0.06-0.021 0.12-0.03 0.184-0.008 0.063-0.015 0.13-0.02 0.198-0.007 0.069-0.01 0.14-0.014 0.217L5.477 9.219v0.157C5.478 9.428 5.48 9.478 5.483 9.527c0.003 0.05 0.007 0.099 0.012 0.147C5.5 9.722 5.505 9.77 5.513 9.818 5.52 9.866 5.529 9.914 5.538 9.961l0.033 0.144 0.042 0.146c0.015 0.05 0.031 0.1 0.05 0.15 0.018 0.051 0.037 0.103 0.059 0.156 0.02 0.053 0.044 0.108 0.068 0.163l0.079 0.173c0.028 0.059 0.058 0.12 0.09 0.182 0.031 0.063 0.065 0.128 0.1 0.196l0.113 0.209 0.125 0.224 0.19 0.325 0.188 0.312 0.184 0.3 0.182 0.287 0.18 0.275c0.059 0.09 0.117 0.177 0.176 0.262 0.058 0.086 0.116 0.17 0.173 0.251l0.17 0.24 0.169 0.227c0.055 0.074 0.11 0.146 0.165 0.217 0.054 0.07 0.108 0.138 0.162 0.205l0.008 0.01C8.02 14.582 7.132 13.792 6.67 13.21c-1.06-1.33-1.604-2.907-1.441-4.18 0.148-1.165 0.664-2.116 1.624-2.991C7.09 5.821 7.117 5.85 7.187 5.871 7.424 6.016 7.452 6.003 7.29 6.092z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_sony_wf_800n_disabled.xml"
+ line="24"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1981 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M12.193 17.066c0.625-0.132 1.334-0.604 1.68-1.12 0.53-0.787 0.661-1.615 0.413-2.61-0.445-1.784-1.737-5.003-2.422-6.035-0.49-0.738-1.147-1.226-1.985-1.474-0.284-0.084-0.55-0.118-0.97-0.127-0.244-0.005-0.653 0.037-1.041 0.174-0.486 0.169-0.64 0.23-0.95 0.458-0.406 0.296-0.789 0.73-1.013 1.175-0.29 0.576-0.392 0.938-0.419 1.547-0.04 0.9 0.118 1.412 0.816 2.655 1.003 1.681 1.225 2.02 2.372 3.434 0.232 0.3 0.63 0.757 0.883 1.016 0.373 0.381 0.524 0.502 0.787 0.633 0.599 0.296 1.271 0.396 1.85 0.274zm9.319-4.474c0.034 0.017 0.066 0.034 0.097 0.053 0.032 0.018 0.064 0.037 0.096 0.058 0.032 0.02 0.064 0.043 0.099 0.067l0.109 0.079 0.075 0.057 0.075 0.06c0.05 0.041 0.098 0.084 0.146 0.129 0.047 0.044 0.094 0.09 0.14 0.138 0.045 0.048 0.089 0.097 0.131 0.148 0.043 0.05 0.084 0.102 0.124 0.154 0.04 0.052 0.078 0.106 0.114 0.16 0.036 0.054 0.07 0.109 0.103 0.164l0.047 0.083 0.044 0.083c0.036 0.072 0.07 0.14 0.1 0.206 0.03 0.066 0.057 0.129 0.082 0.19 0.025 0.062 0.047 0.121 0.067 0.18l0.029 0.088 0.025 0.088 0.022 0.089 0.019 0.089c0.012 0.06 0.022 0.121 0.03 0.185 0.009 0.063 0.016 0.129 0.021 0.198 0.006 0.068 0.01 0.14 0.014 0.217l0.005 0.164v0.157c0 0.051-0.003 0.102-0.006 0.151-0.002 0.05-0.006 0.098-0.011 0.147-0.005 0.048-0.011 0.096-0.019 0.144-0.007 0.048-0.015 0.095-0.025 0.143-0.01 0.048-0.02 0.096-0.033 0.144-0.012 0.048-0.026 0.097-0.041 0.146-0.015 0.05-0.032 0.1-0.05 0.15-0.018 0.051-0.038 0.103-0.06 0.156-0.02 0.053-0.043 0.107-0.068 0.163-0.024 0.056-0.05 0.113-0.079 0.172-0.028 0.06-0.058 0.12-0.09 0.183-0.03 0.063-0.064 0.128-0.1 0.195l-0.113 0.21-0.124 0.224-0.19 0.325-0.188 0.312-0.185 0.3-0.182 0.286-0.179 0.275-0.176 0.263c-0.059 0.086-0.116 0.17-0.174 0.251l-0.17 0.24-0.168 0.227c-0.056 0.074-0.11 0.146-0.165 0.216-0.055 0.07-0.109 0.139-0.163 0.205l-0.008 0.01c0.224-0.032 1.112-0.822 1.575-1.403 1.06-1.332 1.603-2.909 1.44-4.182-0.148-1.165-0.664-2.116-1.623-2.991-0.239-0.218-0.265-0.188-0.334-0.167-0.238 0.144-0.265 0.132-0.105 0.22z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_device_sony_wf_800n_disabled.xml"
+ line="45"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1424 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M17.266 33.844c0-2.401 1.482-4.458 3.581-5.31l-0.098 0.005c-2.717-2.464-2.5-3.827-2.547-4.84v-0.511c-4.804 2.836-10.196 6.529-11.886 4.839L5.892 27.01c5.654-3.903 11.31-7.806 15.96-11.714 0.397-0.434 1.05-0.699 2.122-0.681 4.933 0.049 9.609 0.436 14.096 0.934l5.518-2.378-0.171 7.047c-4.53 4.225-11.256 6.388-16.326 9.616 1.014 1.034 1.641 2.451 1.641 4.01 0 3.164-2.568 5.733-5.734 5.733-3.163 0-5.732-2.569-5.732-5.733zm9.98 0c0-2.34-1.905-4.243-4.248-4.243s-4.246 1.903-4.246 4.243c0 2.343 1.903 4.245 4.246 4.245 2.343 0 4.248-1.902 4.248-4.245zm-6.049 2.308c-0.355-0.375-0.589-0.841-0.7-1.401h-0.612l0.133-0.639h0.405c-0.003-0.066-0.008-0.139-0.008-0.213 0-0.128 0.005-0.237 0.011-0.333h-0.541l0.133-0.641h0.491c0.116-0.55 0.345-1.009 0.689-1.377 0.523-0.557 1.205-0.833 2.055-0.833 0.463 0 0.865 0.083 1.205 0.249l-0.242 1.147c-0.236-0.235-0.576-0.351-1.026-0.351-0.45 0-0.812 0.161-1.089 0.484-0.158 0.18-0.269 0.409-0.335 0.681h2.274l-0.135 0.641h-2.222c-0.003 0.061-0.004 0.143-0.004 0.242 0 0.096 0.001 0.198 0.007 0.304h2.105l-0.132 0.639h-1.887c0.07 0.296 0.174 0.528 0.321 0.693 0.274 0.326 0.635 0.489 1.073 0.489 0.528 0 0.951-0.163 1.265-0.489v1.266c-0.358 0.18-0.775 0.269-1.252 0.269-0.804 0-1.466-0.277-1.982-0.827zm8.297-11.86c0.709-1.712 0.594-4.211-4.755-2.971-2.961 1.242-3.613 2.611-2.547 4.076 0.595 0.911 0.932 1.816 1.137 2.725 0.602 0.035 1.178 0.162 1.718 0.37 1.429-1.572 3.837-2.733 4.447-4.2z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_donate.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (6628 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M5.7484,0C4.909,0.4048 3.9699,0.7895 3.4635,1.8877 3.1075,2.4815 3.0984,3.2545 2.8474,3.9012 3.3122,3.359 4.0318,2.8038 4.4418,2.1731 4.8992,1.3809 5.1318,0.6712 5.7484,0ZM18.2516,0c0.6166,0.6712 0.8493,1.3809 1.3067,2.1731 0.41,0.6308 1.1294,1.186 1.5943,1.7281C20.9016,3.2545 20.8925,2.4815 20.5365,1.8877 20.0301,0.7895 19.091,0.4048 18.2516,0ZM3.4844,1.4146C3.0566,1.5498 2.6763,1.8713 2.3764,2.3345 1.6854,3.2346 1.5669,4.4154 1.8533,5.8391 1.9518,6.2156 1.8117,6.5683 1.8316,6.9198 2.0346,6.4032 2.2645,5.9282 2.7115,5.5956 3.7081,4.8955 3.9823,4.5248 4.2373,3.8647 4.5371,3.0886 4.5929,2.9733 4.5995,2.2768 4.3103,3.2786 3.4374,3.6991 2.8474,4.3755 2.5442,4.7446 2.3013,5.1724 2.1631,5.6254L2.1419,5.6046C2.2145,5.2038 2.3604,4.8436 2.4444,4.4526 2.7078,3.3954 2.6537,2.1228 3.4844,1.4146ZM20.4876,1.4146c0.8307,0.7082 0.7766,1.9808 1.04,3.038 0.084,0.391 0.2299,0.7511 0.3025,1.1519l-0.0212,0.0208C21.6706,5.1724 21.4277,4.7446 21.1245,4.3755 20.5345,3.6991 19.6616,3.2786 19.3725,2.2768c0.0066,0.6965 0.0625,0.8118 0.3623,1.5879 0.255,0.66 0.529,1.0308 1.5257,1.7309 0.447,0.3326 0.6769,0.8075 0.8798,1.3242C22.1602,6.5683 22.0201,6.2156 22.1186,5.8391 22.405,4.4154 22.2865,3.2346 21.5955,2.3345 21.2956,1.8713 20.9154,1.5498 20.4876,1.4146ZM1.7114,3.3085c-0.0217,-0.0051 -0.0335,0.0158 -0.0503,0.0262C1.0818,4.2517 0.5761,4.9453 0.6011,6.1777 0.5231,7.6942 1.4002,9.0766 1.4882,10.5069 1.6212,9.412 2.3095,8.6385 2.879,7.7899 2.804,8.1525 2.7168,8.5038 2.5663,8.8193 1.9424,9.9924 1.3214,11.385 1.7515,12.886 1.7415,12.9018 1.7275,12.9227 1.7114,12.9119 1.5386,12.4159 1.509,11.8485 1.3881,11.3201 1.0992,9.9073 0.2186,8.7316 0.474,7.1031 0.3392,7.4564 0.2846,7.8552 0.2132,8.2405 0.0625,9.2361 -0.0178,9.8503 0.2035,10.7649 0.4725,11.912 1.6056,13.1775 2.0631,14.228l0.0625,0.0622C2.0589,13.7202 2.0249,13.1067 2.1547,12.5632 2.3518,11.6934 2.8936,10.8433 3.0453,9.9468 3.1244,9.2437 2.9481,8.4642 2.8814,7.7863 3.087,7.4797 3.2774,7.1634 3.4198,6.8182 3.6078,6.2973 3.529,5.5561 3.5853,4.9925 3.5096,5.2325 3.3793,5.44 3.2319,5.6443 2.5417,6.4252 1.7515,7.2268 1.6013,8.3586 1.5761,8.5194 1.568,8.6866 1.5303,8.8429 1.5007,8.8591 1.5007,8.817 1.4882,8.8068L1.5723,7.4034C1.6014,6.0326 1.0737,4.5128 1.7114,3.3085ZM22.2886,3.3085c0.6377,1.2042 0.1102,2.7241 0.1392,4.0948l0.084,1.4034c-0.0125,0.0102 -0.0124,0.0523 -0.042,0.0362 -0.0377,-0.1563 -0.0459,-0.3235 -0.0711,-0.4844C22.2485,7.2268 21.4583,6.4252 20.7681,5.6443 20.6207,5.44 20.4904,5.2325 20.4147,4.9925c0.0564,0.5636 -0.0223,1.3048 0.1657,1.8257 0.1424,0.3452 0.3327,0.6614 0.5382,0.9681 -0.0667,0.678 -0.2428,1.4574 -0.1637,2.1605 0.1517,0.8965 0.6935,1.7466 0.8906,2.6164 0.1297,0.5435 0.0956,1.1571 0.029,1.727l0.0625,-0.0622C22.3944,13.1775 23.5276,11.912 23.7966,10.7649 24.018,9.8503 23.9375,9.2361 23.7868,8.2405 23.7154,7.8552 23.6608,7.4564 23.526,7.1031c0.2554,1.6286 -0.6252,2.8043 -0.9141,4.217 -0.1209,0.5284 -0.1505,1.0958 -0.3233,1.5918 -0.0161,0.0109 -0.0301,-0.01 -0.0401,-0.0259 0.4301,-1.501 -0.1907,-2.8935 -0.8146,-4.0667 -0.1505,-0.3155 -0.2378,-0.6668 -0.3129,-1.0295 0.5695,0.8487 1.2578,1.6221 1.3909,2.717 0.088,-1.4303 0.9651,-2.8126 0.8871,-4.3292 0.025,-1.2324 -0.4806,-1.926 -1.0599,-2.8429 -0.0168,-0.0104 -0.0288,-0.0313 -0.0505,-0.0262zM2.7953,11.0137c0.1056,1.1638 -0.3008,2.1508 -0.2722,3.3188 0.0285,0.7055 0.2136,1.3308 0.5114,1.8936l-0.0505,0.0002C2.5817,15.5698 2.2675,14.8712 1.7974,14.246 1.0675,13.372 0.1364,12.4758 0.0406,11.2609 -0.0834,12.2755 0.0833,13.2266 0.4503,14.0674 0.8172,14.9082 1.3843,15.6386 2.061,16.2113l0.3153,0.2422c0.5579,0.4531 0.9973,0.6309 1.5334,1.1462v-0.0633C3.4991,16.6883 3.5518,15.6808 3.4863,14.652 3.4208,13.6233 3.2369,12.573 2.984,11.6393ZM21.2047,11.0137 L21.016,11.6393c-0.253,0.9337 -0.4366,1.984 -0.5022,3.0128 -0.0655,1.0288 -0.0128,2.0362 -0.4234,2.8843v0.0633c0.5361,-0.5153 0.9754,-0.6931 1.5332,-1.1462l0.3153,-0.2422c0.6767,-0.5727 1.2438,-1.3031 1.6107,-2.1439 0.3669,-0.8408 0.5336,-1.7919 0.4097,-2.8064 -0.0958,1.2149 -1.027,2.1111 -1.7568,2.9851 -0.4702,0.6252 -0.7843,1.3237 -1.1866,1.9802l-0.0505,-0.0002c0.2978,-0.5628 0.483,-1.1881 0.5115,-1.8936 0.0285,-1.168 -0.378,-2.155 -0.2724,-3.3188zM3.5004,14.1213c0.5128,1.2745 0.272,2.9326 0.9564,4.1181 0.188,0.3919 0.4985,0.7364 0.767,1.0391l-0.0125,0.0158C4.9684,19.1529 4.743,18.9508 4.5278,18.713 3.3584,17.5169 1.6144,17.3507 0.918,15.3892 1.0125,16.1089 1.1273,16.8312 1.5024,17.4358 2.7623,19.4588 4.1024,19.494 6.2178,20.0338 5.2496,19.0609 5.0897,17.61 4.6713,16.3248 4.39,15.5375 3.9663,14.8158 3.5004,14.1213ZM20.4996,14.1213c-0.4659,0.6945 -0.8896,1.4162 -1.1709,2.2035 -0.4184,1.2852 -0.5783,2.7361 -1.5465,3.709 2.188,-0.5087 3.4283,-0.5189 4.7154,-2.598 0.3751,-0.6046 0.4899,-1.327 0.5844,-2.0467 -0.6964,1.9616 -2.4403,2.1278 -3.6096,3.3239 -0.2152,0.2378 -0.4408,0.4399 -0.6837,0.5813l-0.0125,-0.0158c0.2685,-0.3026 0.579,-0.6472 0.767,-1.0391 0.6844,-1.1855 0.4436,-2.8436 0.9564,-4.1181zM5.1744,17.229c0.6329,1.0603 0.804,2.6309 1.9698,3.1431 0.1935,0.1273 0.4395,0.1128 0.6168,0.2528 -0.2227,0.042 -0.4149,-0.0632 -0.6249,-0.0993 -1.4873,-0.4168 -3.3117,0.1065 -4.4283,-1.4759 0.4238,1.0333 1.2928,1.8637 2.135,2.1955 1.2955,0.5497 2.6527,-0.0582 3.8743,-0.4293 0.1754,-0.0568 0.3897,-0.0359 0.5531,-0.1098C8.5902,20.5326 7.9449,20.2615 7.4087,19.676 6.6628,18.8013 5.9829,17.8988 5.1744,17.229ZM18.8256,17.229c-0.8085,0.6698 -1.4883,1.5723 -2.2342,2.447 -0.5361,0.5855 -1.1816,0.8566 -1.8616,1.0301 0.1634,0.0739 0.3777,0.053 0.5531,0.1098 1.2217,0.3711 2.5789,0.979 3.8745,0.4293 0.8423,-0.3318 1.7113,-1.1622 2.135,-2.1955 -1.1165,1.5824 -2.941,1.0591 -4.4283,1.4759 -0.2101,0.0361 -0.4024,0.1414 -0.6251,0.0993 0.1773,-0.14 0.4234,-0.1255 0.6169,-0.2528 1.1658,-0.5121 1.3368,-2.0828 1.9697,-3.1431zM13.3061,20.6618c-0.3586,-0.0002 -0.7166,0.0516 -1.0603,0.1619 -0.2182,0.2432 -0.4427,-0.0435 -0.6736,-0.049 -1.48,-0.4048 -2.7556,0.4321 -3.9833,1.092 -0.9149,0.4459 -2.1131,0.371 -2.9176,-0.3203 0.3981,0.6219 0.9306,1.0051 1.4804,1.2658 1.0222,0.5092 2.2388,0.1667 3.0846,-0.5663 0.7259,-0.5054 1.3501,-1.5007 2.3063,-1.1218 -0.9731,0.5607 -1.8627,1.3468 -2.6669,2.3573l0.5038,0.5187c0.5864,-0.9759 1.3664,-1.7724 2.1552,-2.3911 0.1711,-0.114 0.3515,-0.2556 0.5412,-0.2858 0.9689,0.591 1.8343,1.6013 2.6157,2.6506 0.1667,-0.1671 0.3437,-0.3483 0.4804,-0.5422 -0.8108,-0.9763 -1.6977,-1.7404 -2.6523,-2.2952 0.1586,-0.0567 0.3387,-0.0677 0.5253,-0.0629 0.5146,-0.0389 0.8652,0.4149 1.2631,0.7304 0.9827,0.9293 2.2958,1.5431 3.554,0.9617 0.564,-0.2501 1.1103,-0.6925 1.5101,-1.3109 -0.7777,0.6861 -1.9507,0.799 -2.8579,0.3796L15.5382,21.3453c-0.6511,-0.4315 -1.4432,-0.6831 -2.2321,-0.6835z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_events_gold.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (4281 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="m11.7472,8.175c-0.0503,-0.0004 -0.0996,-0.0157 -0.1492,-0.0235 -0.0954,-0.0151 -0.1906,-0.0315 -0.2862,-0.0451 -0.0345,-0.0049 -0.069,-0.0102 -0.1035,-0.0151 -0.0108,-0.0015 -0.0304,-0.0008 -0.0356,-0.0128 -0.0082,-0.0188 0.0099,-0.0489 0.0182,-0.0646 0.0279,-0.0519 0.0591,-0.102 0.0903,-0.1519 0.081,-0.1299 0.1724,-0.2638 0.2894,-0.3629 0.077,-0.0652 0.1662,-0.104 0.2649,-0.0634 0.0398,0.0163 0.0757,0.0435 0.104,0.0761 0.0227,0.0261 0.0438,0.0563 0.0542,0.0899 0.0139,0.0443 0.0336,0.1222 0.0151,0.1674 -0.0168,0.041 -0.0621,0.0719 -0.0903,0.1054 -0.0481,0.057 -0.0866,0.1205 -0.1222,0.1859 -0.0167,0.0307 -0.0472,0.0749 -0.0455,0.1115 0.0011,0.0231 0.0694,0.0405 0.0878,0.0506 0.0645,0.0356 0.1239,0.0879 0.1543,0.1571 0.056,0.1274 -0.002,0.3115 -0.1056,0.3984 -0.0315,0.0264 -0.0707,0.0408 -0.1096,0.0518 -0.1087,0.0306 -0.2253,0.0009 -0.3349,-0.0038 -0.0042,0.0549 -0.014,0.1134 0.0023,0.1674 0.0298,0.0985 0.1497,0.1717 0.2504,0.1469 0.1002,-0.0246 0.1645,-0.1152 0.2347,-0.1838 0.0131,-0.0128 0.0334,-0.0128 0.0459,-0.0249 0.0154,-0.015 0.0206,-0.0492 0.0305,-0.0684 0.0153,-0.0296 0.0394,-0.0564 0.0586,-0.0837 0.1165,-0.166 0.2182,-0.3427 0.3158,-0.5207 0.0269,-0.049 0.0533,-0.0985 0.078,-0.1488 0.0085,-0.0174 0.0136,-0.0502 0.0267,-0.0635 0.0126,-0.0128 0.0401,-0.0121 0.0563,-0.0195 0.113,-0.0522 0.2308,-0.0312 0.3197,0.0582 0.0195,0.0196 0.0405,0.0391 0.0521,0.0651 0.0495,0.1116 -0.0026,0.2458 -0.0417,0.3502 -0.0685,0.1834 -0.1751,0.3538 -0.2844,0.5145 -0.0182,0.0268 -0.0208,0.0647 -0.0392,0.089 -0.0122,0.0161 -0.0369,0.0186 -0.0509,0.035 -0.0328,0.0384 -0.0629,0.0862 -0.1049,0.1151 -0.0822,0.0565 -0.223,0.0554 -0.3075,0.0079 -0.0819,-0.0461 -0.0982,-0.142 -0.1024,-0.2284 -0.0015,-0.0312 0.011,-0.0703 -0.0042,-0.0992m0.0365,-1.1746c0.0543,-0.0115 0.0997,-0.0425 0.1553,-0.0513 0.1169,-0.0185 0.2376,0.0519 0.2818,0.1629 0.0224,0.0562 0.0157,0.1148 0.0318,0.1705m0.0639,1.0661c0.0253,0.0358 0.0117,0.1027 0.0236,0.1457 0.0349,0.1267 0.1465,0.1793 0.2687,0.1529 0.1722,-0.0372 0.2966,-0.1979 0.3887,-0.3388 0.0213,-0.0326 0.0379,-0.0756 0.0638,-0.1042 0.0088,-0.0096 0.0311,-0.0235 0.0428,-0.0125 0.0181,0.017 0.0102,0.0806 0.0102,0.1043 0,0.1022 0.0009,0.2051 -0.0086,0.3068 -0.0182,0.195 -0.022,0.4176 -0.1285,0.5888 -0.0361,0.058 -0.0899,0.1069 -0.1399,0.1524 -0.0987,0.0897 -0.2038,0.1731 -0.3136,0.2484 -0.0483,0.0331 -0.1045,0.062 -0.1489,0.1 -0.0177,0.0151 -0.0125,0.0421 -0.0125,0.0632v0.1673c0,0.0164 0.0079,0.0641 -0.0071,0.0743 -0.015,0.0102 -0.0514,0.0032 -0.069,0.0032h-1.0413c-0.0189,0 -0.0591,0.0076 -0.0751,-0.0032 -0.0164,-0.0111 -0.0071,-0.0714 -0.0071,-0.0898 0,-0.0672 0.0161,-0.1749 -0.0268,-0.2293C11.1496,10.1614 10.9343,9.8796 10.7594,9.5728 10.6858,9.4437 10.596,9.2999 10.5816,9.1482 10.5533,8.8506 10.7095,8.5545 10.861,8.3115c0.037,-0.0594 0.0767,-0.1179 0.1189,-0.1736 0.0145,-0.0191 0.0302,-0.0502 0.0518,-0.0614 0.0345,-0.018 0.0963,0.0057 0.1339,0.0057m1.7538,0.3068c0.1344,-0.0282 0.2635,-0.0954 0.3897,0.0012 0.0214,0.0164 0.0393,0.0356 0.0551,0.0577 0.0192,0.0269 0.0317,0.055 0.0406,0.0868 0.0097,0.0348 0.019,0.0719 0.0167,0.1085 -0.0051,0.0811 -0.0466,0.1431 -0.0744,0.2169 -0.0099,0.0262 -0.0114,0.0563 -0.0167,0.0837M11.0591,8.7113c0.0825,0.0372 0.1745,0.0603 0.2618,0.0825 0.0377,0.0096 0.0883,0.0125 0.1218,0.0321m-0.0487,1.9277c-0.0367,0.0199 -0.1078,0.0063 -0.1492,0.0063 -0.012,0 -0.0434,-0.0057 -0.0517,0.005 -0.0054,0.007 -0.0031,0.0208 -0.0031,0.0291v0.4618c0,0.0089 -0.0026,0.0243 0.0071,0.0291 0.0281,0.0141 0.0895,0.0018 0.1208,0.0018h1.3945c0.0343,0 0.0824,0.009 0.1152,-0.001 0.0123,-0.0037 0.0096,-0.0198 0.0096,-0.03v-0.4618c0,-0.0084 0.0023,-0.0221 -0.0031,-0.0291 -0.0093,-0.0119 -0.0501,-0.0049 -0.0639,-0.0049 -0.0538,0 -0.125,0.0124 -0.1766,-0.0031m-0.9865,0.1253c0.0611,-0.0081 0.1271,-0.0013 0.1888,-0.0013h0.5632c0.0374,0 0.0793,-0.004 0.1157,0.0064 0.0252,0.007 0.0336,0.0288 0.0377,0.0525 0.0086,0.05 0.0071,0.117 0.001,0.1674 -0.0029,0.0236 -0.0079,0.0494 -0.0326,0.0588 -0.0245,0.0093 -0.0536,0.0063 -0.0792,0.0063H11.6376c-0.0264,0 -0.0575,0.0035 -0.0822,-0.0082 -0.0343,-0.0163 -0.0274,-0.0721 -0.0274,-0.1034 0,-0.0433 -0.0169,-0.135 0.0216,-0.1649 0.0163,-0.0127 0.0383,-0.011 0.0575,-0.0136m-0.1918,0.4078v0.3936l0.0031,0.0322 0.0639,0.0049h1.0443l0.0621,-0.0049 0.0049,-0.0322v-0.3936""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_events_gold.xml"
+ line="18"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1367 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M10.734 15.568h0.672a0.172 0.172 0 0 1 0.172 0.172v0.625a0.172 0.172 0 0 1-0.172 0.172h-0.672a0.172 0.172 0 0 1-0.171-0.172V15.74a0.172 0.172 0 0 1 0.171-0.172zm0 1.719h0.672a0.172 0.172 0 0 1 0.172 0.172v0.625a0.172 0.172 0 0 1-0.172 0.172h-0.672a0.172 0.172 0 0 1-0.171-0.172v-0.625a0.172 0.172 0 0 1 0.171-0.172zm6.579 0h0.671a0.172 0.172 0 0 1 0.172 0.172v0.625a0.172 0.172 0 0 1-0.172 0.172h-0.672a0.172 0.172 0 0 1-0.171-0.172v-0.625a0.172 0.172 0 0 1 0.172-0.172zm0-1.719h0.671a0.172 0.172 0 0 1 0.172 0.172v0.625a0.172 0.172 0 0 1-0.172 0.172h-0.672a0.172 0.172 0 0 1-0.171-0.172V15.74a0.172 0.172 0 0 1 0.172-0.172zm-2.485-2.453H15.5a0.172 0.172 0 0 1 0.172 0.172v0.625a0.172 0.172 0 0 1-0.172 0.172h-0.672a0.172 0.172 0 0 1-0.172-0.172v-0.625a0.172 0.172 0 0 1 0.172-0.172zm-1.601 0h0.671a0.172 0.172 0 0 1 0.172 0.172v0.625a0.172 0.172 0 0 1-0.172 0.172h-0.671a0.172 0.172 0 0 1-0.172-0.172v-0.625a0.172 0.172 0 0 1 0.172-0.172zm0 6.504h0.671a0.172 0.172 0 0 1 0.172 0.172v0.625a0.172 0.172 0 0 1-0.172 0.172h-0.671a0.172 0.172 0 0 1-0.172-0.172V19.79a0.172 0.172 0 0 1 0.172-0.172zm1.589 0h0.672a0.172 0.172 0 0 1 0.172 0.172v0.625a0.172 0.172 0 0 1-0.172 0.172h-0.672a0.172 0.172 0 0 1-0.171-0.172V19.79a0.172 0.172 0 0 1 0.171-0.172zm5.451-7.929c-5.543-5.552 0.98 0.98-4.564-4.571l4.571 0.002-0.007 4.569zm-5.187-3.5l4.033 4.066-4.026 0.012-0.007-4.077z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_firmware.xml"
+ line="40"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (849 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95c-0.32-1.25-0.78-2.45-1.38-3.56 1.84 0.63 3.37 1.91 4.33 3.56zM12 4.04c0.83 1.2 1.48 2.53 1.91 3.96h-3.82c0.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s0.1-1.36 0.26-2h3.38c-0.08 0.66-0.14 1.32-0.14 2s0.06 1.34 0.14 2H4.26zm0.82 2h2.95c0.32 1.25 0.78 2.45 1.38 3.56-1.84-0.63-3.37-1.9-4.33-3.56zm2.95-8H5.08c0.96-1.66 2.49-2.93 4.33-3.56C8.81 5.55 8.35 6.75 8.03 8zM12 19.96c-0.83-1.2-1.48-2.53-1.91-3.96h3.82c-0.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-0.09-0.66-0.16-1.32-0.16-2s0.07-1.35 0.16-2h4.68c0.09 0.65 0.16 1.32 0.16 2s-0.07 1.34-0.16 2zm0.25 5.56c0.6-1.11 1.06-2.31 1.38-3.56h2.95c-0.96 1.65-2.49 2.93-4.33 3.56zM16.36 14c0.08-0.66 0.14-1.32 0.14-2s-0.06-1.34-0.14-2h3.38C19.9 10.64 20 11.31 20 12s-0.1 1.36-0.26 2h-3.38z" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_language.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (823 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M72.12 87.5l-0.43-10.38c-0.15-7.29 6-13.48 7.64-18.93-1.74 1.66-5.35 9.52-9.1 8.86-2.09-0.7-2.75-1.69-3.6-3.47-2.81 0.55-5.04-1.11-5.85-3.11l0.16 8.09-3.66-10.5c-2.86-0.93-5.48-5.32-2.85-4.81l4.72 0.9c3.23 0.68 5.05-3.69 2.16-5.96-0.62-0.44-6.79-3.32-7.95-3.64-1.04-0.29-2.28 0.12-3.08 1.02l-4.81 8.93c-0.5 0.69-0.5 1.41-0.21 2.04l8.8 17.65c0.28 0.54 0.14 1.81 0.15 2.25l-1.05 11.07zm7.35-31.33c1.59-1.81-1.36-6.84-2.58-5.38l-7.88 10.32c-2.31 2.87 1.17 5.71 3.65 3.94 0.22-0.2 0.34-0.31 0.5-0.52zm-3.85-7.83c-1.17-1.22-3.57-2.98-3.89-2.86l-8.79 11.86c-2.37 2.31 0.3 5.07 2.88 4.8 0.36-0.1 0.48-0.17 0.7-0.38l9.16-11.84c0.4-0.52 0.27-1.16-0.07-1.58zm-5.22-3.82c-0.09-0.61-3.96-3.73-4.26-3.34l-4.46 5.77c5.64 6.14-0.94 9.96-4.85 7.73 0.49 1.57 3.42 3.2 4.33 2.01zm-5.38-4.3c-0.85-1.8-4.22-3.17-5.74-2.59l-4.83 5.95 6.06 2.51z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_launcher_foreground.xml"
+ line="8"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (851 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M6.56 7.98C6.1 7.52 5.31 7.6 5 8.17 4.72 8.68 4.5 9.2 4.33 9.75 4.14 10.38 4.64 11 5.29 11H5.3c0.43 0 0.82-0.28 0.94-0.7 0.12-0.4 0.28-0.79 0.48-1.17 0.22-0.37 0.15-0.84-0.16-1.15zM5.31 13H5.29c-0.65 0-1.15 0.62-0.96 1.25 0.16 0.54 0.38 1.07 0.66 1.58 0.31 0.57 1.11 0.66 1.57 0.2 0.3-0.31 0.38-0.77 0.17-1.15-0.2-0.37-0.36-0.76-0.48-1.16C6.13 13.28 5.74 13 5.31 13zm2.85 6.02c0.51 0.28 1.04 0.5 1.59 0.66 0.62 0.18 1.24-0.32 1.24-0.96v-0.03c0-0.43-0.28-0.82-0.7-0.94-0.4-0.12-0.78-0.28-1.15-0.48-0.38-0.21-0.86-0.14-1.16 0.17l-0.03 0.03c-0.45 0.45-0.36 1.24 0.21 1.55zM13 4.07V3.41c0-0.89-1.08-1.34-1.71-0.71L9.17 4.83c-0.4 0.4-0.4 1.04 0 1.43l2.13 2.08C11.93 8.96 13 8.51 13 7.62V6.09c2.84 0.48 5 2.94 5 5.91 0 2.73-1.82 5.02-4.32 5.75-0.41 0.12-0.68 0.51-0.68 0.94v0.02c0 0.65 0.61 1.14 1.23 0.96C17.57 18.71 20 15.64 20 12c0-4.08-3.05-7.44-7-7.93z" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_rotate_left.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (802 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M19.43 12.98c0.04-0.32 0.07-0.64 0.07-0.98s-0.03-0.66-0.07-0.98l2.11-1.65c0.19-0.15 0.24-0.42 0.12-0.64l-2-3.46c-0.12-0.22-0.39-0.3-0.61-0.22l-2.49 1c-0.52-0.4-1.08-0.73-1.69-0.98l-0.38-2.65C14.46 2.18 14.25 2 14 2h-4C9.75 2 9.54 2.18 9.51 2.42L9.13 5.07C8.52 5.32 7.96 5.66 7.44 6.05l-2.49-1c-0.23-0.09-0.49 0-0.61 0.22l-2 3.46C2.21 8.95 2.27 9.22 2.46 9.37l2.11 1.65C4.53 11.34 4.5 11.67 4.5 12s0.03 0.66 0.07 0.98l-2.11 1.65c-0.19 0.15-0.24 0.42-0.12 0.64l2 3.46c0.12 0.22 0.39 0.3 0.61 0.22l2.49-1c0.52 0.4 1.08 0.73 1.69 0.98l0.38 2.65C9.54 21.82 9.75 22 10 22h4c0.25 0 0.46-0.18 0.49-0.42l0.38-2.65c0.61-0.25 1.17-0.59 1.69-0.98l2.49 1c0.23 0.09 0.49 0 0.61-0.22l2-3.46c0.12-0.22 0.07-0.49-0.12-0.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_settings.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1045 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M6.21,13.97l1.2,2.07c0.08,0.13 0.23,0.18 0.37,0.13l1.49,-0.6c0.31,0.24 0.64,0.44 1.01,0.59l0.22,1.59c0.03,0.14 0.15,0.25 0.3,0.25h2.4c0.15,0 0.27,-0.11 0.3,-0.26l0.22,-1.59c0.36,-0.15 0.7,-0.35 1.01,-0.59l1.49,0.6c0.14,0.05 0.29,0 0.37,-0.13l1.2,-2.07c0.08,-0.13 0.04,-0.29 -0.07,-0.39l-1.27,-0.99c0.03,-0.19 0.04,-0.39 0.04,-0.58 0,-0.2 -0.02,-0.39 -0.04,-0.59l1.27,-0.99c0.11,-0.09 0.15,-0.26 0.07,-0.39l-1.2,-2.07c-0.08,-0.13 -0.23,-0.18 -0.37,-0.13l-1.49,0.6c-0.31,-0.24 -0.64,-0.44 -1.01,-0.59l-0.22,-1.59c-0.03,-0.14 -0.15,-0.25 -0.3,-0.25h-2.4c-0.15,0 -0.27,0.11 -0.3,0.26l-0.22,1.59c-0.36,0.15 -0.71,0.34 -1.01,0.58l-1.49,-0.6c-0.14,-0.05 -0.29,0 -0.37,0.13l-1.2,2.07c-0.08,0.13 -0.04,0.29 0.07,0.39l1.27,0.99c-0.03,0.2 -0.05,0.39 -0.05,0.59 0,0.2 0.02,0.39 0.04,0.59l-1.27,0.99c-0.11,0.1 -0.14,0.26 -0.06,0.39zM12,10.29c0.94,0 1.71,0.77 1.71,1.71s-0.77,1.71 -1.71,1.71 -1.71,-0.77 -1.71,-1.71 0.77,-1.71 1.71,-1.71zM19,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.11,0 2,-0.9 2,-2L21,5c0,-1.1 -0.89,-2 -2,-2zM19,19L5,19L5,5h14v14z"/>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_settings_applications.xml"
+ line="9"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (947 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M20.01,16.94 L18.6,15.4517 23.08,10.7228 24.5,12.2111ZM15.6852,14.1733 L19.55,9.9733 15.3,5.4767 11.4252,9.6766 10.0052,8.1778 15.3,2.5v0l7.08,7.4733 -5.2748,5.6883zM16.74,16.4967 L15.33,17.985v0l-3.33,3.515L2.5,21.5L2.5,18.7239c0,-0.8867 0.52,-1.6572 1.3,-1.9844 0.58,-0.2428 1.28,-0.5911 1.97,-1.0767l1.38,1.4567c0.09,0.1056 0.22,0.1583 0.35,0.1583 0.13,0 0.26,-0.0528 0.36,-0.1583 0.2,-0.2111 0.2,-0.5383 0,-0.7494L6.58,15.0189c0.27,-0.2533 0.53,-0.5383 0.77,-0.8444l1.27,1.3406c0.09,0.1056 0.23,0.1583 0.35,0.1583 0.12,0 0.25,-0.0528 0.35,-0.1583 0.2,-0.2111 0.2,-0.5383 0,-0.7494l-1.4,-1.4778c0.19,-0.3589 0.34,-0.76 0.45,-1.1822l1.71,1.8156c0.09,0.1056 0.23,0.1583 0.35,0.1583 0.12,0 0.25,-0.0528 0.35,-0.1583 0.19,-0.2111 0.19,-0.5278 0.01,-0.7389l-2.3711,-2.5044 1.42,-1.4883zM13.92,16.4967 L12.25,14.7233 8.92,18.2278C8.14,19.0511 6.87,19.0511 6.09,18.2172L5.9,18.0378 5.43,18.2911c-0.29,0.1478 -0.59,0.285 -0.89,0.4117L4.53,19.3889h6.64z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_shoe.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (2472 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="m8.3489,1.4897c0.0293,0.0131 0.0571,0.0295 0.0848,0.0455 0.0277,0.0164 0.055,0.0328 0.0829,0.0508 0.028,0.0164 0.0564,0.0373 0.0864,0.0583 0.03,0.0196 0.0614,0.0436 0.0952,0.0681 0.0221,0.0164 0.044,0.0328 0.0658,0.0495 0.0217,0.0164 0.0435,0.0344 0.065,0.0521 0.043,0.0357 0.0853,0.0727 0.1268,0.1114 0.0414,0.0386 0.082,0.0789 0.1216,0.1202 0.0395,0.0413 0.078,0.0839 0.1152,0.1274 0.0373,0.0436 0.0733,0.0881 0.1079,0.1336 0.0346,0.0455 0.0678,0.0917 0.0993,0.1385 0.0316,0.0468 0.0615,0.0943 0.0897,0.1421 0.0142,0.0229 0.0277,0.0478 0.041,0.0721 0.0132,0.0229 0.026,0.0482 0.0381,0.0724 0.0316,0.0622 0.0604,0.1215 0.0868,0.1785 0.0264,0.057 0.0503,0.1117 0.072,0.1647 0.0217,0.0531 0.0411,0.1048 0.0586,0.1562 0.0086,0.0262 0.0168,0.0511 0.0247,0.0763 0.0079,0.0262 0.0152,0.0508 0.0217,0.076 0.0069,0.0262 0.0132,0.0508 0.0191,0.0763 0.0059,0.0262 0.0115,0.0514 0.0168,0.0773 0.0102,0.0521 0.0191,0.1051 0.0264,0.1602 0.0076,0.055 0.0135,0.112 0.0184,0.1716 0.0049,0.0596 0.0089,0.1222 0.0119,0.188 0.0022,0.0488 0.0036,0.0963 0.0046,0.1425 0.0008,0.0465 0.0008,0.0917 0.0001,0.1359 -0.0008,0.0442 -0.0024,0.0878 -0.0049,0.1307 -0.0025,0.0429 -0.0059,0.0852 -0.0102,0.1271 -0.0043,0.0419 -0.0096,0.0835 -0.0158,0.1248 -0.0063,0.0413 -0.0135,0.0825 -0.0221,0.1241 -0.0086,0.0413 -0.0181,0.0829 -0.029,0.1248 -0.0109,0.0419 -0.0227,0.0839 -0.0359,0.1268 -0.0132,0.0429 -0.0277,0.0861 -0.0434,0.1304 -0.0158,0.0442 -0.0329,0.0891 -0.0514,0.1349 -0.0184,0.0459 -0.0385,0.093 -0.0599,0.1415 -0.0214,0.0485 -0.0443,0.0979 -0.0687,0.1494 -0.0244,0.0511 -0.0505,0.1038 -0.0781,0.1585 -0.0277,0.0544 -0.0568,0.1107 -0.0878,0.169 -0.031,0.0583 -0.0636,0.1186 -0.098,0.1811 -0.0344,0.0626 -0.0706,0.1274 -0.1087,0.1949 -0.0557,0.0957 -0.1111,0.1893 -0.166,0.2814 -0.0549,0.092 -0.1094,0.1821 -0.1635,0.2702 -0.0541,0.0884 -0.1077,0.1749 -0.161,0.2594 -0.0532,0.0848 -0.1061,0.1677 -0.1585,0.2489 -0.0524,0.0812 -0.1045,0.1605 -0.1561,0.2381 -0.0516,0.0776 -0.1028,0.1536 -0.1536,0.2276 -0.0508,0.074 -0.1012,0.1467 -0.1511,0.2175 -0.05,0.0708 -0.0996,0.1399 -0.1487,0.2073 -0.0491,0.0675 -0.0979,0.1333 -0.1462,0.1972 -0.0483,0.0642 -0.0963,0.1268 -0.1438,0.1874 -0.0475,0.0609 -0.0946,0.1199 -0.1413,0.1775 -0.0024,0.003 -0.0046,0.007 -0.0069,0.01 0.195,-0.0262 0.9686,-0.712 1.3717,-1.2158 0.9235,-1.154 1.3969,-2.5207 1.2556,-3.6246 -0.1292,-1.0093 -0.5792,-1.8334 -1.4153,-2.592 -0.2076,-0.1883 -0.2305,-0.1631 -0.2909,-0.1444 -0.2071,0.1251 -0.2308,0.1146 -0.0911,0.1916z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_sony_wf_800n_left.xml"
+ line="8"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1366 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="m6.7195,6.9789v2.5781h0.5762v-0.9785h0.209c0.106,0 0.175,0.023 0.2168,0.0527 0.0383,0.0268 0.11,0.1125 0.1816,0.2578v0.002l0.332,0.666h0.6328l-0.4141,-0.8223v-0.002c-0.0668,-0.1356 -0.1349,-0.2457 -0.209,-0.332 -0.0229,-0.027 -0.0533,-0.036 -0.0781,-0.0586 0.0619,-0.0418 0.1407,-0.0643 0.1836,-0.125 0.0865,-0.1218 0.127,-0.2731 0.127,-0.4394 0,-0.249 -0.0785,-0.4626 -0.2422,-0.6016 -0.1639,-0.1392 -0.3947,-0.1973 -0.6777,-0.1973zM6.9832,7.2426h0.0488v0.125,0.9473 0.008,0.9707h-0.0488zM7.6941,7.2797c0.1482,0.0194 0.3006,0.0378 0.3711,0.0977 0.0967,0.0822 0.1484,0.2029 0.1484,0.4004 0,0.1251 -0.0282,0.2148 -0.0781,0.2852L8.1355,8.0649c-0.0301,0.0431 -0.0878,0.0643 -0.1367,0.0918 0.0941,-0.098 0.1484,-0.2292 0.1484,-0.3789 0,-0.16 -0.051,-0.3074 -0.1602,-0.4043 -0.074,-0.067 -0.1869,-0.0752 -0.293,-0.0937zM7.2957,7.5004h0.2617c0.1307,0 0.2076,0.0301 0.252,0.0703h0.002c0.0449,0.0395 0.0723,0.0969 0.0723,0.207 0,0.1102 -0.028,0.1671 -0.0742,0.209 -0.0444,0.0402 -0.1213,0.0703 -0.252,0.0703h-0.2617zM7.9285,8.2035c-0.0069,0.002 -0.0083,0.008 -0.0156,0.01l-0.0957,0.0273v0.1582c-0.083,-0.0446 -0.1767,-0.0726 -0.2832,-0.0762h0.0234c0.1477,0 0.2679,-0.0478 0.3711,-0.1191zM7.9168,8.4731c0.0361,0.0144 0.0784,0.0403 0.127,0.0977v0.002c0.0534,0.062 0.1131,0.1558 0.1738,0.2793l0.2227,0.4414h-0.041l-0.2598,-0.5215c-0.068,-0.1379 -0.1406,-0.2243 -0.2227,-0.2988z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_sony_wf_800n_right.xml"
+ line="13"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (2469 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="m3.9723,1.4897c-0.0293,0.0131 -0.0571,0.0295 -0.0848,0.0455 -0.0277,0.0164 -0.055,0.0328 -0.0829,0.0508 -0.028,0.0164 -0.0564,0.0373 -0.0864,0.0583 -0.03,0.0196 -0.0614,0.0436 -0.0952,0.0681 -0.0221,0.0164 -0.044,0.0328 -0.0658,0.0495 -0.0217,0.0164 -0.0435,0.0344 -0.065,0.0521 -0.043,0.0357 -0.0853,0.0727 -0.1268,0.1114 -0.0414,0.0386 -0.082,0.0789 -0.1216,0.1202 -0.0395,0.0413 -0.078,0.0839 -0.1152,0.1274 -0.0373,0.0436 -0.0733,0.0881 -0.1079,0.1336 -0.0346,0.0455 -0.0678,0.0917 -0.0993,0.1385 -0.0316,0.0468 -0.0615,0.0943 -0.0897,0.1421 -0.0142,0.0229 -0.0277,0.0478 -0.041,0.0721 -0.0132,0.0229 -0.026,0.0482 -0.0381,0.0724 -0.0316,0.0622 -0.0604,0.1215 -0.0868,0.1785 -0.0264,0.057 -0.0503,0.1117 -0.072,0.1647 -0.0217,0.0531 -0.0411,0.1048 -0.0586,0.1562 -0.0086,0.0262 -0.0168,0.0511 -0.0247,0.0763 -0.0079,0.0262 -0.0152,0.0508 -0.0217,0.076 -0.0069,0.0262 -0.0132,0.0508 -0.0191,0.0763 -0.0059,0.0262 -0.0115,0.0514 -0.0168,0.0773 -0.0102,0.0521 -0.0191,0.1051 -0.0264,0.1602 -0.0076,0.055 -0.0135,0.112 -0.0184,0.1716 -0.0049,0.0596 -0.0089,0.1222 -0.0119,0.188 -0.0022,0.0488 -0.0036,0.0963 -0.0046,0.1425 -0.0008,0.0465 -0.0008,0.0917 -0.0001,0.1359 0.0008,0.0442 0.0024,0.0878 0.0049,0.1307 0.0025,0.0429 0.0059,0.0852 0.0102,0.1271 0.0043,0.0419 0.0096,0.0835 0.0158,0.1248 0.0063,0.0413 0.0135,0.0825 0.0221,0.1241 0.0086,0.0413 0.0181,0.0829 0.029,0.1248 0.0109,0.0419 0.0227,0.0839 0.0359,0.1268 0.0132,0.0429 0.0277,0.0861 0.0434,0.1304 0.0158,0.0442 0.0329,0.0891 0.0514,0.1349 0.0184,0.0459 0.0385,0.093 0.0599,0.1415 0.0214,0.0485 0.0443,0.0979 0.0687,0.1494 0.0244,0.0511 0.0505,0.1038 0.0781,0.1585 0.0277,0.0544 0.0568,0.1107 0.0878,0.169 0.031,0.0583 0.0636,0.1186 0.098,0.1811 0.0344,0.0626 0.0706,0.1274 0.1087,0.1949 0.0557,0.0957 0.1111,0.1893 0.166,0.2814 0.0549,0.092 0.1094,0.1821 0.1635,0.2702 0.0541,0.0884 0.1077,0.1749 0.161,0.2594 0.0532,0.0848 0.1061,0.1677 0.1585,0.2489 0.0524,0.0812 0.1045,0.1605 0.1561,0.2381 0.0516,0.0776 0.1028,0.1536 0.1536,0.2276 0.0508,0.074 0.1012,0.1467 0.1511,0.2175 0.05,0.0708 0.0996,0.1399 0.1487,0.2073 0.0491,0.0675 0.0979,0.1333 0.1462,0.1972 0.0483,0.0642 0.0963,0.1268 0.1438,0.1874 0.0475,0.0609 0.0946,0.1199 0.1413,0.1775 0.0024,0.003 0.0046,0.007 0.0069,0.01 -0.195,-0.0262 -0.9686,-0.712 -1.3717,-1.2158 -0.9235,-1.154 -1.3969,-2.5207 -1.2556,-3.6246 0.1292,-1.0093 0.5792,-1.8334 1.4153,-2.592 0.2076,-0.1883 0.2305,-0.1631 0.2909,-0.1444 0.2071,0.1251 0.2308,0.1146 0.0911,0.1916z""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_sony_wf_800n_right.xml"
+ line="18"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="VectorPath"
+ message="Very long vector path (1194 characters), which is bad for performance. Considering reducing precision, removing minor details or rasterizing vector."
+ errorLine1=" android:pathData="M6.05 4.14L5.66 3.75c-0.39-0.39-1.02-0.38-1.4 0L4.25 3.76c-0.39 0.39-0.39 1.02 0 1.4l0.39 0.39c0.39 0.39 1.01 0.39 1.4 0l0.01-0.01c0.39-0.38 0.39-1.02 0-1.4zM3.01 10.5H1.99C1.44 10.5 1 10.94 1 11.49v0.01c0 0.55 0.44 0.99 0.99 0.99H3c0.56 0.01 1-0.43 1-0.98V11.5c0-0.56-0.44-1-0.99-1zm9-9.95H12c-0.56 0-1 0.44-1 0.99V2.5c0 0.55 0.44 0.99 0.99 0.99H12c0.56 0.01 1-0.43 1-0.98V1.54c0-0.55-0.44-0.99-0.99-0.99zm7.74 3.21c-0.39-0.39-1.02-0.39-1.41-0.01l-0.39 0.39c-0.39 0.39-0.39 1.02 0 1.4l0.01 0.01c0.39 0.39 1.02 0.39 1.4 0l0.39-0.39c0.39-0.39 0.39-1.01 0-1.4zm-1.81 15.1l0.39 0.39c0.39 0.39 1.02 0.39 1.41 0 0.39-0.39 0.39-1.02 0-1.41l-0.39-0.39c-0.39-0.39-1.02-0.38-1.4 0-0.4 0.4-0.4 1.02-0.01 1.41zM20 11.49v0.01c0 0.55 0.44 0.99 0.99 0.99H22c0.55 0 0.99-0.44 0.99-0.99v-0.01c0-0.55-0.44-0.99-0.99-0.99h-1.01c-0.55 0-0.99 0.44-0.99 0.99zM12 5.5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm-0.01 16.95H12c0.55 0 0.99-0.44 0.99-0.99V20.5c0-0.55-0.44-0.99-0.99-0.99h-0.01c-0.55 0-0.99 0.44-0.99 0.99v0.96c0 0.55 0.44 0.99 0.99 0.99zm-7.74-3.21c0.39 0.39 1.02 0.39 1.41 0l0.39-0.39c0.39-0.39 0.38-1.02 0-1.4l-0.01-0.01c-0.39-0.39-1.02-0.39-1.41 0l-0.39 0.39c-0.38 0.4-0.38 1.02 0.01 1.41z" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/drawable/ic_wb_sunny.xml"
+ line="9"
+ column="27"/>
+ </issue>
+
+ <issue
+ id="ViewHolder"
+ message="Unconditional layout inflation from view adapter: Should use View Holder pattern (use recycled view passed into this method as the second parameter) for smoother scrolling"
+ errorLine1=" View itemView = inflater.inflate(groupid, parent, false);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SpinnerWithIconAdapter.java"
+ line="51"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="HandlerLeak"
+ message="This `Handler` class should be static or leaks might occur (nodomain.freeyourgadget.gadgetbridge.util.WebViewSingleton.IncomingHandler)"
+ errorLine1=" private class IncomingHandler extends Handler {"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java"
+ line="126"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="MergeRootFrame"
+ message="This `<FrameLayout>` can be replaced with a `<merge>` tag"
+ errorLine1="<FrameLayout android:id="@+id/webview_placeholder""
+ errorLine2="^">
+ <location
+ file="src/main/res/layout/activity_external_pebble_js.xml"
+ line="2"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UseValueOf"
+ message="Use `Long.valueOf(item.getValue())` instead"
+ errorLine1=" kindArray.add(new SpinnerWithIconItem(item.getKey(), new Long(item.getValue()), ActivityKind.getIconId(item.getValue())));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesFilter.java"
+ line="125"
+ column="66"/>
+ </issue>
+
+ <issue
+ id="UseValueOf"
+ message="Use `Long.valueOf(0)` instead"
+ errorLine1=" SpinnerWithIconItem allActivities = new SpinnerWithIconItem(getString(R.string.activity_summaries_all_activities), new Long(0), ActivityKind.getIconId(0));"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesFilter.java"
+ line="129"
+ column="124"/>
+ </issue>
+
+ <issue
+ id="UseValueOf"
+ message="Use `Long.valueOf(entry.getValue())` instead"
+ errorLine1=" new Long(entry.getValue()),"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesFilter.java"
+ line="414"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="DisableBaselineAlignment"
+ message="Set `android:baselineAligned="false"` on this element for better performance"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_calibration.xml"
+ line="22"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="InefficientWeight"
+ message="Use a `layout_width` of `0dp` instead of `match_parent` for better performance"
+ errorLine1=" android:layout_width="match_parent""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_summary_dashboard_item.xml"
+ line="28"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="InefficientWeight"
+ message="Use a `layout_height` of `0dp` instead of `fill_parent` for better performance"
+ errorLine1=" android:layout_height="fill_parent""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_pai_chart.xml"
+ line="288"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="InefficientWeight"
+ message="Use a `layout_height` of `0dp` instead of `match_parent` for better performance"
+ errorLine1=" android:layout_height="match_parent""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout-land/fragment_stresschart.xml"
+ line="15"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="InefficientWeight"
+ message="Use a `layout_height` of `0dp` instead of `fill_parent` for better performance"
+ errorLine1=" android:layout_height="fill_parent""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout-land/fragment_weeksteps_chart.xml"
+ line="27"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="InefficientWeight"
+ message="Use a `layout_height` of `0dp` instead of `3dp` for better performance"
+ errorLine1=" android:layout_height="3dp""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/widget.xml"
+ line="122"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="InefficientWeight"
+ message="Use a `layout_height` of `0dp` instead of `3dp` for better performance"
+ errorLine1=" android:layout_height="3dp""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/widget.xml"
+ line="164"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="InefficientWeight"
+ message="Use a `layout_height` of `0dp` instead of `3dp` for better performance"
+ errorLine1=" android:layout_height="3dp""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/widget.xml"
+ line="205"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="NestedWeights"
+ message="Nested weights are bad for performance"
+ errorLine1=" android:layout_weight="40" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout-land/fragment_sleepchart.xml"
+ line="32"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="NestedWeights"
+ message="Nested weights are bad for performance"
+ errorLine1=" android:layout_weight="40" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout-land/fragment_stresschart.xml"
+ line="23"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="NestedWeights"
+ message="Nested weights are bad for performance"
+ errorLine1=" android:layout_weight="40" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout-land/fragment_weeksteps_chart.xml"
+ line="28"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `?android:attr/activatedBackgroundIndicator` with a theme that also paints a background (inferred theme is `@style/GadgetbridgeTheme`)"
+ errorLine1=" android:background="?android:attr/activatedBackgroundIndicator""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_blacklist.xml"
+ line="6"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `?android:attr/activatedBackgroundIndicator` with a theme that also paints a background (inferred theme is `@style/GadgetbridgeTheme`)"
+ errorLine1=" android:background="?android:attr/activatedBackgroundIndicator""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_specific_notification_app_list.xml"
+ line="6"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `?android:attr/activatedBackgroundIndicator` with a theme that also paints a background (inferred theme is `@style/GadgetbridgeTheme`)"
+ errorLine1=" android:background="?android:attr/activatedBackgroundIndicator""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_cal_blacklist.xml"
+ line="5"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `?android:attr/activatedBackgroundIndicator` with a theme that also paints a background (inferred theme is `@style/GadgetbridgeTheme`)"
+ errorLine1=" android:background="?android:attr/activatedBackgroundIndicator">"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details.xml"
+ line="5"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `?android:attr/activatedBackgroundIndicator` with a theme that also paints a background (inferred theme is `@style/GadgetbridgeTheme`)"
+ errorLine1=" android:background="?android:attr/activatedBackgroundIndicator""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details_horizontal.xml"
+ line="5"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `?android:attr/activatedBackgroundIndicator` with a theme that also paints a background (inferred theme is `@style/GadgetbridgeTheme`)"
+ errorLine1=" android:background="?android:attr/activatedBackgroundIndicator""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details_small.xml"
+ line="5"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `?attr/drawer_header_color` with a theme that also paints a background (inferred theme is `@style/GadgetbridgeTheme`)"
+ errorLine1=" android:background="?attr/drawer_header_color""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/nav_header_main.xml"
+ line="6"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `?selectableItemBackground` with a theme that also paints a background (inferred theme is `@style/GadgetbridgeTheme`)"
+ errorLine1=" android:background="?selectableItemBackground""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/preference_checkbox.xml"
+ line="6"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `@color/primary_light` with a theme that also paints a background (inferred theme is `@style/GadgetbridgeTheme`)"
+ errorLine1=" android:background="@color/primary_light""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/sleep_alarm_widget.xml"
+ line="4"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="Overdraw"
+ message="Possible overdraw: Root element paints background `@color/primary_dark` with a theme that also paints a background (inferred theme is `@style/Theme_AppCompat_Light_Dialog`)"
+ errorLine1=" android:background="@color/primary_dark""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/widget_alarms_activity_list.xml"
+ line="6"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.array.autoheartrate_interval_hours` appears to be unused"
+ errorLine1=" <string-array name="autoheartrate_interval_hours">"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/arrays.xml"
+ line="3076"
+ column="19"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.color.blacklist_checkboxes` appears to be unused"
+ errorLine1="<selector xmlns:android="http://schemas.android.com/apk/res/android">"
+ errorLine2="^">
+ <location
+ file="src/main/res/color/blacklist_checkboxes.xml"
+ line="2"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.color.btn_dark` appears to be unused"
+ errorLine1="<selector xmlns:android="http://schemas.android.com/apk/res/android">"
+ errorLine2="^">
+ <location
+ file="src/main/res/color/btn_dark.xml"
+ line="2"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.xml.changelog_master` appears to be unused"
+ errorLine1="<changelog>"
+ errorLine2="^">
+ <location
+ file="src/main/res/xml/changelog_master.xml"
+ line="2"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.color.tertiarytext_black` appears to be unused"
+ errorLine1=" <color name="tertiarytext_black" type="color">#000000</color>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/colors.xml"
+ line="17"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.color.divider` appears to be unused"
+ errorLine1=" <color name="divider" type="color">#1f000000</color>"
+ errorLine2=" ~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/colors.xml"
+ line="19"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.xml.devicesettings_autoheartrate` appears to be unused"
+ errorLine1="<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">"
+ errorLine2="^">
+ <location
+ file="src/main/res/xml/devicesettings_autoheartrate.xml"
+ line="2"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.xml.devicesettings_connect_two_devices` appears to be unused"
+ errorLine1="<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">"
+ errorLine2="^">
+ <location
+ file="src/main/res/xml/devicesettings_connect_two_devices.xml"
+ line="2"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.xml.devicesettings_donotdisturb_allday_liftwirst` appears to be unused"
+ errorLine1="<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">"
+ errorLine2="^">
+ <location
+ file="src/main/res/xml/devicesettings_donotdisturb_allday_liftwirst.xml"
+ line="2"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.xml.devicesettings_force_options` appears to be unused"
+ errorLine1="<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">"
+ errorLine2="^">
+ <location
+ file="src/main/res/xml/devicesettings_force_options.xml"
+ line="2"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.xml.devicesettings_header_sound_vibration` appears to be unused"
+ errorLine1="<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">"
+ errorLine2="^">
+ <location
+ file="src/main/res/xml/devicesettings_header_sound_vibration.xml"
+ line="2"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.xml.devicesettings_inactivity_sheduled` appears to be unused"
+ errorLine1="<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">"
+ errorLine2="^">
+ <location
+ file="src/main/res/xml/devicesettings_inactivity_sheduled.xml"
+ line="2"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.dimen.nav_header_height` appears to be unused"
+ errorLine1=" <dimen name="nav_header_height">160dp</dimen>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/dimens.xml"
+ line="14"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.dimen.fab_margin` appears to be unused"
+ errorLine1=" <dimen name="fab_margin">16dp</dimen>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/dimens.xml"
+ line="15"
+ column="12"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_bluetooth_searching` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_bluetooth_searching.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_calculate` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_calculate.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_calendar_from` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:tint="#7E7E7E" android:viewportWidth="24" android:viewportHeight="24">"
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_calendar_from.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_calendar_today` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_calendar_today.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_create` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_create.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_delete` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_delete.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_earbuds_battery` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_earbuds_battery.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_filter_9_plus` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_filter_9_plus.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_monochrome_photos` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_monochrome_photos.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_shoe` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_shoe.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_show_chart` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_show_chart.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.ic_wifi` appears to be unused"
+ errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/ic_wifi.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.drawable.side_nav_bar` appears to be unused"
+ errorLine1="<shape xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/drawable/side_nav_bar.xml"
+ line="1"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.anim.slidefromtop` appears to be unused"
+ errorLine1="<set xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2="^">
+ <location
+ file="src/main/res/anim/slidefromtop.xml"
+ line="2"
+ column="1"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.application_name_banglejs_main` appears to be unused"
+ errorLine1=" <string name="application_name_banglejs_main">Bangle.js Gadgetbridge</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="9"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.title_activity_controlcenter_banglejs_main` appears to be unused"
+ errorLine1=" <string name="title_activity_controlcenter_banglejs_main">Bangle.js Gadgetbridge</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="10"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.about_activity_title_banglejs_main` appears to be unused"
+ errorLine1=" <string name="about_activity_title_banglejs_main">About Bangle.js Gadgetbridge</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="11"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.about_description_banglejs_main` appears to be unused"
+ errorLine1=" <string name="about_description_banglejs_main">Android companion app for Bangle.js built on top of the Gadgetbridge project, with added Internet Access.\n\nDue to Google Play Store policies, we are not allowed a donation link in the app itself, but if you like this app please consider donating via the Gadgetbridge homepage below.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="12"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.gadgetbridge_running_banglejs_main` appears to be unused"
+ errorLine1=" <string name="gadgetbridge_running_banglejs_main">Bangle.js running</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="13"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.application_name_banglejs_nightly` appears to be unused"
+ errorLine1=" <string name="application_name_banglejs_nightly">Bangle.js Gadgetbridge (Nightly)</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="15"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.title_activity_controlcenter_banglejs_nightly` appears to be unused"
+ errorLine1=" <string name="title_activity_controlcenter_banglejs_nightly">Bangle.js Gadgetbridge (Nightly)</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="16"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.about_activity_title_banglejs_nightly` appears to be unused"
+ errorLine1=" <string name="about_activity_title_banglejs_nightly">About Bangle.js Gadgetbridge (Nightly)</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="17"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.about_description_banglejs_nightly` appears to be unused"
+ errorLine1=" <string name="about_description_banglejs_nightly">Android companion app for Bangle.js built on top of the Gadgetbridge project, with added Internet Access.\n\nDue to Google Play Store policies, we are not allowed a donation link in the app itself, but if you like this app please consider donating via the Gadgetbridge homepage below.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="18"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.gadgetbridge_running_banglejs_nightly` appears to be unused"
+ errorLine1=" <string name="gadgetbridge_running_banglejs_nightly">Nightly Bangle.js running</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="19"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.application_name_main_nightly` appears to be unused"
+ errorLine1=" <string name="application_name_main_nightly">Gadgetbridge (Nightly)</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="21"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.title_activity_controlcenter_main_nightly` appears to be unused"
+ errorLine1=" <string name="title_activity_controlcenter_main_nightly">Gadgetbridge Nightly</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="22"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.about_activity_title_main_nightly` appears to be unused"
+ errorLine1=" <string name="about_activity_title_main_nightly">About Gadgetbridge Nightly</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="23"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.about_description_main_nightly` appears to be unused"
+ errorLine1=" <string name="about_description_main_nightly">Cloudless copylefted libre replacement for closed source Android gadget apps from vendors. Nightly releases of Gadgetbridge. It cannot be installed if you already have either the Gadgetbridge or the Pebble app installed, due to a conflict in the Pebble provider.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="24"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.gadgetbridge_running_main_nightly` appears to be unused"
+ errorLine1=" <string name="gadgetbridge_running_main_nightly">Nightly GB running</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="25"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.application_name_main_nopebble` appears to be unused"
+ errorLine1=" <string name="application_name_main_nopebble">Gadgetbridge (Nightly, No Pebble provider)</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="27"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.title_activity_controlcenter_main_nopebble` appears to be unused"
+ errorLine1=" <string name="title_activity_controlcenter_main_nopebble">Gadgetbridge Nightly No Pebble </string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="28"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.about_activity_title_main_nopebble` appears to be unused"
+ errorLine1=" <string name="about_activity_title_main_nopebble">About Gadgetbridge Nightly No Pebble</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="29"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.about_description_main_nopebble` appears to be unused"
+ errorLine1=" <string name="about_description_main_nopebble">Cloudless copylefted libre replacement for closed source Android gadget apps from vendors. Nightly releases of Gadgetbridge. This version has the Pebble provider renamed to prevent conflicts, so some Pebble related integrations will not work, but it can be installed alongside existing Gadgetbridge installation.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="30"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.gadgetbridge_running_main_nopebble` appears to be unused"
+ errorLine1=" <string name="gadgetbridge_running_main_nopebble">Nightly NoPebble GB running</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="31"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.app_move_to_top` appears to be unused"
+ errorLine1=" <string name="app_move_to_top">Move to top</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="122"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.blacklist_all_for_notifications` appears to be unused"
+ errorLine1=" <string name="blacklist_all_for_notifications">Blacklist all for notifications</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="129"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.whitelist_all_for_notifications` appears to be unused"
+ errorLine1=" <string name="whitelist_all_for_notifications">Whitelist all for notifications</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="130"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.fw_upgrade_notice_miband7` appears to be unused"
+ errorLine1=" <string name="fw_upgrade_notice_miband7">You are about to install the %s firmware on your Xiaomi Smart Band 7.\n\nYour band will reboot after installing the .zip file.\n\nPROCEED AT YOUR OWN RISK!</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="159"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.fw_upgrade_notice_amazfit_gts3` appears to be unused"
+ errorLine1=" <string name="fw_upgrade_notice_amazfit_gts3">You are about to install the %s firmware on your Amazfit GTS 3.\n\nYour band will reboot after installing the .zip file.\n\nPROCEED AT YOUR OWN RISK!</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="160"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.fw_upgrade_notice_amazfit_gts4` appears to be unused"
+ errorLine1=" <string name="fw_upgrade_notice_amazfit_gts4">You are about to install the %s firmware on your Amazfit GTS 4.\n\nYour band will reboot after installing the .zip file.\n\nPROCEED AT YOUR OWN RISK!</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="161"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.fw_upgrade_notice_amazfit_gts4_mini` appears to be unused"
+ errorLine1=" <string name="fw_upgrade_notice_amazfit_gts4_mini">You are about to install the %s firmware on your Amazfit GTS 4 Mini.\n\nYour band will reboot after installing the .zip file.\n\nPROCEED AT YOUR OWN RISK!</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="162"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.fw_upgrade_notice_amazfit_gtr3` appears to be unused"
+ errorLine1=" <string name="fw_upgrade_notice_amazfit_gtr3">You are about to install the %s firmware on your Amazfit GTR 3.\n\nYour band will reboot after installing the .zip file.\n\nPROCEED AT YOUR OWN RISK!</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="163"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.fw_upgrade_notice_amazfit_gtr3_pro` appears to be unused"
+ errorLine1=" <string name="fw_upgrade_notice_amazfit_gtr3_pro">You are about to install the %s firmware on your Amazfit GTR 3 Pro.\n\nYour band will reboot after installing the .zip file.\n\nPROCEED AT YOUR OWN RISK!</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="164"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.fw_upgrade_notice_amazfit_gtr4` appears to be unused"
+ errorLine1=" <string name="fw_upgrade_notice_amazfit_gtr4">You are about to install the %s firmware on your Amazfit GTR 4.\n\nYour band will reboot after installing the .zip file.\n\nPROCEED AT YOUR OWN RISK!</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="165"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.fw_upgrade_notice_amazfit_trex2` appears to be unused"
+ errorLine1=" <string name="fw_upgrade_notice_amazfit_trex2">You are about to install the %s firmware on your Amazfit T-Rex 2.\n\nYour band will reboot after installing the .zip file.\n\nPROCEED AT YOUR OWN RISK!</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="166"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.fw_upgrade_notice_amazfit_band7` appears to be unused"
+ errorLine1=" <string name="fw_upgrade_notice_amazfit_band7">You are about to install the %s firmware on your Amazfit Band 7.\n\nYour band will reboot after installing the .zip file.\n\nPROCEED AT YOUR OWN RISK!</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="167"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.fw_upgrade_notice_amazfit_cheetah_pro` appears to be unused"
+ errorLine1=" <string name="fw_upgrade_notice_amazfit_cheetah_pro">You are about to install the %s firmware on your Amazfit Cheetah Pro.\n\nYour band will reboot after installing the .zip file.\n\nPROCEED AT YOUR OWN RISK!</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="168"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.open_fw_installer_ensure_device_connected` appears to be unused"
+ errorLine1=" <string name="open_fw_installer_ensure_device_connected">Make sure that the device %s is connected</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="191"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_pair_bluetooth_calls_help_title` appears to be unused"
+ errorLine1=" <string name="pref_pair_bluetooth_calls_help_title">How to receive bluetooth calls</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="241"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_title_enable_calendar_sync` appears to be unused"
+ errorLine1=" <string name="pref_title_enable_calendar_sync">Sync calendar</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="333"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_summary_enable_calendar_sync` appears to be unused"
+ errorLine1=" <string name="pref_summary_enable_calendar_sync">Send calendar events to the timeline</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="334"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.preferences_makibes_hr3_settings` appears to be unused"
+ errorLine1=" <string name="preferences_makibes_hr3_settings">Makibes HR3 settings</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="454"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.zetime_title_alarm_signaling` appears to be unused"
+ errorLine1=" <string name="zetime_title_alarm_signaling">Set type of signaling for the alarm</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="488"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_hr_alarm_activity` appears to be unused"
+ errorLine1=" <string name="prefs_hr_alarm_activity">Heart rate alarm during sports activity</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="508"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_hr_alarm_low` appears to be unused"
+ errorLine1=" <string name="prefs_hr_alarm_low">Low limit</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="509"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_hr_alarm_high` appears to be unused"
+ errorLine1=" <string name="prefs_hr_alarm_high">High limit</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="510"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_workout_detection_enabled_summary` appears to be unused"
+ errorLine1=" <string name="pref_workout_detection_enabled_summary">Enable automatic detection of this workout</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="549"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_disable_new_ble_scanning` appears to be unused"
+ errorLine1=" <string name="pref_disable_new_ble_scanning">Disable new BLE scanning</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="572"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_summary_disable_new_ble_scanning` appears to be unused"
+ errorLine1=" <string name="pref_summary_disable_new_ble_scanning">Check this option if your device cannot be found during discovery</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="573"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.test` appears to be unused"
+ errorLine1=" <string name="test">Test</string>"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="580"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.tap_connected_device_for_app_mananger` appears to be unused"
+ errorLine1=" <string name="tap_connected_device_for_app_mananger">Tap connected device for App manager</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="586"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.tap_connected_device_for_activity` appears to be unused"
+ errorLine1=" <string name="tap_connected_device_for_activity">Tap connected device for activity</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="587"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.tap_connected_device_for_vibration` appears to be unused"
+ errorLine1=" <string name="tap_connected_device_for_vibration">Tap connected device for vibration</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="588"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.tap_a_device_to_connect` appears to be unused"
+ errorLine1=" <string name="tap_a_device_to_connect">Tap a device to connect</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="589"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pairing_unable_to_pair_with` appears to be unused"
+ errorLine1=" <string name="pairing_unable_to_pair_with">"Unable to pair with %1$s (%2$s)"</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="612"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_header_vibration_count` appears to be unused"
+ errorLine1=" <string name="pref_header_vibration_count">Vibration count</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="640"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.title_activity_sleepmonitor` appears to be unused"
+ errorLine1=" <string name="title_activity_sleepmonitor">Sleep monitor</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="642"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_screen_vibration_profile` appears to be unused"
+ errorLine1=" <string name="pref_screen_vibration_profile">Vibration profile</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="663"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.control_center_cancel_to_stop_vibration` appears to be unused"
+ errorLine1=" <string name="control_center_cancel_to_stop_vibration">Cancel to stop vibration.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="730"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.chart_no_active_data` appears to be unused"
+ errorLine1=" <string name="chart_no_active_data">No activities detected.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="786"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.chart_get_active_and_synchronize` appears to be unused"
+ errorLine1=" <string name="chart_get_active_and_synchronize">Do some activity and synchronize device.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="787"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.clock` appears to be unused"
+ errorLine1=" <string name="clock">Clock</string>"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="879"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.discovery_scanning_intensity_warning` appears to be unused"
+ errorLine1=" <string name="discovery_scanning_intensity_warning">廿匕丕 賵丕噩賴鬲 鬲噩賲賷丿賸丕 兀賵 毓丿賲 丕爻鬲噩丕亘丞 貙 賮丨丕賵賱 囟亘胤 賰孬丕賮丞 丕賱賲爻丨 毓賱賶 賲爻鬲賵賶 兀賯賱. 廿匕丕 賱賲 賷鬲賲 丕賰鬲卮丕賮 噩賴丕夭賰 貙 賮丨丕賵賱 囟亘胤 賰孬丕賮丞 丕賱賲爻丨 毓賱賶 賲爻鬲賵賶 兀毓賱賶.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ar/strings.xml"
+ line="904"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_title_dont_ack_transfer` appears to be unused"
+ errorLine1=" <string name="pref_title_dont_ack_transfer">Do not ACK activity data transfer</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="912"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_summary_dont_ack_transfers` appears to be unused"
+ errorLine1=" <string name="pref_summary_dont_ack_transfers">If not ACKed to the band, activity data is not cleared. Useful if GB is used together with other apps.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="913"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_enable_unsupported_settings_title` appears to be unused"
+ errorLine1=" <string name="pref_enable_unsupported_settings_title">Enable unsupported settings</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="915"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_enable_unsupported_settings_summary` appears to be unused"
+ errorLine1=" <string name="pref_enable_unsupported_settings_summary">This will enable access to all available settings, even if unsupported by the device. This can cause instability and crashes on the device.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="916"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.preferences_miband_settings` appears to be unused"
+ errorLine1=" <string name="preferences_miband_settings">廿毓丿丕丿丕鬲 Mi Band / Amazfit</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ar/strings.xml"
+ line="970"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.device_hw` appears to be unused"
+ errorLine1=" <string name="device_hw">Hardware revision: %1$s</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1117"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.device_fw` appears to be unused"
+ errorLine1=" <string name="device_fw">Firmware version: %1$s</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1118"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.DEVINFO_HR_VER` appears to be unused"
+ errorLine1=" <string name="DEVINFO_HR_VER">"HR: "</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1120"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.watchface_dialog_title_add_widget` appears to be unused"
+ errorLine1=" <string name="watchface_dialog_title_add_widget">Afegeix un giny</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ca/strings.xml"
+ line="1150"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.dbmanagementactivity_error_importing_shared` appears to be unused"
+ errorLine1=" <string name="dbmanagementactivity_error_importing_shared">"Error importing preference: %1$s"</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1152"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.activity_data_management_directory_content_title` appears to be unused"
+ errorLine1=" <string name="activity_data_management_directory_content_title">Export/Import directory content</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1187"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.activity_web_view` appears to be unused"
+ errorLine1=" <string name="activity_web_view">Web View Activity</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1207"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.miband2_prefs_timeformat` appears to be unused"
+ errorLine1=" <string name="miband2_prefs_timeformat">Mi2: Time format</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1211"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.mi3_night_mode_sunset` appears to be unused"
+ errorLine1=" <string name="mi3_night_mode_sunset">At sunset</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1222"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string._pebble_watch_reply` appears to be unused"
+ errorLine1=" <string name="_pebble_watch_reply">Reply</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1239"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.watchface_toast_settings_incomplete` appears to be unused"
+ errorLine1=" <string name="watchface_toast_settings_incomplete">Configuraci贸 incompleta, giny no afegit</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ca/strings.xml"
+ line="1360"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.activity_type_kickboxing` appears to be unused"
+ errorLine1=" <string name="activity_type_kickboxing">Kickboxing</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1368"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.application_name_banglejs_nopebble` appears to be unused"
+ errorLine1=" <string name="application_name_banglejs_nopebble">Bangle.js Gadgetbridge</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ca/strings.xml"
+ line="1458"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.title_activity_controlcenter_banglejs_nopebble` appears to be unused"
+ errorLine1=" <string name="title_activity_controlcenter_banglejs_nopebble">Bangle.js Gadgetbridge</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ca/strings.xml"
+ line="1459"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.about_activity_title_banglejs_nopebble` appears to be unused"
+ errorLine1=" <string name="about_activity_title_banglejs_nopebble">Sobre Bangle.js Gadgetbridge</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ca/strings.xml"
+ line="1460"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.devicetype_watchx` appears to be unused"
+ errorLine1=" <string name="devicetype_watchx">Watch X</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1485"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.about_description_banglejs_nopebble` appears to be unused"
+ errorLine1=" <string name="about_description_banglejs_nopebble">Doprovodn谩 aplikace pro Bangle.js pro Android vytvo艡en谩 na z谩klad臎 projektu Gadgetbridge s p艡idan媒m p艡铆stupem k internetu."
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-cs/strings.xml"
+ line="1584"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.gadgetbridge_running_banglejs_nopebble` appears to be unused"
+ errorLine1=" <string name="gadgetbridge_running_banglejs_nopebble">Bangle.js b臎啪铆</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-cs/strings.xml"
+ line="1597"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.menuitem_unknown` appears to be unused"
+ errorLine1=" <string name="menuitem_unknown">Unknown</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1599"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.heartrate_bpm_112` appears to be unused"
+ errorLine1=" <string name="heartrate_bpm_112">112 tep暖/min</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-cs/strings.xml"
+ line="1640"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.menuitem_unsupported` appears to be unused"
+ errorLine1=" <string name="menuitem_unsupported">[UNSUPPORTED] %s</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1640"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_title_upper_button_function_double` appears to be unused"
+ errorLine1=" <string name="pref_title_upper_button_function_double">Upper Button double</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1749"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_title_middle_button_function_double` appears to be unused"
+ errorLine1=" <string name="pref_title_middle_button_function_double">Middle Button double</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1750"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_title_lower_button_function_double` appears to be unused"
+ errorLine1=" <string name="pref_title_lower_button_function_double">Lower Button double</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1751"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.watchface_setting_title_button_toggle_widgets` appears to be unused"
+ errorLine1=" <string name="watchface_setting_title_button_toggle_widgets">P艡ep铆n谩n铆 viditelnosti widget暖</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-cs/strings.xml"
+ line="1781"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.error_exporting_device_preferences` appears to be unused"
+ errorLine1=" <string name="error_exporting_device_preferences">Error exporting device specific preferences</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1801"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.sonyswr12_settings_title` appears to be unused"
+ errorLine1=" <string name="sonyswr12_settings_title">Sony SWR12 Settings</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1809"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.distanceMeters` appears to be unused"
+ errorLine1=" <string name="distanceMeters">Distance</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1817"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.ascentMeters` appears to be unused"
+ errorLine1=" <string name="ascentMeters">Uphill</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1818"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.descentMeters` appears to be unused"
+ errorLine1=" <string name="descentMeters">Downhill</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1819"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.ascentDistance` appears to be unused"
+ errorLine1=" <string name="ascentDistance">Uphill distance</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1820"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.descentDistance` appears to be unused"
+ errorLine1=" <string name="descentDistance">Downhill distance</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1821"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.flatDistance` appears to be unused"
+ errorLine1=" <string name="flatDistance">Flat distance</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1822"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.elevationGain` appears to be unused"
+ errorLine1=" <string name="elevationGain">Elevation gain</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1823"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.elevationLoss` appears to be unused"
+ errorLine1=" <string name="elevationLoss">Elevation loss</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1824"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.maxAltitude` appears to be unused"
+ errorLine1=" <string name="maxAltitude">Maximum</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1825"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.minAltitude` appears to be unused"
+ errorLine1=" <string name="minAltitude">Minimum</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1826"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.averageAltitude` appears to be unused"
+ errorLine1=" <string name="averageAltitude">Average</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1827"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.activeSeconds` appears to be unused"
+ errorLine1=" <string name="activeSeconds">Active</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1829"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.maxSpeed` appears to be unused"
+ errorLine1=" <string name="maxSpeed">Maximum</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1831"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.minSpeed` appears to be unused"
+ errorLine1=" <string name="minSpeed">Minimum</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1832"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.minPace` appears to be unused"
+ errorLine1=" <string name="minPace">Slowest Pace</string>"
+ errorLine2=" ~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1833"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.maxPace` appears to be unused"
+ errorLine1=" <string name="maxPace">Fastest Pace</string>"
+ errorLine2=" ~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1834"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.totalStride` appears to be unused"
+ errorLine1=" <string name="totalStride">Total stride</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1835"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.averageHR` appears to be unused"
+ errorLine1=" <string name="averageHR">Heartrate</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1836"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.maxHR` appears to be unused"
+ errorLine1=" <string name="maxHR">Max Heartrate</string>"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1837"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.minHR` appears to be unused"
+ errorLine1=" <string name="minHR">Min Heartrate</string>"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1838"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.averageKMPaceSeconds` appears to be unused"
+ errorLine1=" <string name="averageKMPaceSeconds">Pace</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1839"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.HeartRateZones` appears to be unused"
+ errorLine1=" <string name="HeartRateZones">Heart Rate Zones</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1840"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.hrZoneNa` appears to be unused"
+ errorLine1=" <string name="hrZoneNa">N/A</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1841"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.hrZoneWarmUp` appears to be unused"
+ errorLine1=" <string name="hrZoneWarmUp">Warm-Up</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1842"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.hrZoneFatBurn` appears to be unused"
+ errorLine1=" <string name="hrZoneFatBurn">Fat Burn</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1843"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.hrZoneAerobic` appears to be unused"
+ errorLine1=" <string name="hrZoneAerobic">Aerobic</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1844"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.hrZoneAnaerobic` appears to be unused"
+ errorLine1=" <string name="hrZoneAnaerobic">Anaerobic</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1845"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.hrZoneExtreme` appears to be unused"
+ errorLine1=" <string name="hrZoneExtreme">Extreme</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1846"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.TrainingEffect` appears to be unused"
+ errorLine1=" <string name="TrainingEffect">Training Effect</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1847"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.aerobicTrainingEffect` appears to be unused"
+ errorLine1=" <string name="aerobicTrainingEffect">Aerobic Effect</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1848"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.anaerobicTrainingEffect` appears to be unused"
+ errorLine1=" <string name="anaerobicTrainingEffect">Anaerobic Effect</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1849"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.currentWorkoutLoad` appears to be unused"
+ errorLine1=" <string name="currentWorkoutLoad">Workout Load</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1850"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.maximumOxygenUptake` appears to be unused"
+ errorLine1=" <string name="maximumOxygenUptake">Maximum Oxygen Uptake</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1851"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.averageStride` appears to be unused"
+ errorLine1=" <string name="averageStride">Average Stride</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1852"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.maxStride` appears to be unused"
+ errorLine1=" <string name="maxStride">Max Stride</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1853"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.minStride` appears to be unused"
+ errorLine1=" <string name="minStride">Min Stride</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1854"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.averageCadence` appears to be unused"
+ errorLine1=" <string name="averageCadence">Average Cadence</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1855"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.maxCadence` appears to be unused"
+ errorLine1=" <string name="maxCadence">Max Cadence</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1856"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.minCadence` appears to be unused"
+ errorLine1=" <string name="minCadence">Min Cadence</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1857"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.averageStrokeDistance` appears to be unused"
+ errorLine1=" <string name="averageStrokeDistance">Average Stroke Distance</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1858"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.averageStrokesPerSecond` appears to be unused"
+ errorLine1=" <string name="averageStrokesPerSecond">Average Strokes</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1859"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.avgStrokeRate` appears to be unused"
+ errorLine1=" <string name="avgStrokeRate">Average Stroke Rate</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1860"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.maxStrokeRate` appears to be unused"
+ errorLine1=" <string name="maxStrokeRate">Max Stroke Rate</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1861"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.strokes` appears to be unused"
+ errorLine1=" <string name="strokes">Total Strokes</string>"
+ errorLine2=" ~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1862"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.averageLapPace` appears to be unused"
+ errorLine1=" <string name="averageLapPace">Average Lap Pace</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1863"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.swolfIndex` appears to be unused"
+ errorLine1=" <string name="swolfIndex">SWOLF</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1864"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.swimStyle` appears to be unused"
+ errorLine1=" <string name="swimStyle">Swim Style</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1865"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.laneLength` appears to be unused"
+ errorLine1=" <string name="laneLength">Lane Length</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1866"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.laps` appears to be unused"
+ errorLine1=" <string name="laps">Laps</string>"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1867"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.ascentSeconds` appears to be unused"
+ errorLine1=" <string name="ascentSeconds">Ascending</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1868"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.descentSeconds` appears to be unused"
+ errorLine1=" <string name="descentSeconds">Descending</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1869"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.flatSeconds` appears to be unused"
+ errorLine1=" <string name="flatSeconds">Flat</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1870"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.baseAltitude` appears to be unused"
+ errorLine1=" <string name="baseAltitude">Base Elevation</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1871"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.averageSpeed` appears to be unused"
+ errorLine1=" <string name="averageSpeed">Average Speed</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1872"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.meters` appears to be unused"
+ errorLine1=" <string name="meters">m</string>"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1874"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.cm` appears to be unused"
+ errorLine1=" <string name="cm">cm</string>"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1875"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.yard` appears to be unused"
+ errorLine1=" <string name="yard">yard</string>"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1876"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.ft` appears to be unused"
+ errorLine1=" <string name="ft">ft</string>"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1877"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.steps_unit` appears to be unused"
+ errorLine1=" <string name="steps_unit">steps</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1878"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.meters_second` appears to be unused"
+ errorLine1=" <string name="meters_second">m/s</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1879"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.km_h` appears to be unused"
+ errorLine1=" <string name="km_h">km/h</string>"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1880"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.mi_h` appears to be unused"
+ errorLine1=" <string name="mi_h">mi/h</string>"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1881"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.minutes_mi` appears to be unused"
+ errorLine1=" <string name="minutes_mi">min/mi</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1882"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.strokes_second` appears to be unused"
+ errorLine1=" <string name="strokes_second">str/s</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1883"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.strokes_minute` appears to be unused"
+ errorLine1=" <string name="strokes_minute">str/min</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1884"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.strokes_unit` appears to be unused"
+ errorLine1=" <string name="strokes_unit">str</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1885"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.seconds` appears to be unused"
+ errorLine1=" <string name="seconds">sec</string>"
+ errorLine2=" ~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1886"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.swolf_index` appears to be unused"
+ errorLine1=" <string name="swolf_index">swolf index</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1887"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.swim_style` appears to be unused"
+ errorLine1=" <string name="swim_style">swim style</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1888"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.laps_unit` appears to be unused"
+ errorLine1=" <string name="laps_unit">laps</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1889"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.seconds_km` appears to be unused"
+ errorLine1=" <string name="seconds_km">sec/km</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1891"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.seconds_m` appears to be unused"
+ errorLine1=" <string name="seconds_m">sec/m</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1892"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.minutes_km` appears to be unused"
+ errorLine1=" <string name="minutes_km">min/km</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1893"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.discovery_scanning_intensity` appears to be unused"
+ errorLine1=" <string name="discovery_scanning_intensity">賰孬丕賮丞 丕賱賮丨氐</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ar/strings.xml"
+ line="1894"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.spm` appears to be unused"
+ errorLine1=" <string name="spm">steps/min</string>"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1894"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.bpm` appears to be unused"
+ errorLine1=" <string name="bpm">bpm</string>"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1895"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.mi` appears to be unused"
+ errorLine1=" <string name="mi">mi</string>"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1897"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.Strokes` appears to be unused"
+ errorLine1=" <string name="Strokes">Strokes</string>"
+ errorLine2=" ~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1899"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.Swimming` appears to be unused"
+ errorLine1=" <string name="Swimming">Swimming</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1900"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.Elevation` appears to be unused"
+ errorLine1=" <string name="Elevation">Elevation</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1902"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.Speed` appears to be unused"
+ errorLine1=" <string name="Speed">Speed</string>"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1903"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_header_statistics` appears to be unused"
+ errorLine1=" <string name="pref_header_statistics">Sports Activities Statistics</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1928"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.activity_summaries_statistics` appears to be unused"
+ errorLine1=" <string name="activity_summaries_statistics">Statistics</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1939"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.breaststroke` appears to be unused"
+ errorLine1=" <string name="breaststroke">Breaststroke</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1954"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.freestyle` appears to be unused"
+ errorLine1=" <string name="freestyle">Freestyle</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1955"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.backstroke` appears to be unused"
+ errorLine1=" <string name="backstroke">Backstroke</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1956"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.medley` appears to be unused"
+ errorLine1=" <string name="medley">Medley</string>"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1957"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.fossil_hr_commute_processing` appears to be unused"
+ errorLine1=" <string name="fossil_hr_commute_processing">Processing request鈥�</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1973"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_autoheartrate` appears to be unused"
+ errorLine1=" <string name="prefs_autoheartrate">Automatic Heart Rate</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2074"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_autoheartrate_summary` appears to be unused"
+ errorLine1=" <string name="prefs_autoheartrate_summary">Periodical Heart Rate measurements during the day and also while asleep</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2075"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_autoheartrate_measurement` appears to be unused"
+ errorLine1=" <string name="prefs_autoheartrate_measurement">Automatic Heart Rate measurements</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2076"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_autoheartrate_sleep` appears to be unused"
+ errorLine1=" <string name="prefs_autoheartrate_sleep">Take measurements during sleep</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2077"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_autoheartrate_interval` appears to be unused"
+ errorLine1=" <string name="prefs_autoheartrate_interval">Frequency of measurements</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2078"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_title_touch_volume` appears to be unused"
+ errorLine1=" <string name="pref_title_touch_volume">Volume</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2143"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_title_touch_spotify_galaxy_app` appears to be unused"
+ errorLine1=" <string name="pref_title_touch_spotify_galaxy_app">Spotify (Galaxy Wearable app only)</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2147"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.sony_anc_optimizer_status_not_running` appears to be unused"
+ errorLine1=" <string name="sony_anc_optimizer_status_not_running">Not Running</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2209"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.sony_anc_optimizer_status_wearing_condition` appears to be unused"
+ errorLine1=" <string name="sony_anc_optimizer_status_wearing_condition">Measuring wearing condition鈥�</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2210"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.sony_anc_optimizer_status_atmospheric_pressure` appears to be unused"
+ errorLine1=" <string name="sony_anc_optimizer_status_atmospheric_pressure">Measuring atmospheric pressure鈥�</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2211"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.sony_anc_optimizer_status_analyzing` appears to be unused"
+ errorLine1=" <string name="sony_anc_optimizer_status_analyzing">Analyzing鈥�</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2212"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.sony_anc_optimizer_status_finished` appears to be unused"
+ errorLine1=" <string name="sony_anc_optimizer_status_finished">Finishing鈥�</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2213"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_header_sony_equalizer_bands` appears to be unused"
+ errorLine1=" <string name="pref_header_sony_equalizer_bands">Bands</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2226"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_header_sony_equalizer_preset_custom_1` appears to be unused"
+ errorLine1=" <string name="pref_header_sony_equalizer_preset_custom_1">Custom Preset 1</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2227"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_header_sony_equalizer_preset_custom_2` appears to be unused"
+ errorLine1=" <string name="pref_header_sony_equalizer_preset_custom_2">Custom Preset 2</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2228"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.sony_connect_two_devices` appears to be unused"
+ errorLine1=" <string name="sony_connect_two_devices">Connect to 2 devices simultaneously</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2250"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_activity_recognition` appears to be unused"
+ errorLine1=" <string name="prefs_activity_recognition">Activity recognition settings</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2334"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_activity_recognize_running` appears to be unused"
+ errorLine1=" <string name="pref_activity_recognize_running">recognize running</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2335"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_activity_recognize_biking` appears to be unused"
+ errorLine1=" <string name="pref_activity_recognize_biking">recognize biking</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2336"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_activity_recognize_walking` appears to be unused"
+ errorLine1=" <string name="pref_activity_recognize_walking">recognize walking</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2337"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_activity_recognize_rowing` appears to be unused"
+ errorLine1=" <string name="pref_activity_recognize_rowing">recognize rowing</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2338"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_activity_recognition_mode_none` appears to be unused"
+ errorLine1=" <string name="pref_activity_recognition_mode_none">none</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2339"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_activity_recognition_mode_ask` appears to be unused"
+ errorLine1=" <string name="pref_activity_recognition_mode_ask">ask</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2340"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.pref_activity_recognition_mode_auto` appears to be unused"
+ errorLine1=" <string name="pref_activity_recognition_mode_auto">auto</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2341"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.huawei_reparse_workout_data` appears to be unused"
+ errorLine1=" <string name="huawei_reparse_workout_data">"Reparse workout data"</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2374"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.huawei_reparse_workout_data_description` appears to be unused"
+ errorLine1=" <string name="huawei_reparse_workout_data_description">"This will only do something after certain updates"</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2375"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.new_discover_activity_description` appears to be unused"
+ errorLine1=" <string name="new_discover_activity_description">MTU 丕賱丨丕賱賷 丕賱亘丕賱睾 %1$賯賲 亘鬲賲賰賷賳 賳卮丕胤 丕賱丕賰鬲卮丕賮 丕賱噩丿賷丿貙 賵丕賱匕賷 賲賳 丕賱賲賮鬲乇囟 兀賳 賷丨賱 賲卮賰賱丕鬲 丕賰鬲卮丕賮 丕賱噩賴丕夭. 賯賲 亘鬲毓胤賷賱 賴匕丕 廿匕丕 賵丕噩賴鬲 兀賷 賲卮丕賰賱 兀孬賳丕亍 丕賱亘丨孬 毓賳 噩賴丕夭賰 兀賵 丕賱丕賯鬲乇丕賳 亘賴. 賲爻鬲賵賶 丕賱廿乇爻丕賱 賲賳禺賮囟 噩丿賸丕貙 賷乇噩賶 鬲賲賰賷賳 MTU 丕賱毓丕賱賷 賮賷 廿毓丿丕丿丕鬲 丕賱噩賴丕夭 賵賮氐賱/廿毓丕丿丞 鬲賵氐賷賱 丕賱噩賴丕夭.</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ar/strings.xml"
+ line="2389"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.new_discover_activity_title` appears to be unused"
+ errorLine1=" <string name="new_discover_activity_title">鬲賲賰賷賳 兀賳卮胤丞 丕賱丕賰鬲卮丕賮/胤胤賵噩丿賷丿丞</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ar/strings.xml"
+ line="2390"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_wena3_home_icon_name_wena_pay` appears to be unused"
+ errorLine1=" <string name="prefs_wena3_home_icon_name_wena_pay">Wena Pay</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2554"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_wena3_home_icon_name_qrio` appears to be unused"
+ errorLine1=" <string name="prefs_wena3_home_icon_name_qrio">Qrio</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2555"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_wena3_home_icon_name_riiiver` appears to be unused"
+ errorLine1=" <string name="prefs_wena3_home_icon_name_riiiver">Riiiver</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2561"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_wena3_button_action_name_qrio_unlock` appears to be unused"
+ errorLine1=" <string name="prefs_wena3_button_action_name_qrio_unlock">Qrio Unlock</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2574"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.prefs_wena3_button_action_name_qrio_lock` appears to be unused"
+ errorLine1=" <string name="prefs_wena3_button_action_name_qrio_lock">Qrio Lock</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2575"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.serial_number` appears to be unused"
+ errorLine1=" <string name="serial_number">Serial Number</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2623"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.menuitem_stats` appears to be unused"
+ errorLine1=" <string name="menuitem_stats">Stats</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2624"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.menuitem_running` appears to be unused"
+ errorLine1=" <string name="menuitem_running">Running</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2625"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.menuitem_alerts` appears to be unused"
+ errorLine1=" <string name="menuitem_alerts">Alerts</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2626"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.menuitem_focus` appears to be unused"
+ errorLine1=" <string name="menuitem_focus">Focus</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2627"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.widget_move_up` appears to be unused"
+ errorLine1=" <string name="widget_move_up">Move up</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2658"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.widget_move_down` appears to be unused"
+ errorLine1=" <string name="widget_move_down">Move down</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="2659"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.p_clock` appears to be unused"
+ errorLine1=" <item name="p_clock" type="string">clock</item>"
+ errorLine2=" ~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/values.xml"
+ line="16"
+ column="11"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.p_menuitem_countdown` appears to be unused"
+ errorLine1=" <item name="p_menuitem_countdown" type="string">countdown</item>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/values.xml"
+ line="59"
+ column="11"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.p_menuitem_personal_activity_intelligence` appears to be unused"
+ errorLine1=" <item name="p_menuitem_personal_activity_intelligence" type="string">personal_activity_intelligence</item>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/values.xml"
+ line="60"
+ column="11"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.p_menuitem_workout_history` appears to be unused"
+ errorLine1=" <item name="p_menuitem_workout_history" type="string">workout_history</item>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/values.xml"
+ line="61"
+ column="11"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.p_menuitem_female_health` appears to be unused"
+ errorLine1=" <item name="p_menuitem_female_health" type="string">female_health</item>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/values.xml"
+ line="62"
+ column="11"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.p_menuitem_workout_status` appears to be unused"
+ errorLine1=" <item name="p_menuitem_workout_status" type="string">workout_status</item>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/values.xml"
+ line="63"
+ column="11"/>
+ </issue>
+
+ <issue
+ id="UnusedResources"
+ message="The resource `R.string.p_sunset` appears to be unused"
+ errorLine1=" <item name="p_sunset" type="string">sunset</item>"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/values.xml"
+ line="74"
+ column="11"/>
+ </issue>
+
+ <issue
+ id="UselessLeaf"
+ message="This `LinearLayout` view is unnecessary (no children, no `background`, no `id`, no `style`)"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list.xml"
+ line="13"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `ScrollView` layout or its `RelativeLayout` parent is possibly unnecessary"
+ errorLine1=" <ScrollView"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_alarm_details.xml"
+ line="8"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `RelativeLayout` parent is unnecessary"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_binary_sensor_data.xml"
+ line="8"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `ScrollView` layout or its `RelativeLayout` parent is possibly unnecessary"
+ errorLine1=" <ScrollView"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_data_management.xml"
+ line="8"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `ScrollView` layout or its `RelativeLayout` parent is possibly unnecessary"
+ errorLine1=" <ScrollView"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="8"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `LinearLayout` parent is unnecessary"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_discovery.xml"
+ line="11"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `RelativeLayout` layout or its `RelativeLayout` parent is possibly unnecessary"
+ errorLine1=" <RelativeLayout"
+ errorLine2=" ~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="83"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `RelativeLayout` parent is unnecessary"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_dashboard_item.xml"
+ line="10"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `RelativeLayout` parent is possibly unnecessary; transfer the `background` attribute to the other view"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="11"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `LinearLayout` parent is unnecessary; transfer the `background` attribute to the other view"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="22"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `ScrollView` layout or its `RelativeLayout` parent is possibly unnecessary"
+ errorLine1=" <ScrollView"
+ errorLine2=" ~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_open_fw_app_installer.xml"
+ line="8"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `RelativeLayout` parent is unnecessary"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_summary_dashboard_item.xml"
+ line="11"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `LinearLayout` parent is possibly unnecessary"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_summary_dashboard_item.xml"
+ line="26"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `LinearLayout` parent is unnecessary"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="500"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `RelativeLayout` parent is unnecessary"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_gps.xml"
+ line="7"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `RelativeLayout` parent is unnecessary"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout-land/fragment_stresschart.xml"
+ line="8"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `RelativeLayout` parent is unnecessary"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_stresschart.xml"
+ line="8"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `LinearLayout` parent is unnecessary"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout-land/fragment_stresschart.xml"
+ line="13"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="UselessParent"
+ message="This `LinearLayout` layout or its `LinearLayout` parent is unnecessary"
+ errorLine1=" <LinearLayout"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/heart_rate_dialog.xml"
+ line="59"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="RedundantNamespace"
+ message="This namespace declaration is redundant"
+ errorLine1=" xmlns:android="http://schemas.android.com/apk/res/android""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="144"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="RedundantNamespace"
+ message="This namespace declaration is redundant"
+ errorLine1=" xmlns:app="http://schemas.android.com/apk/res-auto""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="145"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="RedundantNamespace"
+ message="This namespace declaration is redundant"
+ errorLine1=" <PreferenceCategory xmlns:app="http://schemas.android.com/apk/res-auto""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/xml/notifications_preferences.xml"
+ line="3"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="TypographyDashes"
+ message="Replace "-" with an "en dash" character (鈥�, &#8211;) ?"
+ errorLine1=" <string name="pref_summary_pebble_mtu_limit">N岷縰 Pebble 2/Pebble LE c峄 b岷 kh么ng ho岷 膽峄檔g nh瓢 mong 膽峄, h茫y th峄� c脿i 膽岷穞 n脿y 膽峄� gi峄沬 h岷 MTU (ph岷 vi h峄 l峄� 20-512)</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-vi/strings.xml"
+ line="350"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="TypographyDashes"
+ message="Replace "-" with an "en dash" character (鈥�, &#8211;) ?"
+ errorLine1=" <string name="pref_summary_pebble_mtu_limit">携泻褖芯 胁邪褕 Pebble 2/LE 薪械 锌褉邪褑褞褦, 褋锌褉芯斜褍泄褌械 褑械 薪邪谢邪褕褌褍胁邪薪薪褟, 褖芯斜 芯斜屑械卸懈褌懈 MTU (胁 写褨邪锌邪蟹芯薪褨 20-512)</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-uk/strings.xml"
+ line="476"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="TypographyDashes"
+ message="Replace "-" with an "en dash" character (鈥�, &#8211;) ?"
+ errorLine1=" <string name="pref_summary_pebble_mtu_limit">廿匕丕 賱賲 賷毓賲賱 Pebble 2 / Pebble LE 賰賲丕 賴賵 賲鬲賵賯毓 貙 賮噩乇賾亘 賴匕丕 丕賱廿毓丿丕丿 賱鬲賯賷賷丿 MTU (丕賱賳胤丕賯 丕賱氐丕賱丨 20-512)</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ar/strings.xml"
+ line="569"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="TypographyDashes"
+ message="Replace "-" with an "en dash" character (鈥�, &#8211;) ?"
+ errorLine1=" <string name="pref_summary_pebble_mtu_limit">袗泻芯 谢懈 袙邪褕 袩械斜谢 2/袩械斜谢 袥袝 薪械 褉邪写懈 芯褔械泻懈胁邪薪芯, 锌芯泻褍褕邪褬褌械 芯胁褍 锌芯褋褌邪胁泻褍 褉邪写懈 芯谐褉邪薪懈褔邪胁邪褮邪 袦孝校-邪 (懈褋锌褉邪胁邪薪 芯锌褋械谐 褬械 20-512)</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-sr/strings.xml"
+ line="1686"
+ column="50"/>
+ </issue>
+
+ <issue
+ id="TypographyDashes"
+ message="Replace "-" with an "en dash" character (鈥�, &#8211;) ?"
+ errorLine1=" <string name="femometer_measurement_mode_normal">袧芯褉屑邪谢褜薪褘泄 (60-90 褋械泻.)</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ru/strings.xml"
+ line="2499"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="pref_title_whenscreenon">...褌邪泻卸械 锌褉懈 胁泻谢褞褔械薪薪芯屑 褝泻褉邪薪械</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ru/strings.xml"
+ line="75"
+ column="44"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">The Firmware/Watchface/App/File Installer allows you to upload/install supported files (firmware, watchfaces, applications, GPS, resources, fonts...) to the device. See some more information in the wiki: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="181"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">賷爻賲丨 賱賰 亘乇賳丕賲噩 Firmware / Watchface / App / File Installer 亘鬲丨賲賷賱 / 鬲孬亘賷鬲 丕賱賲賱賮丕鬲 丕賱賲丿毓賵賲丞 (丕賱亘乇丕賲噩 丕賱孬丕亘鬲丞 貙 賵賵丕噩賴丕鬲 丕賱賲乇丕賯亘丞 貙 賵丕賱鬲胤亘賷賯丕鬲 貙 賵賳馗丕賲 鬲丨丿賷丿 丕賱賲賵丕賯毓 丕賱毓丕賱賲賷 (GPS) 貙 賵丕賱賲賵丕乇丿 貙 賵丕賱禺胤賵胤 ...) 毓賱賶 丕賱噩賴丕夭. 丕胤賱毓 毓賱賶 賲夭賷丿 賲賳 丕賱賲毓賱賵賲丕鬲 賮賷 丕賱賵賷賰賷: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ar/strings.xml"
+ line="442"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="pref_summary_banglejs_webview_url">廿匕丕 賰賳鬲 鬲乇賷丿 兀丿丕丞 鬲丨賲賷賱 鬲胤亘賷賯丕鬲 賲禺氐氐丞 貙 賮囟毓 毓賳賵丕賳 URL https://.../android.html 賴賳丕. 賵廿賱丕 丕鬲乇賰賴 賮丕乇睾丕 賱賭 https://banglejs.com/apps</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ar/strings.xml"
+ line="456"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="about_additional_contributions">卮賰乇丕賸 噩夭賷賱丕賸 賱噩賲賷毓 丕賱賲爻丕賴賲賷賳 睾賷乇 丕賱賲丿乇噩賷賳 賮賷 丕賱賯丕卅賲丞 賱賱賲爻丕賴賲丞 賮賷 丕賱鬲毓賱賷賲丕鬲 丕賱亘乇賲噩賷丞 賵丕賱鬲乇噩賲丕鬲 賵丕賱丿毓賲 賵丕賱兀賮賰丕乇 賵丕賱鬲丨賮賷夭 賵鬲賯丕乇賷乇 丕賱兀禺胤丕亍 賵丕賱賲丕賱 ... 鉁�</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ar/strings.xml"
+ line="914"
+ column="51"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">Pemasang Firmware/Muka Jam/Aplikasi/Berkas memungkinkan Anda untuk mengunggah/memasang berkas yang didukung (perangkat tegar, muka jam, GPS, sumber daya, fon...) ke perangkat. Lihat beberapa informasi lebih lanjut di wiki: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-id/strings.xml"
+ line="1118"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">袩褉芯谐褉邪屑屑邪 褍褋褌邪薪芯胁泻懈 袩褉芯褕懈胁泻懈/效邪褋芯胁芯谐芯 懈薪褌械褉褎械泄褋邪/袩褉懈谢芯卸械薪懈褟/肖邪泄谢邪 锌芯蟹胁芯谢褟械褌 蟹邪谐褉褍卸邪褌褜/褍褋褌邪薪邪胁谢懈胁邪褌褜 锌芯写写械褉卸懈胁邪械屑褘械 褎邪泄谢褘 (锌褉芯褕懈胁泻褍, 褔邪褋芯胁褘械 懈薪褌械褉褎械泄褋褘, 锌褉懈谢芯卸械薪懈褟, GPS, 褉械褋褍褉褋褘, 褕褉懈褎褌褘...) 薪邪 褍褋褌褉芯泄褋褌胁芯. 袛芯锌芯谢薪懈褌械谢褜薪褍褞 懈薪褎芯褉屑邪褑懈褞 屑芯卸薪芯 薪邪泄褌懈 胁 胁懈泻懈: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-ru/strings.xml"
+ line="1494"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="pref_summary_banglejs_webview_url">Se vuoi usare un app loader personalizzato, inserisci l\'URL di https://.../android.html. Altrimenti lascia vuoto per https://banglejs.com/apps</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-it/strings.xml"
+ line="1517"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">L\'installer di Firmware/Quadrante/App/File consente di caricare/installare i file supportati (firmware, quadranti, applicazioni, GPS, risorse, font...) sul dispositivo. Per ulteriori informazioni, consultare la wiki: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-it/strings.xml"
+ line="1578"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">Instalator oprogramowania uk艂adowego/tarcz/aplikacji/plik贸w pozwala na wgranie/instalacj臋 obs艂ugiwanych plik贸w (oprogramowanie uk艂adowe, tarcze, aplikacje, GPS, zasoby, czcionki...) do urz膮dzenia. Zobacz wi臋cej informacji w wiki: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-pl/strings.xml"
+ line="1688"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">El Instalador de Firmware/Watchface/App/Archivos le permite cargar/instalar archivos compatibles (firmware, watchfaces, aplicaciones, GPS, recursos, fuentes...) en el dispositivo. M谩s informaci贸n en la wiki: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-es/strings.xml"
+ line="1717"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="pref_summary_banglejs_webview_url">Je艣li chcesz mie膰 niestandardowy program 艂aduj膮cy aplikacje, umie艣膰 sw贸j adres URL https://.../android.html tutaj. W przeciwnym razie pozostaw puste miejsce dla https://banglejs.com/apps</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-pl/strings.xml"
+ line="1717"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="pref_summary_banglejs_webview_url">Pokud chcete vlastn铆 zavad臎膷 aplikac铆, vlo啪te adresu ve form谩tu URL https://.../android.html. V opa膷n茅m p艡铆pad臎 zanechte pr谩zdn茅 pro https://banglejs.com/apps</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-cs/strings.xml"
+ line="1721"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="pref_summary_banglejs_webview_url">Si quiere un cargador de aplicaciones personalizado ponga aqu铆 su URL https://.../android.html. De lo contrario, deje en blanco https://banglejs.com/apps</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-es/strings.xml"
+ line="1723"
+ column="54"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">L\'installateur de firmware/fa莽ade de montre/application/fichier vous permet d\'envoyer/installer des fichiers compatibles (firmwares, fa莽ades de montre, applications, GPS, ressources, polices,...) dans l\'appareil. Pour plus d\'information se reporter au wiki鈥�: https鈥�://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-fr/strings.xml"
+ line="1770"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">Met de Firmware-/Wijzerplaat-/App-/Bestand-installer kunt u ondersteunde bestanden (firmware, wijzerplaten, toepassingen, GPS, bronnen, lettertypen...) naar het apparaat uploaden/installeren. Kijk voor meer informatie op de wiki: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-nl/strings.xml"
+ line="1770"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">袙褋褌邪薪芯胁谢褞胁邪褔 袦褨泻褉芯锌褉芯谐褉邪屑/笑懈褎械褉斜谢邪褌褨胁/袟邪褋褌芯褋褍薪泻褨胁/肖邪泄谢褨胁 写邪褦 蟹屑芯谐褍 蟹邪胁邪薪褌邪卸懈褌懈/褍褋褌邪薪芯胁懈褌懈 锌褨写褌褉懈屑褍胁邪薪褨 褎邪泄谢懈 (屑褨泻褉芯锌褉芯谐褉邪屑懈, 褑懈褎械褉斜谢邪褌懈, 蟹邪褋褌芯褋褍薪泻懈, GPS, 褉械褋褍褉褋懈, 褕褉懈褎褌懈...) 薪邪 锌褉懈褋褌褉褨泄. 袘褨谢褜褕械 褨薪褎芯褉屑邪褑褨褩 写懈胁褨褌褜褋褟 褍 胁褨泻褨: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-uk/strings.xml"
+ line="1772"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">Instal谩tor Firmware/Watchface/App/Soubor暖 umo啪艌uje nahr谩t/instalovat do za艡铆zen铆 podporovan茅 soubory (firmware, watchfaces, aplikace, GPS, zdroje, fonty...). Dal拧铆 informace naleznete ve wiki: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-cs/strings.xml"
+ line="1773"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">Mit dem Firmware/Watchface/App/File Installer kannst du unterst眉tzte Dateien (Firmware, Zifferbl盲tter, Anwendungen, GPS, Ressourcen, Schriftarten...) auf das Ger盲t hochladen/installieren. Weitere Informationen findest du im Wiki: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-de/strings.xml"
+ line="1778"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="TypographyEllipsis"
+ message="Replace "..." with ellipsis character (鈥�, &#8230;) ?"
+ errorLine1=" <string name="open_fw_installer_info_text">脺r眉n Yaz谋l谋m谋/Saat Aray眉z眉/Uygulama/Dosya Kurucusu, desteklenen dosyalar谋 (眉r眉n yaz谋l谋m谋, saat aray眉zleri, uygulamalar, GPS, kaynaklar, yaz谋 tipleri...) ayg谋ta y眉klemenizi/kurman谋z谋 sa臒lar. Wiki sayfas谋nda daha fazla bilgi edinin: https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Firmware-Update</string>"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/values-tr/strings.xml"
+ line="1779"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="IconLocation"
+ message="Found bitmap drawable `res/drawable/widget_preview.png` in densityless folder">
+ <location
+ file="src/main/res/drawable/widget_preview.png"/>
+ </issue>
+
+ <issue
+ id="AppLinkUrlError"
+ message="Missing URL"
+ errorLine1=" <intent-filter>"
+ errorLine2=" ^">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="378"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="AppLinkUrlError"
+ message="Missing URL"
+ errorLine1=" <intent-filter>"
+ errorLine2=" ^">
+ <location
+ file="src/main/AndroidManifest.xml"
+ line="387"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="54"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="63"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="72"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="81"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="89"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="98"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_data_management.xml"
+ line="50"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_data_management.xml"
+ line="58"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_gpx_receiver.xml"
+ line="54"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_gpx_receiver.xml"
+ line="61"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="93"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="100"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_vesc_control.xml"
+ line="49"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_vesc_control.xml"
+ line="56"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/dialog_frequency_picker.xml"
+ line="46"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/dialog_frequency_picker.xml"
+ line="54"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/dialog_frequency_picker.xml"
+ line="61"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_activity_widget_settings.xml"
+ line="55"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ButtonStyle"
+ message="Buttons in button bars should be borderless; use `style="?android:attr/buttonBarButtonStyle"` (and `?android:attr/buttonBarStyle` on the parent)"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_activity_widget_settings.xml"
+ line="62"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="TextFields"
+ message="This text field does not specify an `inputType`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_contact_details.xml"
+ line="38"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="TextFields"
+ message="This text field does not specify an `inputType`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_contact_details.xml"
+ line="76"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="TextFields"
+ message="This text field does not specify an `inputType`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_reminder_details.xml"
+ line="155"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="TextFields"
+ message="This text field does not specify an `inputType`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_world_clock_details.xml"
+ line="103"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="TextFields"
+ message="This text field does not specify an `inputType`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_world_clock_details.xml"
+ line="141"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="TextFields"
+ message="This text field does not specify an `inputType`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_activity_widget_settings.xml"
+ line="24"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="TextFields"
+ message="This text field does not specify an `inputType`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="14"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="TextFields"
+ message="This text field does not specify an `inputType`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="24"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="AlwaysShowAction"
+ message="Prefer "`ifRoom`" instead of "`always`""
+ errorLine1=" app:showAsAction="always""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/menu/activity_hybridhr_watchface_designer_actionbar.xml"
+ line="8"
+ column="9"/>
+ <location
+ file="src/main/res/menu/activity_hybridhr_watchface_designer_actionbar.xml"
+ line="15"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="AlwaysShowAction"
+ message="Prefer "`ifRoom`" instead of "`always`""
+ errorLine1=" app:showAsAction="always" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/menu/activity_list_menu.xml"
+ line="10"
+ column="9"/>
+ <location
+ file="src/main/res/menu/activity_list_menu.xml"
+ line="17"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="AlwaysShowAction"
+ message="Prefer "`ifRoom`" instead of "`always`""
+ errorLine1=" app:showAsAction="always">"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/menu/activity_take_screenshot_menu.xml"
+ line="10"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_alarm_details.xml"
+ line="46"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_alarm_details.xml"
+ line="55"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_contact_details.xml"
+ line="38"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_contact_details.xml"
+ line="76"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="36"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_notification_filter.xml"
+ line="54"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout-land/activity_notification_filter.xml"
+ line="58"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_settings.xml"
+ line="43"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_reminder_details.xml"
+ line="155"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_vesc_control.xml"
+ line="19"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_vesc_control.xml"
+ line="37"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_world_clock_details.xml"
+ line="103"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_world_clock_details.xml"
+ line="141"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_activity_widget_settings.xml"
+ line="24"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="14"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="Autofill"
+ message="Missing `autofillHints` attribute"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="24"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ButtonCase"
+ message="The standard Android way to capitalize cancel is "Cancel" (tip: use `@android:string/cancel` instead)"
+ errorLine1=" <string name="fossil_hr_new_action_cancel">cancel</string>"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/values/strings.xml"
+ line="1993"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="Custom view ``ImageView`` has `setOnTouchListener` called on it but does not override `performClick`"
+ errorLine1=" holder.mDragHandle.setOnTouchListener(new View.OnTouchListener() {"
+ errorLine2=" ^">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CommuteActionsListAdapter.java"
+ line="59"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="`onTouch` should call `View#performClick` when a click is detected"
+ errorLine1=" public boolean onTouch(View view, MotionEvent motionEvent) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CommuteActionsListAdapter.java"
+ line="61"
+ column="28"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="`DragSortController#onTouch` should call `View#performClick` when a click is detected"
+ errorLine1=" public boolean onTouch(View v, MotionEvent ev) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/com/mobeta/android/dslv/DragSortController.java"
+ line="244"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="Custom view `DragSortListView` overrides `onTouchEvent` but not `performClick`"
+ errorLine1=" public boolean onTouchEvent(MotionEvent ev) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/com/mobeta/android/dslv/DragSortListView.java"
+ line="1583"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="Custom view ``ImageView`` has `setOnTouchListener` called on it but does not override `performClick`"
+ errorLine1=" holder.mDragHandle.setOnTouchListener(new View.OnTouchListener() {"
+ errorLine2=" ^">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAppAdapter.java"
+ line="140"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="`onTouch` should call `View#performClick` when a click is detected"
+ errorLine1=" public boolean onTouch(View view, MotionEvent motionEvent) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAppAdapter.java"
+ line="142"
+ column="28"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="Custom view ``ImageView`` has `setOnTouchListener` called on it but does not override `performClick`"
+ errorLine1=" overlay.setOnTouchListener(this);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ImageEditActivity.java"
+ line="73"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="`ImageEditActivity#onTouch` should call `View#performClick` when a click is detected"
+ errorLine1=" public boolean onTouch(View view, MotionEvent motionEvent) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ImageEditActivity.java"
+ line="202"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="Custom view `NonSwipeableViewPager` overrides `onTouchEvent` but not `performClick`"
+ errorLine1=" public boolean onTouchEvent(final MotionEvent ev) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/NonSwipeableViewPager.java"
+ line="42"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="Custom view `RotaryControl` overrides `onTouchEvent` but not `performClick`"
+ errorLine1=" public boolean onTouchEvent(MotionEvent ev) {"
+ errorLine2=" ~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/RotaryControl.java"
+ line="169"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="`SwipeEvents#onTouch` should call `View#performClick` when a click is detected"
+ errorLine1=" public boolean onTouch(View v, MotionEvent event) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/util/SwipeEvents.java"
+ line="39"
+ column="20"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="Custom view ``ImageView`` has `setOnTouchListener` called on it but does not override `performClick`"
+ errorLine1=" pickerView.setOnTouchListener(new View.OnTouchListener() {"
+ errorLine2=" ^">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/TimePicker.java"
+ line="161"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="`onTouch` should call `View#performClick` when a click is detected"
+ errorLine1=" public boolean onTouch(View view, MotionEvent motionEvent) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/TimePicker.java"
+ line="163"
+ column="28"/>
+ </issue>
+
+ <issue
+ id="ClickableViewAccessibility"
+ message="`onTouch` should call `View#performClick` when a click is detected"
+ errorLine1=" public boolean onTouch(View v, MotionEvent event) {"
+ errorLine2=" ~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vesc/VescControlActivity.java"
+ line="197"
+ column="28"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_commute_actions.xml"
+ line="27"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_configure_contacts.xml"
+ line="18"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_configure_reminders.xml"
+ line="18"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_configure_world_clocks.xml"
+ line="18"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_contact_details.xml"
+ line="87"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_controlcenterv2_app_bar_main.xml"
+ line="25"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_controlcenterv2_content_main.xml"
+ line="13"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_fragmentappmanager.xml"
+ line="29"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_fragmentappmanager.xml"
+ line="39"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="50"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="56"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="63"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="71"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="90"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="105"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list.xml"
+ line="34"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_total_dashboard.xml"
+ line="23"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_total_dashboard.xml"
+ line="71"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_image_edit.xml"
+ line="15"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_image_edit.xml"
+ line="21"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_reminder_details.xml"
+ line="166"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageButton"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_supercars_control.xml"
+ line="70"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageButton"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_supercars_control.xml"
+ line="86"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageButton"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_supercars_control.xml"
+ line="108"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageButton"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_supercars_control.xml"
+ line="124"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_widget_screen_details.xml"
+ line="245"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_widget_screens_list.xml"
+ line="18"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_world_clock_details.xml"
+ line="152"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="122"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="152"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="182"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="236"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/device_itemv2.xml"
+ line="453"
+ column="22"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fossil_hr_row_commute_action.xml"
+ line="9"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fossil_hr_row_installed_app.xml"
+ line="15"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_gps.xml"
+ line="21"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <com.google.android.material.floatingactionbutton.FloatingActionButton"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_steps_list.xml"
+ line="29"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_weeksteps_chart.xml"
+ line="32"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout-land/fragment_weeksteps_chart.xml"
+ line="46"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_blacklist.xml"
+ line="34"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_blacklist.xml"
+ line="73"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_specific_notification_app_list.xml"
+ line="10"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_specific_notification_app_list.xml"
+ line="49"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_appmanager_watchapp.xml"
+ line="20"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/nav_header_main.xml"
+ line="15"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_app_view.xml"
+ line="8"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_package_settings_item.xml"
+ line="8"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_package_settings_item.xml"
+ line="22"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/spinner_with_image_layout.xml"
+ line="7"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageView"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streaks_dashboard.xml"
+ line="55"
+ column="18"/>
+ </issue>
+
+ <issue
+ id="ContentDescription"
+ message="Missing `contentDescription` attribute on image"
+ errorLine1=" <ImageButton"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streaks_dashboard.xml"
+ line="70"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="LabelFor"
+ message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_contact_details.xml"
+ line="38"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="LabelFor"
+ message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_contact_details.xml"
+ line="76"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="LabelFor"
+ message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="36"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="LabelFor"
+ message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_settings.xml"
+ line="43"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="LabelFor"
+ message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_reminder_details.xml"
+ line="155"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="LabelFor"
+ message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_world_clock_details.xml"
+ line="103"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="LabelFor"
+ message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_world_clock_details.xml"
+ line="141"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="LabelFor"
+ message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_activity_widget_settings.xml"
+ line="24"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="LabelFor"
+ message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="14"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="LabelFor"
+ message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
+ errorLine1=" <EditText"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="24"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" format.format(timeOffset / 60) + ":" +"
+ errorLine2=" ^">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ConfigActivity.java"
+ line="327"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" format.format(timeOffset / 60) + ":" +"
+ errorLine2=" ^">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ConfigActivity.java"
+ line="337"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" buttonTextView.setText("Button " + (i + 1) + ": " + payload.getDescription());"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ConfigActivity.java"
+ line="447"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" buttonTextView.setText("Button " + (i + 1) + ": " + payload.getDescription());"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ConfigActivity.java"
+ line="447"
+ column="65"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="String literal in `setText` can not be translated. Use Android resources instead."
+ errorLine1=" buttonTextView.setText("Button " + (i + 1) + ": " + payload.getDescription());"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ConfigActivity.java"
+ line="447"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" buttonTextView.setText("Button " + (i + 1) + ": Unknown");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ConfigActivity.java"
+ line="449"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" buttonTextView.setText("Button " + (i + 1) + ": Unknown");"
+ errorLine2=" ~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ConfigActivity.java"
+ line="449"
+ column="65"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="String literal in `setText` can not be translated. Use Android resources instead."
+ errorLine1=" buttonTextView.setText("Button " + (i + 1) + ": Unknown");"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ConfigActivity.java"
+ line="449"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="String literal in `setText` can not be translated. Use Android resources instead."
+ errorLine1=" buttonTextView.setText("Button " + (i + 1) + ": Unknown");"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ConfigActivity.java"
+ line="449"
+ column="74"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" countView.setText("Count: " + count);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/binary_sensor/activity/DataActivity.java"
+ line="50"
+ column="31"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="String literal in `setText` can not be translated. Use Android resources instead."
+ errorLine1=" countView.setText("Count: " + count);"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/binary_sensor/activity/DataActivity.java"
+ line="50"
+ column="31"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" autoExportLocation_path.setText(getAutoExportLocationUserString() + " (" + getAutoExportLocationPreferenceString() + ")" );"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java"
+ line="165"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="String literal in `setText` can not be translated. Use Android resources instead."
+ errorLine1=" textView.setText("MAC Address: ");"
+ errorLine2=" ~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java"
+ line="562"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="String literal in `setText` can not be translated. Use Android resources instead."
+ errorLine1=" editContent.setText("Test");"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java"
+ line="846"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" batteryStatusLabels[batteryIndex].setText(device.getBatteryLevel(batteryIndex) + "%");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java"
+ line="353"
+ column="59"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" ((TextView) findViewById(widgetButtonsMapping.keyAt(i))).setText(widgetButtonsMapping.valueAt(i) + " widget");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HRConfigActivity.java"
+ line="359"
+ column="82"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="String literal in `setText` can not be translated. Use Android resources instead."
+ errorLine1=" ((TextView) findViewById(widgetButtonsMapping.keyAt(i))).setText(widgetButtonsMapping.valueAt(i) + " widget");"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HRConfigActivity.java"
+ line="359"
+ column="116"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" position + " widget: " + function.substring(7)"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HRConfigActivity.java"
+ line="377"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="String literal in `setText` can not be translated. Use Android resources instead."
+ errorLine1=" position + " widget: " + function.substring(7)"
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HRConfigActivity.java"
+ line="377"
+ column="48"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Do not concatenate text displayed with `setText`. Use resource string with placeholders."
+ errorLine1=" position + " widget: ""
+ errorLine2=" ^">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HRConfigActivity.java"
+ line="385"
+ column="41"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="String literal in `setText` can not be translated. Use Android resources instead."
+ errorLine1=" position + " widget: ""
+ errorLine2=" ~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HRConfigActivity.java"
+ line="385"
+ column="52"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Number formatting does not take into account locale settings. Consider using `String.format` instead."
+ errorLine1=" days_current.setText(Integer.toString(stepsStreaks.current.days));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java"
+ line="190"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Number formatting does not take into account locale settings. Consider using `String.format` instead."
+ errorLine1=" average_current.setText(Integer.toString(stepsStreaks.current.steps / stepsStreaks.current.days));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java"
+ line="191"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Number formatting does not take into account locale settings. Consider using `String.format` instead."
+ errorLine1=" total_current.setText(Integer.toString(stepsStreaks.current.steps));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java"
+ line="192"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Number formatting does not take into account locale settings. Consider using `String.format` instead."
+ errorLine1=" days_maximum.setText(Integer.toString(stepsStreaks.maximum.days));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java"
+ line="201"
+ column="34"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Number formatting does not take into account locale settings. Consider using `String.format` instead."
+ errorLine1=" average_maximum.setText(Integer.toString(stepsStreaks.maximum.steps / stepsStreaks.maximum.days));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java"
+ line="202"
+ column="37"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Number formatting does not take into account locale settings. Consider using `String.format` instead."
+ errorLine1=" total_maximum.setText(Integer.toString(stepsStreaks.maximum.steps));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java"
+ line="203"
+ column="35"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="Number formatting does not take into account locale settings. Consider using `String.format` instead."
+ errorLine1=" total_total.setText(Integer.toString(stepsStreaks.total.steps));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java"
+ line="233"
+ column="33"/>
+ </issue>
+
+ <issue
+ id="SetTextI18n"
+ message="String literal in `setText` can not be translated. Use Android resources instead."
+ errorLine1=" box.setText("Respect silent mode");"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/TimePicker.java"
+ line="102"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Today", should use `@string` resource"
+ errorLine1=" android:text="Today" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="43"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "<<<", should use `@string` resource"
+ errorLine1=" android:text="&lt;&lt;&lt;" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="61"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "<<", should use `@string` resource"
+ errorLine1=" android:text="&lt;&lt;" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="70"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "<", should use `@string` resource"
+ errorLine1=" android:text="&lt;" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="79"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string ">", should use `@string` resource"
+ errorLine1=" android:text=">" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="88"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string ">>", should use `@string` resource"
+ errorLine1=" android:text=">>" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="96"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string ">>>", should use `@string` resource"
+ errorLine1=" android:text=">>>" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_charts.xml"
+ line="105"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Message / Caller", should use `@string` resource"
+ errorLine1=" android:text="Message / Caller""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="33"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Test", should use `@string` resource"
+ errorLine1=" android:text="Test" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="44"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "send as SMS", should use `@string` resource"
+ errorLine1=" android:text="send as SMS" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="52"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Send", should use `@string` resource"
+ errorLine1=" android:text="Send" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="60"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "incoming call", should use `@string` resource"
+ errorLine1=" android:text="incoming call" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="68"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "outgoing call", should use `@string` resource"
+ errorLine1=" android:text="outgoing call" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="76"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "start call", should use `@string` resource"
+ errorLine1=" android:text="start call" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="84"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "end call", should use `@string` resource"
+ errorLine1=" android:text="end call" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="92"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "set time", should use `@string` resource"
+ errorLine1=" android:text="set time" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="100"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "set music info", should use `@string` resource"
+ errorLine1=" android:text="set music info" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="108"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Heart Rate Test", should use `@string` resource"
+ errorLine1=" android:text="Heart Rate Test" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="116"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "reboot", should use `@string` resource"
+ errorLine1=" android:text="reboot" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="124"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Set Activity Fetch Time", should use `@string` resource"
+ errorLine1=" android:text="Set Activity Fetch Time" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="132"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Set Weather", should use `@string` resource"
+ errorLine1=" android:text="Set Weather" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="140"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Show Cached Weather", should use `@string` resource"
+ errorLine1=" android:text="Show Cached Weather" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="148"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "factory reset", should use `@string` resource"
+ errorLine1=" android:text="factory reset" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="156"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "create test notification", should use `@string` resource"
+ errorLine1=" android:text="create test notification" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="164"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "create PebbleKit test notification", should use `@string` resource"
+ errorLine1=" android:text="create PebbleKit test notification" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="171"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Fetch device Debug Logs", should use `@string` resource"
+ errorLine1=" android:text="Fetch device Debug Logs" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="178"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Test New Functionality", should use `@string` resource"
+ errorLine1=" android:text="Test New Functionality" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="185"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Show all registered app widgets IDs", should use `@string` resource"
+ errorLine1=" android:text="Show all registered app widgets IDs" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="199"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Delete all registered app widgets IDs", should use `@string` resource"
+ errorLine1=" android:text="Delete all registered app widgets IDs" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="206"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Show app Widgets Preferences", should use `@string` resource"
+ errorLine1=" android:text="Show app Widgets Preferences" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="213"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Delete app Widgets Preferences", should use `@string` resource"
+ errorLine1=" android:text="Delete app Widgets Preferences" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="220"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Activity summary extra date range (takes long to generate when used)", should use `@string` resource"
+ errorLine1=" android:text="Activity summary extra date range (takes long to generate when used)""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="230"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Add test device manually", should use `@string` resource"
+ errorLine1=" android:text="Add test device manually" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="239"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Remove device preferences", should use `@string` resource"
+ errorLine1=" android:text="Remove device preferences""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="245"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Run Debug Function", should use `@string` resource"
+ errorLine1=" android:text="Run Debug Function""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="253"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Show Fit.App.Track. Status", should use `@string` resource"
+ errorLine1=" android:text="Show Fit.App.Track. Status""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="277"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "NewWatchface", should use `@string` resource"
+ errorLine1=" android:text="NewWatchface""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="34"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "14:30", should use `@string` resource"
+ errorLine1=" android:text="14:30" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="40"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "16:30", should use `@string` resource"
+ errorLine1=" android:text="16:30" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="51"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Running", should use `@string` resource"
+ errorLine1=" android:text="Running""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="83"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "25min", should use `@string` resource"
+ errorLine1=" android:text="25min" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="104"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "15000", should use `@string` resource"
+ errorLine1=" android:text="15000" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="149"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "101", should use `@string` resource"
+ errorLine1=" android:text="101" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="179"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "15.1km", should use `@string` resource"
+ errorLine1=" android:text="15.1km" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="209"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "122", should use `@string` resource"
+ errorLine1=" android:text="122" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="239"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "1.1.1973", should use `@string` resource"
+ errorLine1=" android:text="1.1.1973" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_list_item.xml"
+ line="269"
+ column="29"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "encrypted file", should use `@string` resource"
+ errorLine1=" android:text="encrypted file""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_file_management.xml"
+ line="26"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "generate file header", should use `@string` resource"
+ errorLine1=" android:text="generate file header""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_file_management.xml"
+ line="32"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "download file", should use `@string` resource"
+ errorLine1=" android:text="download file""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_file_management.xml"
+ line="38"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "upload file", should use `@string` resource"
+ errorLine1=" android:text="upload file""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_file_management.xml"
+ line="44"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "top button single press", should use `@string` resource"
+ errorLine1=" android:text="top button single press""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="18"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "middle button single press", should use `@string` resource"
+ errorLine1=" android:text="middle button single press""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="25"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "bottom button single press", should use `@string` resource"
+ errorLine1=" android:text="bottom button single press""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="32"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "top widget", should use `@string` resource"
+ errorLine1=" android:text="top widget""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="45"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "right widget", should use `@string` resource"
+ errorLine1=" android:text="right widget""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="52"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "bottom widget", should use `@string` resource"
+ errorLine1=" android:text="bottom widget""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="59"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "left widget", should use `@string` resource"
+ errorLine1=" android:text="left widget""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="66"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "custom widgets", should use `@string` resource"
+ errorLine1=" android:text="custom widgets" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="73"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "add widget", should use `@string` resource"
+ errorLine1=" android:text="add widget" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="85"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "delete background", should use `@string` resource"
+ errorLine1=" android:text="delete background""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="97"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "set background", should use `@string` resource"
+ errorLine1=" android:text="set background""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_hr_settings.xml"
+ line="104"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "ok", should use `@string` resource"
+ errorLine1=" android:text="ok""
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_image_edit.xml"
+ line="31"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Once", should use `@string` resource"
+ errorLine1=" android:text="Once""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_reminder_details.xml"
+ line="43"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "2019-08-17", should use `@string` resource"
+ errorLine1=" android:text="2019-08-17""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_reminder_details.xml"
+ line="82"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "16:35", should use `@string` resource"
+ errorLine1=" android:text="16:35""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_reminder_details.xml"
+ line="121"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "15000", should use `@string` resource"
+ errorLine1=" android:text="15000""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_summary_dashboard_item.xml"
+ line="100"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "30000", should use `@string` resource"
+ errorLine1=" android:text="30000""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_summary_dashboard_item.xml"
+ line="147"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "1d 20h 35m", should use `@string` resource"
+ errorLine1=" android:text="1d 20h 35m""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_summary_dashboard_item.xml"
+ line="206"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "1d 13h 30m", should use `@string` resource"
+ errorLine1=" android:text="1d 13h 30m""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_summary_dashboard_item.xml"
+ line="252"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "All Activities", should use `@string` resource"
+ errorLine1=" android:text="All Activities""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_summary_dashboard_item.xml"
+ line="311"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "15", should use `@string` resource"
+ errorLine1=" android:text="15""
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_summary_dashboard_item.xml"
+ line="358"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "January 1, 1970", should use `@string` resource"
+ errorLine1=" android:text="January 1, 1970""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_summary_dashboard_item.xml"
+ line="415"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "December 1, 2020", should use `@string` resource"
+ errorLine1=" android:text="December 1, 2020""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_summary_dashboard_item.xml"
+ line="461"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "RPM: ", should use `@string` resource"
+ errorLine1=" android:text="RPM: ""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_vesc_control.xml"
+ line="16"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Break current (A): ", should use `@string` resource"
+ errorLine1=" android:text="Break current (A): " />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_vesc_control.xml"
+ line="35"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "break", should use `@string` resource"
+ errorLine1=" android:text="break""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_vesc_control.xml"
+ line="53"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "fwd", should use `@string` resource"
+ errorLine1=" android:text="fwd""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_vesc_control.xml"
+ line="60"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Volume keys control", should use `@string` resource"
+ errorLine1=" android:text="Volume keys control""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_vesc_control.xml"
+ line="68"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Weather on your Pebble", should use `@string` resource"
+ errorLine1=" android:text="Weather on your Pebble""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_weather_notification.xml"
+ line="9"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "2x1", should use `@string` resource"
+ errorLine1=" android:text="2x1""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_widget_screen_details.xml"
+ line="43"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "TOP LEFT", should use `@string` resource"
+ errorLine1=" android:text="TOP LEFT""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_widget_screen_details.xml"
+ line="82"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "TOP RIGHT", should use `@string` resource"
+ errorLine1=" android:text="TOP RIGHT""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_widget_screen_details.xml"
+ line="121"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "CENTER", should use `@string` resource"
+ errorLine1=" android:text="CENTER""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_widget_screen_details.xml"
+ line="160"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "BOT LEFT", should use `@string` resource"
+ errorLine1=" android:text="BOT LEFT""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_widget_screen_details.xml"
+ line="199"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "BOT RIGHT", should use `@string` resource"
+ errorLine1=" android:text="BOT RIGHT""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_widget_screen_details.xml"
+ line="238"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string ".", should use `@string` resource"
+ errorLine1=" android:text=".""
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/dialog_frequency_picker.xml"
+ line="22"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "+10", should use `@string` resource"
+ errorLine1=" android:text="+10""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_pai_chart.xml"
+ line="85"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "100", should use `@string` resource"
+ errorLine1=" android:text="100""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_pai_chart.xml"
+ line="121"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "5", should use `@string` resource"
+ errorLine1=" android:text="5""
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_pai_chart.xml"
+ line="168"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "10 min", should use `@string` resource"
+ errorLine1=" android:text="10 min""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_pai_chart.xml"
+ line="180"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "5", should use `@string` resource"
+ errorLine1=" android:text="5""
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_pai_chart.xml"
+ line="216"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "10 min", should use `@string` resource"
+ errorLine1=" android:text="10 min""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_pai_chart.xml"
+ line="228"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "5", should use `@string` resource"
+ errorLine1=" android:text="5""
+ errorLine2=" ~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_pai_chart.xml"
+ line="264"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "10 min", should use `@string` resource"
+ errorLine1=" android:text="10 min""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_pai_chart.xml"
+ line="276"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "00:00", should use `@string` resource"
+ errorLine1=" android:text="00:00""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_alarm.xml"
+ line="33"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "(Smart)", should use `@string` resource"
+ errorLine1=" android:text="(Smart)""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_alarm.xml"
+ line="42"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "NOTIF", should use `@string` resource"
+ errorLine1=" android:text="NOTIF""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_blacklist.xml"
+ line="19"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "PEBBLE", should use `@string` resource"
+ errorLine1=" android:text="PEBBLE""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_blacklist.xml"
+ line="31"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Name", should use `@string` resource"
+ errorLine1=" android:text="Item Name""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_blacklist.xml"
+ line="60"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Description", should use `@string` resource"
+ errorLine1=" android:text="Item Description""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_blacklist.xml"
+ line="67"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Name", should use `@string` resource"
+ errorLine1=" android:text="Item Name""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_specific_notification_app_list.xml"
+ line="36"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Description", should use `@string` resource"
+ errorLine1=" android:text="Item Description""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_app_specific_notification_app_list.xml"
+ line="43"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Name", should use `@string` resource"
+ errorLine1=" android:text="Item Name""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_appmanager_watchapp.xml"
+ line="61"
+ column="21"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Description", should use `@string` resource"
+ errorLine1=" android:text="Item Description""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_appmanager_watchapp.xml"
+ line="72"
+ column="25"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "drag handle", should use `@string` resource"
+ errorLine1=" android:contentDescription="drag handle""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_appmanager_watchapp.xml"
+ line="95"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "TextView", should use `@string` resource"
+ errorLine1=" android:text="TextView""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_cal_blacklist.xml"
+ line="45"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Name", should use `@string` resource"
+ errorLine1=" android:text="Item Name" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_cal_blacklist.xml"
+ line="56"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "John Smith", should use `@string` resource"
+ errorLine1=" android:text="John Smith""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_contact.xml"
+ line="34"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "+1 000 000 000", should use `@string` resource"
+ errorLine1=" android:text="+1 000 000 000""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_contact.xml"
+ line="47"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Every ?, at ??:??", should use `@string` resource"
+ errorLine1=" android:text="Every ?, at ??:??""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_reminder.xml"
+ line="34"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Screen 1", should use `@string` resource"
+ errorLine1=" android:text="Screen 1""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_widget_screen.xml"
+ line="33"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "x widgets", should use `@string` resource"
+ errorLine1=" android:text="x widgets""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_widget_screen.xml"
+ line="46"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item preview", should use `@string` resource"
+ errorLine1=" android:contentDescription="Item preview" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details.xml"
+ line="14"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Name", should use `@string` resource"
+ errorLine1=" android:text="Item Name""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details.xml"
+ line="40"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Description", should use `@string` resource"
+ errorLine1=" android:text="Item Description" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details.xml"
+ line="54"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item preview", should use `@string` resource"
+ errorLine1=" android:contentDescription="Item preview" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details_horizontal.xml"
+ line="14"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Name", should use `@string` resource"
+ errorLine1=" android:text="Item Name""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details_horizontal.xml"
+ line="39"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Description", should use `@string` resource"
+ errorLine1=" android:text="Item Description" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details_horizontal.xml"
+ line="48"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item preview", should use `@string` resource"
+ errorLine1=" android:contentDescription="Item preview" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details_small.xml"
+ line="15"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Name", should use `@string` resource"
+ errorLine1=" android:text="Item Name" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details_small.xml"
+ line="40"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Item Description", should use `@string` resource"
+ errorLine1=" android:text="Item Description" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details_small.xml"
+ line="47"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "Middle-earth/Gondor", should use `@string` resource"
+ errorLine1=" android:text="Middle-earth/Gondor""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_world_clock.xml"
+ line="34"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "00:00", should use `@string` resource"
+ errorLine1=" android:text="00:00""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_world_clock.xml"
+ line="60"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "15000", should use `@string` resource"
+ errorLine1=" android:text="15000""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/layout_widget_active_steps.xml"
+ line="44"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "15", should use `@string` resource"
+ errorLine1=" android:text="15""
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/layout_widget_activities_count.xml"
+ line="44"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "15.1km", should use `@string` resource"
+ errorLine1=" android:text="15.1km""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/layout_widget_distance.xml"
+ line="44"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "122", should use `@string` resource"
+ errorLine1=" android:text="122""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/layout_widget_duration.xml"
+ line="44"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "15", should use `@string` resource"
+ errorLine1=" android:text="15""
+ errorLine2=" ~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/layout_widget_intensity_total2.xml"
+ line="44"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "15000", should use `@string` resource"
+ errorLine1=" android:text="15000""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/layout_widget_total_steps.xml"
+ line="44"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "widget name", should use `@string` resource"
+ errorLine1=" android:text="widget name" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_activity_widget_settings.xml"
+ line="22"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "elements", should use `@string` resource"
+ errorLine1=" android:text="elements" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_activity_widget_settings.xml"
+ line="32"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "add element", should use `@string` resource"
+ errorLine1=" android:text="add element" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_activity_widget_settings.xml"
+ line="44"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "delete", should use `@string` resource"
+ errorLine1=" android:text="delete""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_activity_widget_settings.xml"
+ line="59"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "save", should use `@string` resource"
+ errorLine1=" android:text="save""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_activity_widget_settings.xml"
+ line="66"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "element id", should use `@string` resource"
+ errorLine1=" android:text="element id" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="12"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "default value", should use `@string` resource"
+ errorLine1=" android:text="default value" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="22"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "type", should use `@string` resource"
+ errorLine1=" android:text="type" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="32"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "text", should use `@string` resource"
+ errorLine1=" android:text="text""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="43"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "background image", should use `@string` resource"
+ errorLine1=" android:text="background image""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="49"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "position", should use `@string` resource"
+ errorLine1=" android:text="position" />"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="57"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "upper half", should use `@string` resource"
+ errorLine1=" android:text="upper half""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="68"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "lower half", should use `@string` resource"
+ errorLine1=" android:text="lower half""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_element_popup_view.xml"
+ line="74"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "15000", should use `@string` resource"
+ errorLine1=" android:text="15000""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streak_average.xml"
+ line="34"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "9150000", should use `@string` resource"
+ errorLine1=" android:text="9150000""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streak_days.xml"
+ line="36"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="HardcodedText"
+ message="Hardcoded string "9150000", should use `@string` resource"
+ errorLine1=" android:text="9150000""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streak_total.xml"
+ line="35"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="RelativeOverlap"
+ message="`@id/button_edit_name` can overlap `@id/watchface_name` if @string/button_watchface_edit_name grows due to localized text expansion"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_hybridhr_watchface_designer.xml"
+ line="37"
+ column="10"/>
+ </issue>
+
+ <issue
+ id="RelativeOverlap"
+ message="`@id/qhybridTimeOffset` can overlap `TextView-1` if @string/qhybrid_time_shift, @id/qhybridTimeOffset grow due to localized text expansion"
+ errorLine1=" <TextView"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_settings.xml"
+ line="59"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="RelativeOverlap"
+ message="`@id/timezoneOffset` can overlap `TextView-1` if @string/qhybrid_second_timezone_offset_relative_to_utc, @id/timezoneOffset grow due to localized text expansion"
+ errorLine1=" <TextView"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_qhybrid_settings.xml"
+ line="77"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="RelativeOverlap"
+ message="`@id/withings_calibration_button_next` can overlap `@id/withings_calibration_button_ok` if @string/ok, @string/withings_bt_calibration_next grow due to localized text expansion"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_withings_calibration.xml"
+ line="37"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="RelativeOverlap"
+ message="`@id/withings_calibration_button_next` can overlap `@id/withings_calibration_button_previous` if @string/withings_bt_calibration_previous, @string/withings_bt_calibration_next grow due to localized text expansion"
+ errorLine1=" <Button"
+ errorLine2=" ~~~~~~">
+ <location
+ file="src/main/res/layout/activity_withings_calibration.xml"
+ line="37"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="RelativeOverlap"
+ message="`@id/fossil_hr_row_app_name` can overlap `ImageView-3` if @id/fossil_hr_row_app_name grows due to localized text expansion"
+ errorLine1=" <TextView"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/fossil_hr_row_installed_app.xml"
+ line="7"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="RelativeOverlap"
+ message="`@id/world_clock_item_label` can overlap `@id/world_clock_current_time` if ? grows due to localized text expansion"
+ errorLine1=" <TextView"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_world_clock.xml"
+ line="37"
+ column="14"/>
+ </issue>
+
+ <issue
+ id="RelativeOverlap"
+ message="`@id/packageName` can overlap `@id/packageClock` if @id/packageName grows due to localized text expansion"
+ errorLine1=" <TextView"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/res/layout/qhybrid_package_settings_item.xml"
+ line="15"
+ column="6"/>
+ </issue>
+
+ <issue
+ id="RtlSymmetry"
+ message="When you define `paddingLeft` you should probably also define `paddingRight` for right-to-left symmetry"
+ errorLine1=" android:paddingLeft="10dp""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="229"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="RtlSymmetry"
+ message="When you define `paddingLeft` you should probably also define `paddingRight` for right-to-left symmetry"
+ errorLine1=" android:paddingLeft="10dp""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_statschart.xml"
+ line="23"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="RtlSymmetry"
+ message="When you define `paddingStart` you should probably also define `paddingEnd` for right-to-left symmetry"
+ errorLine1=" android:paddingStart="1dp">"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/item_with_details_horizontal.xml"
+ line="6"
+ column="5"/>
+ </issue>
+
+ <issue
+ id="RtlSymmetry"
+ message="When you define `paddingStart` you should probably also define `paddingEnd` for right-to-left symmetry"
+ errorLine1=" android:paddingStart="16dp""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/list_item_checkable.xml"
+ line="15"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="RtlHardcoded"
+ message="Consider replacing `android:paddingLeft` with `android:paddingStart="10dp"` to better support right-to-left layouts"
+ errorLine1=" android:paddingLeft="10dp""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/activity_debug.xml"
+ line="229"
+ column="17"/>
+ </issue>
+
+ <issue
+ id="RtlHardcoded"
+ message="Consider replacing `android:layout_alignParentRight` with `android:layout_alignParentEnd="true"` to better support right-to-left layouts"
+ errorLine1=" android:layout_alignParentRight="true""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_sleepchart.xml"
+ line="41"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="RtlHardcoded"
+ message="Consider replacing `android:layout_alignParentRight` with `android:layout_alignParentEnd="true"` to better support right-to-left layouts"
+ errorLine1=" android:layout_alignParentRight="true""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_sleepchart.xml"
+ line="49"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="RtlHardcoded"
+ message="Consider replacing `android:layout_alignParentLeft` with `android:layout_alignParentStart="true"` to better support right-to-left layouts"
+ errorLine1=" android:layout_alignParentLeft="true""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout-land/fragment_sleepchart.xml"
+ line="54"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="RtlHardcoded"
+ message="Consider replacing `android:layout_alignParentLeft` with `android:layout_alignParentStart="true"` to better support right-to-left layouts"
+ errorLine1=" android:layout_alignParentLeft="true""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout-land/fragment_sleepchart.xml"
+ line="64"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="RtlHardcoded"
+ message="Consider replacing `android:paddingLeft` with `android:paddingStart="10dp"` to better support right-to-left layouts"
+ errorLine1=" android:paddingLeft="10dp""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/fragment_statschart.xml"
+ line="23"
+ column="9"/>
+ </issue>
+
+ <issue
+ id="RtlHardcoded"
+ message="Consider replacing `android:layout_alignParentRight` with `android:layout_alignParentEnd="true"` to better support right-to-left layouts"
+ errorLine1=" android:layout_alignParentRight="true""
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/res/layout/steps_streaks_dashboard.xml"
+ line="74"
+ column="13"/>
+ </issue>
+
+ <issue
+ id="RtlEnabled"
+ message="The project references RTL attributes, but does not explicitly enable or disable RTL support with `android:supportsRtl` in the manifest">
+ <location
+ file="src/main/AndroidManifest.xml"/>
+ </issue>
+
+</issues>
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..27cc1f4
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,79 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /home/andi/Android/Sdk/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+-dontobfuscate
+
+# Pebble BG-JS
+-keepclassmembers class * {
+ @android.webkit.JavascriptInterface <methods>;
+}
+-keepclassmembers class nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.JSInterface {
+ public *;
+}
+# Required for reflection in BangleJSDeviceSupport
+-keepclassmembers class nodomain.freeyourgadget.gadgetbridge.model.CallSpec {
+ public static *;
+}
+# Required for reflection in method GattCharacteristic.initDebugMap()
+-keepclassmembers class nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic {
+ public static *;
+}
+# Keep constructors for support classes, as they're called by reflection in DeviceSupportFactory#createServiceDeviceSupport
+-keep public class * extends nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport
+-keepclassmembers class * extends nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport {
+ public <init>(nodomain.freeyourgadget.gadgetbridge.model.DeviceType);
+ public <init>();
+}
+-keep class nodomain.freeyourgadget.gadgetbridge.womp.** { *; }
+
+# Keep coordinators, they're only referenced from DeviceType
+-keep public class * implements nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator
+
+# 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.**
+
+# To avoid any stacktrace ambiguity
+-keepattributes SourceFile,LineNumberTable
+
+# GreenDAO 2 - http://greenrobot.org/greendao/documentation/technical-faq/
+-keepclassmembers class * extends de.greenrobot.dao.AbstractDao {
+ public static java.lang.String TABLENAME;
+}
+
+-keep class **$Properties
+
+-keep class **$Properties { *; }
+
+# Keep database migration classes accessed trough reflection
+-keep class **.gadgetbridge.database.schema.* { *; }
+
+# Keep Nordic DFU library
+-keep class no.nordicsemi.android.dfu.** { *; }
+
+# Keep logback classes
+-keep class ch.qos.** { *; }
+-keep class org.slf4j.** { *; }
+
+# Keep data classes
+-keepclassmembers,allowobfuscation class * {
+ @com.google.gson.annotations.SerializedName <fields>;
+}
+# Somehow the rule above was not enough for some
+-keep class nodomain.freeyourgadget.gadgetbridge.devices.pinetime.InfiniTimeDFU* { *; }
+
+# Keep generated protobuf classes
+-keep class nodomain.freeyourgadget.gadgetbridge.proto.** { *; }
diff --git a/app/src/banglejs/AndroidManifest.xml b/app/src/banglejs/AndroidManifest.xml
new file mode 100644
index 0000000..3cb3262
--- /dev/null
+++ b/app/src/banglejs/AndroidManifest.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+</manifest>
diff --git a/app/src/banglejs/res/drawable/ic_launcher_foreground.xml b/app/src/banglejs/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..92a4552
--- /dev/null
+++ b/app/src/banglejs/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,72 @@
+<vector android:height="652dp" android:viewportHeight="651.75745"
+ android:viewportWidth="651.75745" android:width="652dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillColor="#000000"
+ android:pathData="M4.395,0L647.362,0A5.273,4.395 90,0 1,651.757 5.273L651.757,646.485A5.273,4.395 90,0 1,647.362 651.758L4.395,651.758A5.273,4.395 90,0 1,-0 646.485L-0,5.273A5.273,4.395 90,0 1,4.395 0z"
+ android:strokeColor="#00000000" android:strokeWidth="1.31811"/>
+ <group>
+ <clip-path android:pathData="M137.032,998.55L1528.284,998.55L1528.284,215.971L137.032,215.971Z"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="M488.99,417.594L484.347,417.594L484.347,402.013L488.99,402.013c0.852,-0 1.543,0.691 1.543,1.543L490.533,416.051C490.533,416.903 489.842,417.594 488.99,417.594"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m476.084,380.673 l-1.312,1.312 -7.454,-7.454 1.312,-1.312c1.032,-1.033 2.706,-1.033 3.738,-0L476.084,376.935C477.116,377.968 477.116,379.641 476.084,380.673"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m468.63,446.387 l-1.312,-1.312 7.454,-7.454 1.312,1.312c1.033,1.033 1.033,2.706 0,3.738L472.368,446.387C471.335,447.42 469.662,447.42 468.63,446.387"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m462.755,452.39l0,-3.575c0,-0 -17.441,6.416 -23.255,6.416 -5.814,-0 -23.255,-6.416 -23.255,-6.416L416.245,452.39c0,1.966 0.644,3.696 1.622,4.716 1.038,5.563 2.264,19.093 2.667,23.64 0,3.14 1.638,5.685 3.661,5.685l30.611,-0c2.022,-0 3.66,-2.545 3.66,-5.685l0,-0.001c0.403,-4.547 1.629,-18.077 2.668,-23.64C462.111,456.086 462.755,454.356 462.755,452.39"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m416.245,367.217l0,3.575c0,-0 17.441,-6.416 23.255,-6.416 5.814,-0 23.255,6.416 23.255,6.416L462.755,367.217c0,-1.966 -0.644,-3.696 -1.622,-4.716 -1.038,-5.563 -2.264,-19.093 -2.667,-23.64 0,-3.14 -1.638,-5.685 -3.66,-5.685L424.194,333.175c-2.022,-0 -3.66,2.546 -3.66,5.685l0,-0C420.131,343.408 418.905,356.938 417.866,362.5 416.889,363.521 416.245,365.25 416.245,367.217"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m406.365,376.668c-18.3,18.3 -18.3,47.97 0,66.27 18.3,18.3 47.97,18.3 66.27,-0 18.301,-18.3 18.3,-47.97 0,-66.27C454.335,358.368 424.665,358.368 406.365,376.668m62.269,62.269c-16.091,16.09 -42.178,16.09 -58.268,-0 -16.091,-16.091 -16.091,-42.178 -0.001,-58.269 16.091,-16.09 42.178,-16.09 58.269,-0 16.091,16.091 16.091,42.178 0,58.269"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="M195.008,427.752C191.39,430.98 186.965,432.594 181.734,432.594L163.118,432.594l0,-59.606l14.526,-0c4.619,-0 8.292,1.198 11.02,3.59 2.893,2.561 4.341,6.123 4.341,10.686 0,5.232 -2.143,9.072 -6.428,11.521C190.751,399.618 194.117,401.511 196.678,404.461 199.182,407.411 200.434,411.028 200.434,415.313 200.434,420.434 198.625,424.579 195.008,427.752m-8.766,-48.336c-2.283,-2.004 -5.065,-3.006 -8.348,-3.006L166.457,376.41L166.457,398.116l11.937,-0c3.227,-0 5.899,-1.002 8.015,-3.006 2.114,-2.004 3.172,-4.619 3.172,-7.848 0,-3.339 -1.114,-5.955 -3.34,-7.847m6.261,25.795c-2.838,-2.448 -6.401,-3.673 -10.686,-3.673L166.457,401.538L166.457,429.171l15.36,-0c4.229,-0 7.819,-1.28 10.769,-3.84 3.005,-2.559 4.508,-5.899 4.508,-10.018 0,-4.174 -1.531,-7.541 -4.591,-10.102"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m237.917,432.594l0,-5.927c-3.618,4.341 -8.21,6.511 -13.774,6.511 -5.009,-0 -9.212,-1.696 -12.606,-5.092 -3.451,-3.451 -5.177,-7.681 -5.177,-12.689 0,-5.009 1.698,-9.211 5.093,-12.606 3.45,-3.45 7.68,-5.176 12.689,-5.176 5.509,-0 10.101,2.17 13.774,6.512l0,-6.011L241.34,398.115L241.34,432.594ZM234.327,405.212c-2.838,-2.838 -6.233,-4.257 -10.184,-4.257 -4.007,-0 -7.403,1.391 -10.185,4.174 -2.838,2.838 -4.258,6.261 -4.258,10.268 0,3.952 1.42,7.346 4.258,10.184 2.838,2.839 6.232,4.258 10.185,4.258 4.006,-0 7.401,-1.391 10.184,-4.174 2.838,-2.839 4.258,-6.261 4.258,-10.268 0,-3.951 -1.42,-7.347 -4.258,-10.185"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m274.732,432.594l0,-20.62c0,-7.29 -3.285,-10.937 -9.85,-10.937 -3.229,-0 -5.677,0.948 -7.347,2.839 -1.725,1.893 -2.588,4.48 -2.588,7.763L254.947,432.594l-3.423,-0l0,-34.478l3.423,-0l0,4.174c1.558,-3.061 4.869,-4.592 9.935,-4.592 4.285,-0 7.568,1.225 9.85,3.673 2.282,2.45 3.423,5.873 3.423,10.268L278.155,432.594Z"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m316.89,444.114c-3.117,3.117 -7.208,4.675 -12.272,4.675 -9.406,-0 -15.026,-3.702 -16.863,-11.103l3.422,-0c0.612,2.726 2.366,4.759 5.26,6.094 2.281,1.056 5.009,1.586 8.181,1.586 4.285,-0 7.597,-1.197 9.934,-3.59 2.393,-2.393 3.59,-5.733 3.59,-10.018l0,-5.093c-3.618,4.341 -8.21,6.511 -13.774,6.511 -5.009,-0 -9.212,-1.696 -12.606,-5.092 -3.451,-3.451 -5.176,-7.681 -5.176,-12.689 0,-5.009 1.697,-9.211 5.093,-12.606 3.45,-3.45 7.681,-5.176 12.689,-5.176 5.509,-0 10.101,2.17 13.774,6.512L318.142,398.116L321.565,398.116L321.565,431.842C321.565,436.906 320.007,440.997 316.89,444.114m-2.338,-38.903c-2.838,-2.838 -6.233,-4.257 -10.184,-4.257 -4.007,-0 -7.403,1.391 -10.185,4.174 -2.838,2.838 -4.258,6.261 -4.258,10.268 0,3.952 1.42,7.346 4.258,10.185 2.838,2.838 6.232,4.257 10.185,4.257 4.006,-0 7.401,-1.391 10.184,-4.174 2.838,-2.839 4.258,-6.261 4.258,-10.268 0,-3.951 -1.42,-7.347 -4.258,-10.185"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ </group>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m329.326,367.888l3.34,-0L332.666,429.665l-3.34,-0z"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <group>
+ <clip-path android:pathData="M133.69,995.622L1524.942,995.622L1524.942,213.043L133.69,213.043Z"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m345.103,411.717c0,4.675 1.252,8.377 3.757,11.103 2.615,2.783 6.205,4.174 10.769,4.174 2.338,-0 4.452,-0.388 6.345,-1.168 2.392,-1.002 4.535,-3.033 6.428,-6.095l3.089,1.42c-2.171,4.119 -5.51,6.901 -10.018,8.348 -1.726,0.556 -3.674,0.835 -5.844,0.835 -5.176,-0 -9.434,-1.697 -12.773,-5.093 -3.396,-3.394 -5.093,-7.652 -5.093,-12.773 0,-5.176 1.67,-9.434 5.009,-12.773 3.393,-3.394 7.68,-5.093 12.856,-5.093 5.12,-0 9.35,1.586 12.689,4.759C375.712,402.534 377.411,406.653 377.411,411.717ZM368.645,400.78C366.029,398.889 363.023,397.942 359.629,397.942 352.505,397.942 347.829,401.448 345.604,408.461l27.966,-0c-0.557,-3.172 -2.199,-5.732 -4.925,-7.681"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ </group>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m435.285,411.261l1.746,-0l0,24.973l-1.746,-0z"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <group>
+ <clip-path android:pathData="M133.69,995.622L1524.942,995.622L1524.942,213.043L133.69,213.043Z"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m451.556,427.744c-1.136,0.966 -2.498,1.448 -4.089,1.448 -2.725,-0 -4.77,-1.235 -6.133,-3.705l1.491,-0.895c0.483,0.88 1.064,1.562 1.746,2.045 0.71,0.539 1.675,0.809 2.896,0.809 2.669,-0 4.003,-1.135 4.003,-3.408 0,-0.993 -0.468,-1.859 -1.405,-2.598 -0.285,-0.17 -0.689,-0.362 -1.214,-0.575 -0.526,-0.213 -1.172,-0.462 -1.938,-0.746 -1.42,-0.511 -2.442,-1.022 -3.066,-1.533 -0.909,-0.738 -1.363,-1.803 -1.363,-3.194 0,-1.193 0.454,-2.215 1.363,-3.067 0.88,-0.88 2.029,-1.32 3.449,-1.32 1.959,-0 3.62,0.681 4.983,2.044L451.003,414.158c-0.965,-0.965 -2.201,-1.448 -3.706,-1.448 -2.044,-0 -3.067,0.894 -3.067,2.683 0,0.795 0.312,1.448 0.938,1.959 0.511,0.399 1.32,0.782 2.427,1.15 0.964,0.341 1.76,0.66 2.385,0.959 0.625,0.298 1.093,0.561 1.406,0.788C452.606,421.185 453.217,422.448 453.217,424.038 453.217,425.572 452.664,426.807 451.556,427.744"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ </group>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m437.646,406.875c0,0.822 -0.666,1.488 -1.488,1.488 -0.822,-0 -1.488,-0.666 -1.488,-1.488 0,-0.822 0.667,-1.488 1.488,-1.488 0.822,-0 1.488,0.666 1.488,1.488"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <path android:fillColor="#ffffff" android:fillType="nonZero"
+ android:pathData="m438.109,402.347 l1.46,0.957 15.692,-23.932 -1.46,-0.958z"
+ android:strokeColor="#00000000" android:strokeWidth="1.84007"/>
+ <path android:fillColor="#ffffff"
+ android:pathData="M262.574,286.308m-70.193,0a70.193,70.193 45,1 1,140.385 0a70.193,70.193 45,1 1,-140.385 0"
+ android:strokeColor="#00000000" android:strokeWidth="3.30771"/>
+ <path android:fillColor="#008de4"
+ android:pathData="m256.715,379.575c-19.655,-1.374 -38.812,-8.771 -53.855,-20.794 -3.999,-3.196 -11.197,-10.361 -14.382,-14.315 -11.11,-13.796 -18.274,-30.626 -20.677,-48.575 -0.67,-5.009 -0.67,-17.926 0,-22.935 2.448,-18.29 10.142,-36.068 21.399,-49.447 15.453,-18.366 35.936,-29.741 60.348,-33.514 5.552,-0.858 18.768,-0.979 24.368,-0.223 24.914,3.363 47.223,15.725 62.535,34.653 6.785,8.387 12.88,19.485 16.21,29.513 3.67,11.051 4.921,18.804 4.921,30.485 0,15.435 -3.055,28.599 -9.795,42.207 -10.586,21.373 -28.391,37.719 -50.732,46.576 -5.482,2.173 -15.632,5.003 -17.944,5.003 -0.266,-0 18.503,-12.673 37.207,-33.536l33.599,-37.476 -6.692,-5.016c-29.624,-22.207 -39.184,-43.496 -39.485,-43.311 -0.197,0.122 -0.307,0.053 -0.246,-0.153 0.132,-0.438 -7.522,-8.072 -7.976,-7.956 -0.169,0.043 -0.307,-0.194 -0.307,-0.527 0,-1.136 -8.362,-7.586 -9.835,-7.586 -0.365,-0 -2.034,-1.399 -3.708,-3.108 -1.674,-1.709 -4.242,-4.01 -5.706,-5.112 -1.464,-1.102 -2.85,-2.16 -3.08,-2.35 -0.23,-0.19 -0.67,-0.261 -0.978,-0.159 -0.311,0.104 -0.444,-0 -0.3,-0.235 0.325,-0.526 -2.508,-3.069 -3.048,-2.735 -0.217,0.134 -0.345,0.073 -0.283,-0.135 0.179,-0.603 -3.374,-3.401 -5.651,-4.451 -2.433,-1.122 -5.685,-1.806 -7.137,-1.502 -0.715,0.15 -2.692,2.295 -6.553,7.111 -3.041,3.793 -5.759,6.906 -6.041,6.919 -0.619,0.028 -0.695,0.842 -0.078,0.842 0.239,-0 0.325,0.176 0.192,0.391 -0.133,0.215 1.128,1.551 2.802,2.969 1.674,1.418 3.003,2.622 2.954,2.674 -0.049,0.052 -1.92,-0.708 -4.157,-1.69 -2.238,-0.982 -4.652,-1.912 -5.365,-2.066 -1.772,-0.383 -4.427,0.401 -5.871,1.735 -1.519,1.403 -13.509,23.553 -13.793,25.481 -0.187,1.265 1.165,4.197 11.091,24.067 11.82,23.66 12.4,25.095 10.382,25.705 -1.32,0.399 -3.182,2.221 -3.839,3.757 -0.401,0.937 -0.564,3.222 -0.564,7.897 0,7.137 0.282,8.439 2.214,10.221 1.061,0.979 1.084,1.098 0.821,4.331 -0.256,3.153 -0.211,3.418 0.863,5.137 2.885,4.614 21.556,27.429 25.029,30.583l1.015,0.921 -4.016,-0.072c-2.209,-0.039 -4.845,-0.13 -5.859,-0.2zM270.75,379.153c0.398,-0.104 0.951,-0.097 1.229,0.015 0.277,0.112 -0.049,0.197 -0.725,0.189 -0.676,-0.008 -0.903,-0.1 -0.504,-0.204zM274.223,378.756c0.282,-0.114 0.742,-0.114 1.024,-0 0.282,0.114 0.051,0.207 -0.512,0.207 -0.563,-0 -0.793,-0.093 -0.512,-0.207zM277.525,378.338c0.296,-0.118 0.649,-0.104 0.785,0.032 0.136,0.136 -0.106,0.233 -0.538,0.215 -0.477,-0.02 -0.574,-0.117 -0.247,-0.247zM285.873,341.144c0.008,-0.676 0.1,-0.903 0.204,-0.504 0.104,0.398 0.097,0.951 -0.015,1.229 -0.112,0.277 -0.197,-0.049 -0.189,-0.724zM285.416,314.49c0.02,-0.477 0.117,-0.574 0.247,-0.247 0.118,0.296 0.104,0.649 -0.032,0.785 -0.136,0.136 -0.233,-0.106 -0.215,-0.538zM258.418,297.937c0,-0.563 0.093,-0.794 0.207,-0.512 0.114,0.282 0.114,0.742 0,1.024 -0.114,0.282 -0.207,0.051 -0.207,-0.512zM282.44,295.61c0.296,-0.118 0.649,-0.104 0.785,0.032 0.136,0.136 -0.106,0.233 -0.538,0.215 -0.477,-0.02 -0.574,-0.117 -0.247,-0.247zM272.278,287.289c-0.369,-0.239 -0.42,-0.4 -0.127,-0.403 0.268,-0.003 0.602,0.178 0.741,0.403 0.314,0.508 0.172,0.508 -0.614,-0zM276.438,285.651c0,-0.563 0.093,-0.793 0.207,-0.512 0.114,0.282 0.114,0.742 0,1.024 -0.114,0.282 -0.207,0.051 -0.207,-0.512zM257.944,280.917c0,-0.802 0.154,-1.363 0.341,-1.247 0.323,0.2 0.153,2.704 -0.183,2.704 -0.087,-0 -0.158,-0.656 -0.158,-1.458zM256.203,271.045c0.282,-0.114 0.742,-0.114 1.024,-0 0.282,0.114 0.051,0.207 -0.512,0.207 -0.563,-0 -0.793,-0.093 -0.512,-0.207zM258.353,270.752c0,-0.085 0.323,-0.408 0.717,-0.717 0.65,-0.509 0.664,-0.495 0.155,0.155 -0.535,0.682 -0.871,0.899 -0.871,0.562zM253.746,266.522c0.507,-0.098 1.336,-0.098 1.843,-0 0.507,0.098 0.092,0.177 -0.921,0.177 -1.014,-0 -1.428,-0.08 -0.921,-0.177zM262.953,256.163c0,-0.788 0.084,-1.111 0.187,-0.717 0.103,0.394 0.103,1.039 0,1.433 -0.103,0.394 -0.187,0.072 -0.187,-0.717z" android:strokeWidth="2.50943"/>
+ <path android:fillColor="#fb4f02"
+ android:pathData="m240.419,339.853c-3.201,-1.591 -3.892,-3.868 -3.699,-12.194 0.159,-6.865 0.503,-8.021 2.891,-9.724l1.337,-0.954 22.08,-0.113 22.08,-0.113 1.56,1.033c2.764,1.829 3.017,2.739 3.017,10.865 0,7.984 -0.242,8.924 -2.758,10.718l-1.337,0.954 -21.911,0.099c-19.59,0.088 -22.054,0.028 -23.259,-0.571zM274.743,335.406c1.786,-0.924 2.04,-1.763 2.04,-6.753 0,-7.503 0.241,-7.372 -13.515,-7.372 -13.776,-0 -13.521,-0.14 -13.506,7.447 0.014,7.366 -0.166,7.268 13.424,7.284 8.459,0.01 10.581,-0.101 11.558,-0.606z" android:strokeWidth="2.50943"/>
+ <path android:fillColor="#0276cb"
+ android:pathData="m262.196,375.239c-10.425,-11.902 -22.682,-27.444 -22.682,-28.763 0,-0.117 10.505,-0.213 23.344,-0.213l23.344,-0l0,-3.099c0,-2.994 0.047,-3.144 1.393,-4.413 2.002,-1.889 2.389,-3.822 2.221,-11.093 -0.161,-6.954 -0.539,-8.171 -3.026,-9.761 -1.403,-0.897 -1.428,-0.961 -1.168,-2.964 0.781,-6.011 4.082,-13.816 10.844,-25.641 3.856,-6.744 6.667,-12.421 7.177,-14.495l0.302,-1.229 -1.023,1.229c-0.563,0.676 -2.004,2.703 -3.203,4.505 -9.492,14.265 -14.606,18.102 -20.394,15.3 -2.299,-1.113 -4.322,-3.131 -5.539,-5.527l-0.953,-1.875 -3.042,-0.102c-4.508,-0.151 -7.967,-2.022 -10.248,-5.542 -0.73,-1.127 -1.349,-2.049 -1.375,-2.049 -0.026,-0 0.007,4.193 0.074,9.317l0.121,9.317 -4.322,-12.286c-2.377,-6.758 -4.489,-12.345 -4.693,-12.416 -0.204,-0.071 -1.471,-0.84 -2.816,-1.71 -4.29,-2.773 -7.666,-8.132 -6.106,-9.692 0.584,-0.584 1.239,-0.526 7.171,0.632 7.945,1.552 9.531,1.591 11.749,0.291 4.453,-2.61 4.971,-9.515 0.993,-13.222 -0.529,-0.493 -3.296,-2.024 -6.149,-3.403 -3.738,-1.807 -6.064,-3.255 -8.324,-5.183 -1.725,-1.472 -3.049,-2.763 -2.942,-2.87 0.107,-0.107 3.36,1.119 7.229,2.724 3.869,1.605 7.188,2.861 7.375,2.792 0.398,-0.148 10.644,-13.396 10.949,-14.158 0.114,-0.284 -0.044,-0.834 -0.351,-1.221 -0.307,-0.387 0.197,-0.115 1.12,0.604 0.923,0.719 1.865,1.545 2.094,1.835 0.309,0.391 -1.051,2.409 -5.264,7.81l-5.681,7.283 1.627,2.155c2.113,2.798 3.304,5.54 3.583,8.243 0.43,4.169 -2.327,8.052 -6.801,9.577 -1.975,0.673 -6.749,0.592 -8.513,-0.145 -1.847,-0.772 -2.155,-0.508 -1.087,0.934 2.054,2.772 6.245,4.934 8.477,4.374 0.706,-0.177 4.32,-4.615 12.404,-15.235 6.274,-8.241 11.54,-15.184 11.703,-15.427 0.186,-0.279 0.815,0.088 1.7,0.991l1.404,1.433 -5.94,7.986c-3.267,4.392 -8.547,11.488 -11.734,15.768 -5.704,7.661 -5.794,7.814 -5.794,9.901 0,2.45 0.802,3.786 3.354,5.592 1.942,1.374 6.004,1.957 7.148,1.026 0.37,-0.301 5.853,-7.258 12.184,-15.461 9.256,-11.991 11.511,-15.156 11.511,-16.153l0,-1.24l2.002,2.228c1.697,1.888 1.888,2.259 1.256,2.433 -1.133,0.311 -21.618,27.334 -22.121,29.18 -1.06,3.894 2.404,8.089 6.678,8.089 3.103,-0 4.128,-1.01 13.208,-13.006l8.6,-11.362l0,-2.868c0,-2.275 -0.226,-3.333 -1.096,-5.119 -0.995,-2.044 0.164,-0.971 12.604,11.673 7.535,7.659 18.245,18.59 23.8,24.292l13.475,14.878c-5.84,15.359 -12.375,30.53 -26.942,43.512 -13.179,12.611 -29.511,20.35 -48.477,24.021l-4.891,0.607c-8.074,1.002 -7.284,1.248 -11.523,-3.591zM252.005,335.55c-1.951,-1.073 -2.234,-1.932 -2.243,-6.821 -0.015,-7.588 -0.27,-7.447 13.506,-7.447 13.756,-0 13.515,-0.131 13.515,7.372 0,7.512 0.252,7.376 -13.597,7.359 -6.742,-0.008 -10.648,-0.17 -11.181,-0.463zM242.381,237.324c0,-0.225 0.195,-0.41 0.434,-0.41 0.239,-0 0.32,0.184 0.181,0.41 -0.139,0.225 -0.334,0.41 -0.434,0.41 -0.099,-0 -0.181,-0.184 -0.181,-0.41z" android:strokeWidth="2.50943"/>
+</vector>
diff --git a/app/src/banglejs/res/values/strings.xml b/app/src/banglejs/res/values/strings.xml
new file mode 100644
index 0000000..2756be0
--- /dev/null
+++ b/app/src/banglejs/res/values/strings.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string translatable="false" name="pebble_content_provider">com.getpebble.android.nopebble.bangle.provider</string>
+ <string translatable="false" name="app_name">@string/application_name_banglejs_main</string>
+ <string translatable="false" name="title_activity_controlcenter">@string/title_activity_controlcenter_banglejs_main</string>
+ <string translatable="false" name="about_activity_title">@string/about_activity_title_banglejs_main</string>
+ <string translatable="false" name="about_description">@string/about_description_banglejs_main</string>
+ <string translatable="false" name="gadgetbridge_running">@string/gadgetbridge_running_banglejs_main</string>
+</resources>
diff --git a/app/src/banglejsNightly/res/values/strings.xml b/app/src/banglejsNightly/res/values/strings.xml
new file mode 100644
index 0000000..bec569b
--- /dev/null
+++ b/app/src/banglejsNightly/res/values/strings.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- enable Pebble content provider for Bangle.js nightly, so can that it can be installed besides regular Bangle.js release -->
+ <string translatable="false" name="pebble_content_provider">com.getpebble.android.provider</string>
+ <string translatable="false" name="app_name">@string/application_name_banglejs_nightly</string>
+ <string translatable="false" name="title_activity_controlcenter">@string/title_activity_controlcenter_banglejs_nightly</string>
+ <string translatable="false" name="about_activity_title">@string/about_activity_title_banglejs_nightly</string>
+ <string translatable="false" name="about_description">@string/about_description_banglejs_nightly</string>
+ <string translatable="false" name="gadgetbridge_running">@string/gadgetbridge_running_banglejs_nightly</string>
+</resources>
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8a5579d
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,947 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <!-- 鍒嗘嫞鍛樹笟鍔℃帴鍙c�佸仴搴蜂笌浣嶇疆涓婃姤 -->
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+ <!-- Used for Bluetooth access -->
+ <!-- Request legacy Bluetooth permissions on older devices. -->
+ <uses-permission android:name="android.permission.BLUETOOTH"
+ android:maxSdkVersion="30"/>
+ <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
+ android:maxSdkVersion="30"/>
+ <!-- Needed to looks for Bluetooth devices. -->
+ <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
+ <!-- Needed to communicate with already-paired Bluetooth devices. -->
+ <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
+
+ <!-- Since Android 10 -->
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+ <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
+
+ <uses-permission android:name="android.permission.READ_CALL_LOG" />
+ <uses-permission android:name="android.permission.READ_PHONE_STATE" />
+ <uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS" />
+ <uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
+ <uses-permission android:name="android.permission.CALL_PHONE" />
+ <uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
+ <uses-permission android:name="android.permission.RECEIVE_SMS" />
+ <uses-permission android:name="android.permission.SEND_SMS" />
+ <uses-permission android:name="android.permission.READ_CONTACTS" />
+
+ <!-- Schedule exact alarms (eg. for DST changes) -->
+ <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
+
+ <!-- Take wake locks (e.g. for time sync) -->
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
+
+ <!-- Read loyalty cards from Catima -->
+ <uses-permission android:name="me.hackerchick.catima.READ_CARDS"/>
+ <uses-permission android:name="me.hackerchick.catima.debug.READ_CARDS"/>
+
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
+ android:maxSdkVersion="32" />
+ <uses-permission android:name="android.permission.READ_CALENDAR" />
+ <uses-permission
+ android:name="android.permission.MEDIA_CONTENT_CONTROL"
+ tools:ignore="ProtectedPermissions" />
+
+ <!--
+ Allow Gadgetbridge to run Termux commands. Permission must be granted manually through the
+ permissions page in Android settings.
+ More information: https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent
+ -->
+ <uses-permission android:name="com.termux.permission.RUN_COMMAND"/>
+
+ <!-- Used for background service -->
+ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
+ <uses-permission android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND" />
+
+ <!-- Used for reverse find device -->
+ <uses-permission android:name="android.permission.VIBRATE" />
+
+ <!-- Used for weather provider access -->
+ <uses-permission android:name="cyanogenmod.permission.ACCESS_WEATHER_MANAGER" />
+ <uses-permission android:name="cyanogenmod.permission.READ_WEATHER" />
+ <uses-permission android:name="lineageos.permission.ACCESS_WEATHER_MANAGER" />
+ <uses-permission android:name="lineageos.permission.READ_WEATHER" />
+ <uses-permission android:name="org.omnirom.omnijaws.READ_WEATHER" />
+
+ <!-- Used for starting activities from the background with intents -->
+ <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
+
+ <uses-permission android:name="android.permission.CAMERA"/>
+
+ <!--
+ SDK 30 & Android 11 - Used for getting app name from notifications, and for starting
+ services from other packages via intents, when targeting Android API level 30 or later
+ on devices running Android 11 or later.
+ https://developer.android.com/training/package-visibility
+ https://support.google.com/googleplay/android-developer/answer/10158779?hl=en#zippy=%2Cpermitted-uses-of-the-query-all-packages-permission%2Cexceptions
+ -->
+ <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
+ tools:ignore="QueryAllPackagesPermission" />
+
+ <!-- Needed to show notification with targetSdkVersion 33 with Android 13 -->
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
+
+ <uses-feature
+ android:name="android.hardware.bluetooth"
+ android:required="true" />
+ <uses-feature
+ android:name="android.hardware.bluetooth_le"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.telephony"
+ android:required="false" />
+ <uses-feature
+ android:name="android.software.companion_device_setup"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.camera"
+ android:required="false" />
+
+ <application
+ android:name=".GBApplication"
+ android:allowBackup="false"
+ android:supportsRtl="true"
+ android:fullBackupContent="false"
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:roundIcon="@mipmap/ic_launcher_round"
+ android:requestLegacyExternalStorage="true"
+ android:theme="@style/GadgetbridgeTheme"
+ android:usesCleartextTraffic="true"
+ android:networkSecurityConfig="@xml/network_security_config"
+ 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"
+ android:theme="@style/SplashTheme"
+ android:launchMode="singleTop"
+ android:exported="true">
+ <intent-filter>
+ <category android:name="android.intent.category.DEFAULT" />
+
+ <action android:name="android.intent.action.MAIN" />
+
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+ <activity
+ android:name=".womp.WompWebActivity"
+ android:label="@string/womp_workbench"
+ android:exported="false" />
+ <activity
+ android:name=".womp.WompScanActivity"
+ android:label="@string/womp_scan"
+ android:screenOrientation="portrait"
+ android:exported="false" />
+ <activity
+ android:name=".activities.welcome.WelcomeActivity"
+ android:label="@string/first_start_welcome_title"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.PermissionsActivity"
+ android:label="@string/first_start_permissions_title"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.SettingsActivity"
+ android:label="@string/title_activity_settings"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ 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"
+ android:parentActivityName=".activities.AboutUserPreferencesActivity" />
+ <activity
+ android:name=".activities.charts.ChartsPreferencesActivity"
+ android:label="@string/activity_prefs_charts"
+ android:parentActivityName=".activities.charts.ChartsPreferencesActivity" />
+ <activity
+ android:name=".activities.files.FileManagerActivity"
+ android:label="@string/activity_data_management_directory_content_title"
+ android:parentActivityName=".activities.DataManagementActivity" />
+ <activity
+ android:name=".activities.BackupRestoreProgressActivity"
+ android:label="@string/activity_db_management_backup_restore_label"
+ android:parentActivityName=".activities.DataManagementActivity" />
+ <activity
+ android:name=".activities.discovery.DiscoveryPairingPreferenceActivity"
+ android:label="@string/activity_prefs_discovery_pairing"
+ android:parentActivityName=".activities.SettingsActivity" />
+ <activity
+ android:name=".activities.loyaltycards.LoyaltyCardsSettingsActivity"
+ android:label="@string/loyalty_cards"
+ android:parentActivityName=".activities.devicesettings.DeviceSettingsActivity" />
+ <activity
+ android:name=".devices.garmin.GarminRealtimeSettingsActivity"
+ android:label="@string/loading"
+ android:parentActivityName=".activities.devicesettings.DeviceSettingsActivity" />
+ <activity
+ android:name=".activities.ActivitySummariesActivity"
+ android:label="@string/activity_summaries"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.appmanager.AppManagerActivity"
+ android:label="@string/title_activity_appmanager"
+ android:launchMode="singleTop"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.musicmanager.MusicManagerActivity"
+ android:label="@string/title_activity_musicmanager"
+ android:parentActivityName=".activities.SettingsActivity" />
+ <activity
+ android:name=".activities.AppBlacklistActivity"
+ android:label="@string/title_activity_notification_management"
+ android:parentActivityName=".activities.SettingsActivity" />
+ <activity
+ android:name=".activities.CalBlacklistActivity"
+ android:label="@string/title_activity_calblacklist"
+ android:parentActivityName=".activities.SettingsActivity" />
+ <activity
+ android:name=".devices.vesc.VescControlActivity"
+ android:label="@string/devicetype_vesc"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".devices.idasen.IdasenControlActivity"
+ android:label="@string/devicetype_idasen"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.FwAppInstallerActivity"
+ android:label="@string/title_activity_fw_app_insaller"
+ android:parentActivityName=".activities.ControlCenterv2"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+
+ <category android:name="android.intent.category.DEFAULT" />
+
+ <data android:mimeType="*/*" />
+ <!-- needed for aosp-derived ROMs? -->
+
+ <data android:host="*" />
+ <data android:scheme="file" />
+
+ <!-- as seen on openkeychain repo: https://github.com/open-keychain/open-keychain/blob/master/OpenKeychain/src/main/AndroidManifest.xml -->
+
+ <data android:pathPattern="/.*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\.res" />
+ <data android:pathPattern="/.*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbl" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+
+ <category android:name="android.intent.category.DEFAULT" />
+
+ <!-- no mimeType filter, needed for CM-derived ROMs? -->
+
+ <data android:host="*" />
+ <data android:scheme="file" />
+
+ <!-- as seen on openkeychain repo: https://github.com/open-keychain/open-keychain/blob/master/OpenKeychain/src/main/AndroidManifest.xml -->
+
+ <data android:pathPattern="/.*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.fw" />
+ <data android:pathPattern="/.*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft.en" />
+ <data android:pathPattern="/.*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.ft" />
+ <data android:pathPattern="/.*\\.res" />
+ <data android:pathPattern="/.*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.res" />
+ <data android:pathPattern="/.*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.gps" />
+ <data android:pathPattern="/.*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbw" />
+ <data android:pathPattern="/.*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbz" />
+ <data android:pathPattern="/.*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbl" />
+ <data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.pbl" />
+ </intent-filter>
+
+ <!-- to receive the firmwares from the download content provider -->
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+
+ <category android:name="android.intent.category.DEFAULT" />
+
+ <data android:mimeType="application/octet-stream" />
+ </intent-filter>
+
+ <!-- to receive firmwares from the download content provider if recognized as zip -->
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+
+ <category android:name="android.intent.category.DEFAULT" />
+
+ <data android:mimeType="application/zip" />
+ <data android:mimeType="application/x-zip-compressed" />
+ </intent-filter>
+
+ <!-- to receive files from the "share" intent -->
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+
+ <category android:name="android.intent.category.DEFAULT" />
+
+ <data android:mimeType="*/*" />
+ </intent-filter>
+ </activity>
+ <activity
+ android:exported="false"
+ android:name=".activities.app_specific_notifications.AppSpecificNotificationSettingsActivity"
+ android:label="@string/prefs_wena3_notification_per_app_settings_title"
+ android:parentActivityName=".activities.devicesettings.DeviceSettingsActivity">
+ <intent-filter>
+ <action android:name=".SonyWena3PerAppNotificationSettingsActivity" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ </activity>
+ <activity
+ android:exported="false"
+ android:name=".activities.app_specific_notifications.AppSpecificNotificationSettingsDetailActivity"
+ android:label="@string/prefs_wena3_notification_per_app_settings_title"
+ android:parentActivityName=".activities.app_specific_notifications.AppSpecificNotificationSettingsActivity"
+ />
+
+ <service
+ android:name=".externalevents.NotificationListener"
+ android:label="@string/app_name"
+ android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
+ android:exported="false"
+ android:foregroundServiceType="specialUse">
+ <property
+ android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
+ android:value="Bind to notification listener for capturing and forwarding notifications" />
+ <intent-filter>
+ <action android:name="android.service.notification.NotificationListenerService" />
+ </intent-filter>
+ </service>
+ <service
+ android:name=".service.NotificationCollectorMonitorService"
+ android:foregroundServiceType="specialUse">
+ <property
+ android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
+ android:value="Service for capturing and forwarding notifications" />
+ </service>
+ <service
+ android:name=".service.DeviceCommunicationService"
+ android:foregroundServiceType="connectedDevice" />
+ <service
+ android:name=".womp.WompKeepAliveService"
+ android:exported="false"
+ android:foregroundServiceType="location" />
+ <service
+ android:name=".devices.pinetime.PineTimeDFUService"
+ android:label="PineTime Nordic DFU service"
+ android:foregroundServiceType="connectedDevice" />
+
+ <service
+ android:name=".service.btle.BLEScanService"
+ android:enabled="true"
+ android:exported="false"
+ android:foregroundServiceType="connectedDevice" />
+
+ <receiver
+ android:name=".externalevents.GenericWeatherReceiver"
+ android:enabled="true"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="nodomain.freeyourgadget.gadgetbridge.ACTION_GENERIC_WEATHER" />
+ </intent-filter>
+ </receiver>
+
+ <receiver
+ android:name=".externalevents.WeatherNotificationReceiver"
+ android:enabled="true"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="ru.gelin.android.weather.notification.ACTION_WEATHER_UPDATE_2" />
+ </intent-filter>
+ </receiver>
+
+ <activity android:name=".externalevents.WeatherNotificationConfig"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="ru.gelin.android.weather.notification.ACTION_WEATHER_SKIN_PREFERENCES" />
+ </intent-filter>
+ </activity>
+
+ <receiver
+ android:name=".externalevents.AutoStartReceiver"
+ android:permission="android.permission.RECEIVE_BOOT_COMPLETED"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="android.intent.action.BOOT_COMPLETED" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
+ </intent-filter>
+ </receiver>
+
+ <receiver
+ android:name=".externalevents.BluetoothStateChangeReceiver"
+ android:exported="true"
+ android:permission="android.permission.BLUETOOTH,android.permission.BLUETOOTH_ADMIN">
+ <intent-filter>
+ <action android:name="android.bluetooth.adapter.action.STATE_CHANGED" />
+ <action android:name="android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED" />
+
+ <action android:name="android.bluetooth.device.action.ACL_CONNECTED" />
+ <action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />
+ <action android:name="android.bluetooth.device.action.ACL_DISCONNECT_REQUESTED" />
+ </intent-filter>
+ </receiver>
+
+ <receiver
+ android:name=".service.receivers.GBMusicControlReceiver"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="nodomain.freeyourgadget.gadgetbridge.musiccontrol" />
+ </intent-filter>
+ </receiver>
+ <receiver
+ android:name=".service.receivers.GBCallControlReceiver"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="nodomain.freeyourgadget.gadgetbridge.callcontrol" />
+ </intent-filter>
+ </receiver>
+ <receiver
+ android:name=".database.PeriodicExporter"
+ android:enabled="true"
+ android:exported="false" />
+ <!--
+ forcing the DebugActivity to portrait mode avoids crashes with the progress
+ dialog when changing orientation
+ -->
+ <activity
+ android:name=".activities.DebugActivity"
+ android:label="@string/title_activity_debug"
+ android:parentActivityName=".activities.ControlCenterv2"
+ android:screenOrientation="portrait"
+ android:windowSoftInputMode="stateHidden" />
+ <activity
+ android:name=".activities.AboutActivity"
+ android:label="@string/about_activity_title"
+ android:parentActivityName=".activities.ControlCenterv2"
+ android:windowSoftInputMode="stateHidden" />
+ <activity
+ android:name=".activities.OpenFwAppInstallerActivity"
+ android:label="@string/open_fw_installer_info_text_title"
+ android:parentActivityName=".activities.ControlCenterv2"
+ android:windowSoftInputMode="stateHidden" />
+ <activity
+ android:name=".activities.BatteryInfoActivity"
+ android:label="@string/battery_detail_activity_title"
+ android:parentActivityName=".activities.ControlCenterv2" />
+
+ <activity
+ android:name=".activities.ActivitySummaryDetail"
+ android:label="@string/activity_summary_detail"
+ android:parentActivityName=".activities.ActivitySummariesActivity"
+ android:windowSoftInputMode="stateHidden" />
+ <activity
+ android:name=".activities.ActivitySummariesFilter"
+ android:label="@string/activity_filter_filter_title"
+ android:parentActivityName=".activities.ActivitySummariesActivity"
+ android:excludeFromRecents="true"/>
+
+ <activity
+ android:name=".activities.DataManagementActivity"
+ android:label="@string/title_activity_db_management"
+ android:parentActivityName=".activities.ControlCenterv2"
+ android:windowSoftInputMode="stateHidden" />
+ <activity
+ android:name=".activities.NotificationManagementActivity"
+ android:label="@string/title_activity_notification_management"
+ android:parentActivityName=".activities.ControlCenterv2"
+ android:windowSoftInputMode="stateHidden" />
+ <activity
+ android:name=".activities.discovery.DiscoveryActivityV2"
+ android:label="@string/title_activity_discovery"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.AndroidPairingActivity"
+ android:label="@string/title_activity_android_pairing" />
+ <activity
+ android:name=".devices.banglejs.AppsManagementActivity"
+ android:label="@string/title_activity_appmanager"
+ android:launchMode="singleTop"
+ android:configChanges="keyboard|keyboardHidden|orientation|screenSize|layoutDirection|uiMode"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".devices.miband.MiBandPairingActivity"
+ android:label="@string/title_activity_mi_band_pairing" />
+ <activity
+ android:name=".devices.pebble.PebblePairingActivity"
+ android:label="@string/title_activity_pebble_pairing" />
+ <activity
+ android:name=".devices.watch9.Watch9PairingActivity"
+ android:label="@string/title_activity_watch9_pairing" />
+ <activity
+ android:name=".devices.watch9.Watch9CalibrationActivity"
+ android:label="@string/title_activity_watch9_calibration" />
+ <activity
+ android:name=".devices.lenovo.LenovoWatchPairingActivity"
+ android:label="@string/title_activity_watch9_pairing" />
+ <activity
+ android:name=".devices.lenovo.LenovoWatchCalibrationActivity"
+ android:label="@string/title_activity_LenovoWatch_calibration" />
+ <activity
+ android:name=".activities.charts.ActivityChartsActivity"
+ android:label="@string/title_activity_charts"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.ConfigureAlarms"
+ android:label="@string/title_activity_set_alarm"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.ConfigureReminders"
+ android:label="@string/title_activity_set_reminders"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.ConfigureContacts"
+ android:label="@string/title_activity_set_contacts"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.widgets.WidgetScreensListActivity"
+ android:label="@string/menuitem_widgets"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.ConfigureWorldClocks"
+ android:label="@string/pref_world_clocks_title"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.devicesettings.DeviceSettingsActivity"
+ android:label="@string/title_activity_device_specific_settings"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.AlarmDetails"
+ android:label="@string/title_activity_alarm_details"
+ android:parentActivityName=".activities.ConfigureAlarms"
+ android:screenOrientation="portrait" />
+ <activity
+ android:name=".activities.ReminderDetails"
+ android:label="@string/title_activity_reminder_details"
+ android:parentActivityName=".activities.ConfigureReminders"
+ android:screenOrientation="portrait"
+ android:windowSoftInputMode="adjustResize" />
+ <activity
+ android:name=".activities.ContactDetails"
+ android:label="@string/title_activity_contact_details"
+ android:parentActivityName=".activities.ConfigureReminders"
+ android:screenOrientation="portrait"
+ android:windowSoftInputMode="adjustResize" />
+ <activity
+ android:name=".activities.widgets.WidgetScreenDetailsActivity"
+ android:label="@string/widget_screen"
+ android:parentActivityName=".activities.widgets.WidgetScreensListActivity"
+ android:screenOrientation="portrait"
+ android:windowSoftInputMode="adjustResize" />
+ <activity
+ android:name=".activities.WorldClockDetails"
+ android:label="@string/title_activity_world_clock_details"
+ android:parentActivityName=".activities.ConfigureWorldClocks"
+ android:screenOrientation="portrait"
+ android:windowSoftInputMode="adjustResize" />
+ <activity
+ android:name=".activities.VibrationActivity"
+ android:label="@string/title_activity_vibration"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity
+ android:name=".activities.NotificationFilterActivity"
+ android:label="@string/title_activity_notification_filter"
+ android:parentActivityName=".activities.AppBlacklistActivity"
+ android:windowSoftInputMode="stateHidden|adjustPan" />
+ <activity
+ android:name=".activities.FindPhoneActivity"
+ android:label="Find Phone" />
+
+ <provider
+ android:name=".contentprovider.PebbleContentProvider"
+ android:authorities="@string/pebble_content_provider"
+ android:exported="true" />
+
+ <provider
+ android:name="androidx.core.content.FileProvider"
+ android:authorities="${applicationId}.screenshot_provider"
+ android:exported="false"
+ android:grantUriPermissions="true">
+ <meta-data
+ android:name="android.support.FILE_PROVIDER_PATHS"
+ android:resource="@xml/shared_paths" />
+ </provider>
+
+ <receiver
+ android:name=".SleepAlarmWidget"
+ android:label="@string/appwidget_sleep_alarm_widget_label"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
+ <action android:name="nodomain.freeyourgadget.gadgetbridge.SLEEP_ALARM_WIDGET_CLICK" />
+ </intent-filter>
+
+ <meta-data
+ android:name="android.appwidget.provider"
+ android:resource="@xml/sleep_alarm_widget_info" />
+ </receiver>
+ <receiver
+ android:name=".Widget"
+ android:label="@string/widget_listing_label"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
+ <action android:name="android.appwidget.action.APPWIDGET_DELETED" />
+ <action android:name="android.appwidget.action.APPWIDGET_DISABLED" />
+ <action android:name="nodomain.freeyourgadget.gadgetbridge.WidgetClick" />
+ </intent-filter>
+
+ <meta-data
+ android:name="android.appwidget.provider"
+ android:resource="@xml/widget_info" />
+ </receiver>
+
+ <activity
+ android:name=".activities.WidgetAlarmsActivity"
+ android:excludeFromRecents="true"
+ android:launchMode="singleInstance"
+ android:theme="@style/Theme.AppCompat.Light.Dialog" />
+
+ <activity android:name=".activities.WidgetConfigurationActivity"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
+ </intent-filter>
+ </activity>
+
+ <activity android:name=".activities.SleepAlarmWidgetConfigurationActivity"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
+ </intent-filter>
+ </activity>
+
+ <activity
+ android:name=".activities.ExternalPebbleJSActivity"
+ android:allowTaskReparenting="true"
+ android:clearTaskOnLaunch="true"
+ android:label="@string/app_configure"
+ android:launchMode="singleTask"
+ android:parentActivityName=".activities.appmanager.AppManagerActivity"
+ android:exported="true">
+ <meta-data
+ android:name="android.support.PARENT_ACTIVITY"
+ android:value="nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2" />
+
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+
+ <data android:scheme="gadgetbridge" />
+ </intent-filter>
+ </activity>
+
+ <activity
+ android:name=".devices.qhybrid.QHybridConfigActivity"
+ android:label="@string/qhybrid_title_watchface"
+ android:exported="true"
+ android:parentActivityName=".activities.devicesettings.DeviceSettingsActivity" />
+ <activity
+ android:name=".devices.qhybrid.QHybridAppChoserActivity"
+ android:label="@string/qhybrid_title_apps"
+ android:exported="true"
+ android:parentActivityName=".devices.qhybrid.QHybridConfigActivity" />
+ <activity
+ android:name=".devices.qhybrid.HRConfigActivity"
+ android:label="@string/qhybrid_title_watchface"
+ android:exported="true"
+ android:parentActivityName=".activities.ControlCenterv2" />
+ <activity android:name=".devices.qhybrid.WidgetSettingsActivity"
+ android:label="@string/add_widget"
+ android:parentActivityName=".devices.qhybrid.HRConfigActivity" />
+ <activity
+ android:name=".devices.qhybrid.ImageEditActivity"
+ android:label="@string/qhybrid_title_background_image"
+ android:exported="true"
+ android:parentActivityName=".devices.qhybrid.HRConfigActivity" />
+ <activity
+ android:name=".devices.qhybrid.FileManagementActivity"
+ android:label="@string/qhybrid_title_file_management"
+ android:exported="true"
+ android:parentActivityName=".devices.qhybrid.HRConfigActivity"/>
+ <activity
+ android:name=".devices.qhybrid.AppsManagementActivity"
+ android:label="@string/qhybrid_title_apps_management"
+ android:exported="true"
+ android:parentActivityName=".devices.qhybrid.HRConfigActivity" />
+ <activity
+ android:name=".devices.qhybrid.CalibrationActivity"
+ android:label="@string/qhybrid_title_calibration"
+ android:parentActivityName=".devices.qhybrid.HRConfigActivity" />
+ <activity
+ android:name=".devices.withingssteelhr.WithingsCalibrationActivity"
+ android:label="@string/qhybrid_title_calibration"
+ android:parentActivityName=".devices.withingssteelhr.WithingsCalibrationActivity" />
+ <activity
+ android:name=".devices.qhybrid.CommuteActionsActivity"
+ android:label="@string/qhybrid_pref_title_actions"
+ android:parentActivityName=".devices.qhybrid.HRConfigActivity" />
+ <activity
+ android:name=".devices.qhybrid.HybridHRWatchfaceDesignerActivity"
+ android:label="@string/qhybrid_title_watchface_designer"
+ android:screenOrientation="portrait"
+ android:launchMode="singleTop"
+ android:parentActivityName=".activities.appmanager.AppManagerActivity" />
+ <activity
+ android:name=".devices.qhybrid.HybridHRWatchfaceSettingsActivity"
+ android:label="@string/watchface_dialog_title_settings"
+ android:parentActivityName=".devices.qhybrid.HybridHRWatchfaceDesignerActivity" />
+ <activity
+ android:name=".devices.qhybrid.HybridHRWatchfaceWidgetActivity"
+ android:label="@string/watchface_dialog_title_widget"
+ android:parentActivityName=".devices.qhybrid.HybridHRWatchfaceDesignerActivity" />
+
+ <activity
+ android:name=".devices.um25.Activity.DataActivity"
+ android:exported="true" />
+
+ <activity
+ android:name=".devices.supercars.ControlActivity"
+ android:exported="true" />
+
+ <activity
+ android:name=".devices.marstek.SolarEquipmentStatusActivity"
+ android:label="Solar Equipment Status"
+ android:exported="true" />
+
+ <activity
+ android:name=".devices.binary_sensor.activity.DataActivity"
+ android:exported="true" />
+
+ <activity
+ android:name=".activities.GpxReceiverActivity"
+ android:label="@string/gpx_receiver_activity_title"
+ android:screenOrientation="portrait"
+ android:windowSoftInputMode="stateHidden"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+ <action android:name="android.intent.action.SEND_MULTIPLE" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="*/*" />
+ </intent-filter>
+ </activity>
+
+ <activity
+ android:name=".activities.TextReceiverActivity"
+ android:excludeFromRecents="true"
+ android:label="@string/text_receiver_activity_title"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="text/plain" />
+ </intent-filter>
+ </activity>
+
+ <activity
+ android:name=".activities.WakeActivity"
+ android:label="WakeActivity">
+ </activity>
+
+ <activity
+ android:name=".externalevents.opentracks.OpenTracksController"
+ android:label="OpenTracks controller and intent receiver"
+ android:exported="true"/>
+
+ <activity
+ android:name=".activities.dashboard.DashboardCalendarActivity"
+ android:label="@string/menuitem_calendar"
+ android:parentActivityName=".activities.ControlCenterv2" />
+
+ <activity
+ android:name=".activities.CameraActivity"
+ android:launchMode="singleInstance"
+ android:exported="false" />
+
+ <activity
+ android:name=".devices.cycling_sensor.activity.CyclingLiveDataActivity"
+ android:launchMode="singleInstance"
+ android:exported="true" />
+ </application>
+
+</manifest>
diff --git a/app/src/main/aidl/com/android/internal/telephony/ITelephony.aidl b/app/src/main/aidl/com/android/internal/telephony/ITelephony.aidl
new file mode 100644
index 0000000..5eeb60e
--- /dev/null
+++ b/app/src/main/aidl/com/android/internal/telephony/ITelephony.aidl
@@ -0,0 +1,8 @@
+package com.android.internal.telephony;
+
+
+interface ITelephony {
+ boolean endCall();
+ void answerRingingCall();
+ void silenceRinger();
+}
diff --git a/app/src/main/aidl/lineageos/weather/ILineageWeatherManager.aidl b/app/src/main/aidl/lineageos/weather/ILineageWeatherManager.aidl
new file mode 100644
index 0000000..d12e8bb
--- /dev/null
+++ b/app/src/main/aidl/lineageos/weather/ILineageWeatherManager.aidl
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weather;
+
+import lineageos.weather.IWeatherServiceProviderChangeListener;
+import lineageos.weather.RequestInfo;
+
+interface ILineageWeatherManager {
+ oneway void updateWeather(in RequestInfo info);
+ oneway void lookupCity(in RequestInfo info);
+ oneway void registerWeatherServiceProviderChangeListener(
+ in IWeatherServiceProviderChangeListener listener);
+ oneway void unregisterWeatherServiceProviderChangeListener(
+ in IWeatherServiceProviderChangeListener listener);
+ String getActiveWeatherServiceProviderLabel();
+ oneway void cancelRequest(int requestId);
+}
\ No newline at end of file
diff --git a/app/src/main/aidl/lineageos/weather/IRequestInfoListener.aidl b/app/src/main/aidl/lineageos/weather/IRequestInfoListener.aidl
new file mode 100644
index 0000000..11916f3
--- /dev/null
+++ b/app/src/main/aidl/lineageos/weather/IRequestInfoListener.aidl
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weather;
+
+import lineageos.weather.RequestInfo;
+import lineageos.weather.WeatherInfo;
+import lineageos.weather.WeatherLocation;
+
+import java.util.List;
+
+interface IRequestInfoListener {
+ void onWeatherRequestCompleted(in RequestInfo requestInfo, int status,
+ in WeatherInfo weatherInfo);
+ void onLookupCityRequestCompleted(in RequestInfo requestInfo, int status,
+ in List<WeatherLocation> weatherLocation);
+}
\ No newline at end of file
diff --git a/app/src/main/aidl/lineageos/weather/IWeatherServiceProviderChangeListener.aidl b/app/src/main/aidl/lineageos/weather/IWeatherServiceProviderChangeListener.aidl
new file mode 100644
index 0000000..12ad2ff
--- /dev/null
+++ b/app/src/main/aidl/lineageos/weather/IWeatherServiceProviderChangeListener.aidl
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weather;
+
+/** @hide */
+oneway interface IWeatherServiceProviderChangeListener {
+ void onWeatherServiceProviderChanged(String providerLabel);
+}
\ No newline at end of file
diff --git a/app/src/main/aidl/lineageos/weather/RequestInfo.aidl b/app/src/main/aidl/lineageos/weather/RequestInfo.aidl
new file mode 100644
index 0000000..bdc3ecc
--- /dev/null
+++ b/app/src/main/aidl/lineageos/weather/RequestInfo.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weather;
+
+parcelable RequestInfo;
\ No newline at end of file
diff --git a/app/src/main/aidl/lineageos/weather/WeatherInfo.aidl b/app/src/main/aidl/lineageos/weather/WeatherInfo.aidl
new file mode 100644
index 0000000..16cbb59
--- /dev/null
+++ b/app/src/main/aidl/lineageos/weather/WeatherInfo.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2016 The CyanongenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weather;
+
+parcelable WeatherInfo;
\ No newline at end of file
diff --git a/app/src/main/aidl/lineageos/weather/WeatherLocation.aidl b/app/src/main/aidl/lineageos/weather/WeatherLocation.aidl
new file mode 100644
index 0000000..d19e8bc
--- /dev/null
+++ b/app/src/main/aidl/lineageos/weather/WeatherLocation.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weather;
+
+parcelable WeatherLocation;
\ No newline at end of file
diff --git a/app/src/main/aidl/lineageos/weatherservice/IWeatherProviderService.aidl b/app/src/main/aidl/lineageos/weatherservice/IWeatherProviderService.aidl
new file mode 100644
index 0000000..fb3bc5e
--- /dev/null
+++ b/app/src/main/aidl/lineageos/weatherservice/IWeatherProviderService.aidl
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weatherservice;
+
+import lineageos.weatherservice.IWeatherProviderServiceClient;
+import lineageos.weather.RequestInfo;
+
+interface IWeatherProviderService {
+ void processWeatherUpdateRequest(in RequestInfo request);
+ void processCityNameLookupRequest(in RequestInfo request);
+ void setServiceClient(in IWeatherProviderServiceClient client);
+ void cancelOngoingRequests();
+ void cancelRequest(int requestId);
+}
\ No newline at end of file
diff --git a/app/src/main/aidl/lineageos/weatherservice/IWeatherProviderServiceClient.aidl b/app/src/main/aidl/lineageos/weatherservice/IWeatherProviderServiceClient.aidl
new file mode 100644
index 0000000..2f54edd
--- /dev/null
+++ b/app/src/main/aidl/lineageos/weatherservice/IWeatherProviderServiceClient.aidl
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weatherservice;
+
+import lineageos.weather.RequestInfo;
+import lineageos.weatherservice.ServiceRequestResult;
+
+interface IWeatherProviderServiceClient {
+ void setServiceRequestState(in RequestInfo requestInfo, in ServiceRequestResult result,
+ int state);
+}
\ No newline at end of file
diff --git a/app/src/main/aidl/lineageos/weatherservice/ServiceRequestResult.aidl b/app/src/main/aidl/lineageos/weatherservice/ServiceRequestResult.aidl
new file mode 100644
index 0000000..ece4f47
--- /dev/null
+++ b/app/src/main/aidl/lineageos/weatherservice/ServiceRequestResult.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weatherservice;
+
+parcelable ServiceRequestResult;
\ No newline at end of file
diff --git a/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlCallback.aidl b/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlCallback.aidl
new file mode 100644
index 0000000..0598f31
--- /dev/null
+++ b/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlCallback.aidl
@@ -0,0 +1,59 @@
+package net.osmand.aidlapi;
+
+import net.osmand.aidlapi.search.SearchResult;
+import net.osmand.aidlapi.gpx.AGpxBitmap;
+import net.osmand.aidlapi.navigation.ADirectionInfo;
+import net.osmand.aidlapi.navigation.OnVoiceNavigationParams;
+
+interface IOsmAndAidlCallback {
+
+ /**
+ * Callback for search requests.
+ *
+ * @return resultSet - set of SearchResult
+ */
+ void onSearchComplete(in List<SearchResult> resultSet);
+
+ /**
+ * Callback for {@link IOsmAndAidlInterface} registerForUpdates() method.
+ */
+ void onUpdate();
+
+ /**
+ * Callback for {@link IOsmAndAidlInterface} registerForOsmandInitListener() method.
+ */
+ void onAppInitialized();
+
+ /**
+ * Callback for {@link IOsmAndAidlInterface} getBitmapForGpx() method.
+ *
+ * @return bitmap - snapshot image of gpx track on map
+ */
+ void onGpxBitmapCreated(in AGpxBitmap bitmap);
+
+ /**
+ * Callback for {@link IOsmAndAidlInterface} registerForNavigationUpdates() method.
+ *
+ * @return directionInfo - update on distance to next turn and turns type.
+ */
+ void updateNavigationInfo(in ADirectionInfo directionInfo);
+
+ /**
+ * Callback for {@link IOsmAndAidlInterface} buttons set with addContextMenuButtons() method.
+ *
+ * @param buttonId - id of custom button
+ * @param pointId - id of point button associated with
+ * @param layerId - id of layer point and button associated with
+ */
+ void onContextMenuButtonClicked(in int buttonId, String pointId, String layerId);
+
+ /**
+ * Callback for {@link IOsmAndAidlInterface} registerForVoiceRouterMessages() method.
+ */
+ void onVoiceRouterNotify(in OnVoiceNavigationParams params);
+
+ /**
+ * Callback for {@link IOsmAndAidlInterface} registerForKeyEvents() method.
+ */
+ void onKeyEvent(in KeyEvent params);
+}
\ No newline at end of file
diff --git a/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl b/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl
new file mode 100644
index 0000000..c22a9db
--- /dev/null
+++ b/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl
@@ -0,0 +1,25 @@
+package net.osmand.aidlapi;
+
+import net.osmand.aidlapi.navigation.ANavigationUpdateParams;
+import net.osmand.aidlapi.navigation.ANavigationVoiceRouterMessageParams;
+import net.osmand.aidlapi.IOsmAndAidlCallback;
+
+interface IOsmAndAidlInterface {
+ /**
+ * Method to register for updates during navgation. Notifies user about distance to the next turn and its type.
+ *
+ * @param subscribeToUpdates (boolean) - subscribe or unsubscribe from updates
+ * @param callbackId (long) - id of callback, needed to unsubscribe from updates
+ * @param callback (IOsmAndAidlCallback) - callback to notify user on navigation data change
+ */
+ long registerForNavigationUpdates(in ANavigationUpdateParams params, IOsmAndAidlCallback callback) = 65;
+
+ /**
+ * Method to register for Voice Router voice messages during navigation. Notifies user about voice messages.
+ *
+ * @params subscribeToUpdates (boolean) - boolean flag to subscribe or unsubscribe from messages
+ * @params callbackId (long) - id of callback, needed to unsubscribe from messages
+ * @params callback (IOsmAndAidlCallback) - callback to notify user on voice message
+ */
+ long registerForVoiceRouterMessages(in ANavigationVoiceRouterMessageParams params, IOsmAndAidlCallback callback) = 71;
+}
diff --git a/app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxBitmap.aidl b/app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxBitmap.aidl
new file mode 100644
index 0000000..abbc036
--- /dev/null
+++ b/app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxBitmap.aidl
@@ -0,0 +1,3 @@
+package net.osmand.aidlapi.gpx;
+
+parcelable AGpxBitmap;
\ No newline at end of file
diff --git a/app/src/main/aidl/net/osmand/aidlapi/navigation/ADirectionInfo.aidl b/app/src/main/aidl/net/osmand/aidlapi/navigation/ADirectionInfo.aidl
new file mode 100644
index 0000000..19d7a7e
--- /dev/null
+++ b/app/src/main/aidl/net/osmand/aidlapi/navigation/ADirectionInfo.aidl
@@ -0,0 +1,3 @@
+package net.osmand.aidlapi.navigation;
+
+parcelable ADirectionInfo;
diff --git a/app/src/main/aidl/net/osmand/aidlapi/navigation/ANavigationUpdateParams.aidl b/app/src/main/aidl/net/osmand/aidlapi/navigation/ANavigationUpdateParams.aidl
new file mode 100644
index 0000000..ccdbc4b
--- /dev/null
+++ b/app/src/main/aidl/net/osmand/aidlapi/navigation/ANavigationUpdateParams.aidl
@@ -0,0 +1,4 @@
+
+package net.osmand.aidlapi.navigation;
+
+parcelable ANavigationUpdateParams;
\ No newline at end of file
diff --git a/app/src/main/aidl/net/osmand/aidlapi/navigation/ANavigationVoiceRouterMessageParams.aidl b/app/src/main/aidl/net/osmand/aidlapi/navigation/ANavigationVoiceRouterMessageParams.aidl
new file mode 100644
index 0000000..84fd5c2
--- /dev/null
+++ b/app/src/main/aidl/net/osmand/aidlapi/navigation/ANavigationVoiceRouterMessageParams.aidl
@@ -0,0 +1,3 @@
+package net.osmand.aidlapi.navigation;
+
+parcelable ANavigationVoiceRouterMessageParams;
diff --git a/app/src/main/aidl/net/osmand/aidlapi/navigation/OnVoiceNavigationParams.aidl b/app/src/main/aidl/net/osmand/aidlapi/navigation/OnVoiceNavigationParams.aidl
new file mode 100644
index 0000000..107f69c
--- /dev/null
+++ b/app/src/main/aidl/net/osmand/aidlapi/navigation/OnVoiceNavigationParams.aidl
@@ -0,0 +1,3 @@
+package net.osmand.aidlapi.navigation;
+
+parcelable OnVoiceNavigationParams;
\ No newline at end of file
diff --git a/app/src/main/aidl/net/osmand/aidlapi/search/SearchResult.aidl b/app/src/main/aidl/net/osmand/aidlapi/search/SearchResult.aidl
new file mode 100644
index 0000000..54b7487
--- /dev/null
+++ b/app/src/main/aidl/net/osmand/aidlapi/search/SearchResult.aidl
@@ -0,0 +1,3 @@
+package net.osmand.aidlapi.search;
+
+parcelable SearchResult;
\ No newline at end of file
diff --git a/app/src/main/assets/activity.svg b/app/src/main/assets/activity.svg
new file mode 100644
index 0000000..fe5a484
--- /dev/null
+++ b/app/src/main/assets/activity.svg
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="24"
+ height="24"
+ tint="#7E7E7E"
+ viewportWidth="25"
+ viewportHeight="25"
+ version="1.1"
+ id="svg1967"
+ sodipodi:docname="activity.svg"
+ inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <metadata
+ id="metadata1973">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs1971" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1042"
+ id="namedview1969"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="20.08"
+ inkscape:cx="19.684708"
+ inkscape:cy="14.278117"
+ inkscape:window-x="0"
+ inkscape:window-y="38"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg1967"
+ inkscape:document-rotation="0">
+ <sodipodi:guide
+ position="20.217787,166.09221"
+ orientation="0,-1"
+ id="guide2551" />
+ <sodipodi:guide
+ position="8.775067,164.28813"
+ orientation="1,0"
+ id="guide2553" />
+ </sodipodi:namedview>
+ <g
+ id="g5608"
+ transform="matrix(0.8841733,0,0,0.8841733,1.3899206,1.3899206)"
+ style="stroke-width:1.131">
+ <path
+ fill="#000000"
+ d="m 12,18 a 6,6 0 0 1 -6,-6 6,6 0 0 1 6,-6 6,6 0 0 1 6,6 6,6 0 0 1 -6,6 M 20,15.31 23.31,12 20,8.69 V 4 H 15.31 L 12,0.69 8.69,4 H 4 V 8.69 L 0.69,12 4,15.31 V 20 H 8.69 L 12,23.31 15.31,20 H 20 Z"
+ id="path1965"
+ style="stroke-width:1.95392" />
+ <path
+ fill="currentColor"
+ d="m 4.4762317,12.388718 h 3.561751 l 2.0837443,-5.0286723 1.932068,5.4893343 1.823648,-2.726785 h 6.48905 l -0.488068,1.38717 H 14.61543 l -2.938854,4.509156 -1.60202,-4.54569 -0.9505983,2.403348 -3.506487,-4.01e-4 z"
+ id="path2361"
+ sodipodi:nodetypes="ccccccccccccc"
+ style="stroke-width:1.131"
+ inkscape:transform-center-x="1.0957646"
+ inkscape:transform-center-y="-1.4792654" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/activity_stacked.svg b/app/src/main/assets/activity_stacked.svg
new file mode 100644
index 0000000..882ecdd
--- /dev/null
+++ b/app/src/main/assets/activity_stacked.svg
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="26"
+ height="26"
+ tint="#7E7E7E"
+ viewportWidth="25"
+ viewportHeight="25"
+ version="1.1"
+ id="svg1967"
+ sodipodi:docname="activity_stacked.svg"
+ inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <metadata
+ id="metadata1973">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs1971" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1042"
+ id="namedview1969"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16"
+ inkscape:cx="1.5628297"
+ inkscape:cy="3.351769"
+ inkscape:window-x="0"
+ inkscape:window-y="38"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="g5608"
+ inkscape:document-rotation="0"
+ inkscape:snap-nodes="false">
+ <sodipodi:guide
+ position="18.131447,189.15957"
+ orientation="0,-1"
+ id="guide2551" />
+ <sodipodi:guide
+ position="6.8118842,20.241473"
+ orientation="1,0"
+ id="guide2553" />
+ </sodipodi:namedview>
+ <g
+ id="g5608"
+ transform="matrix(0.6107681,0,0,0.6107681,-0.50776959,-2.4214301)"
+ style="stroke-width:1.63728">
+ <g
+ id="g2806"
+ transform="matrix(1.8819341,0,-1.0509142,4.0780968,7.7001407,-9.1852646)"
+ style="stroke-width:0.591009">
+ <g
+ id="g2123"
+ transform="matrix(1,0,0,0.33594705,0,3.7327618)"
+ style="stroke-width:1.01966;stroke-miterlimit:4;stroke-dasharray:none">
+ <path
+ fill="#000000"
+ d="m 12,18 a 6,6 0 0 1 -6,-6 6,6 0 0 1 6,-6 6,6 0 0 1 6,6 6,6 0 0 1 -6,6 M 20,15.31 23.31,12 20,8.69 V 4 H 15.31 L 12,0.69 8.69,4 H 4 V 8.69 L 0.69,12 4,15.31 V 20 H 8.69 L 12,23.31 15.31,20 H 20 Z"
+ id="path1965"
+ style="stroke-width:1.01966;stroke-miterlimit:4;stroke-dasharray:none" />
+ <path
+ fill="currentColor"
+ d="m 4.4762317,12.388718 h 3.561751 l 2.0837443,-5.0286723 1.932068,5.4893343 1.823648,-2.726785 h 6.48905 l -0.488068,1.38717 H 14.61543 l -2.938854,4.509156 -1.60202,-4.54569 -0.9505983,2.403348 -3.506487,-4.01e-4 z"
+ id="path2361"
+ sodipodi:nodetypes="ccccccccccccc"
+ style="stroke-width:1.01966;stroke-miterlimit:4;stroke-dasharray:none"
+ inkscape:transform-center-x="1.0957646"
+ inkscape:transform-center-y="-1.4792654" />
+ </g>
+ <path
+ style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.591009;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000"
+ d="m 3.9815232,10.751128 v 0.490234 h 4.6484375 l 3.3515623,1.126953 3.349609,-1.126953 h 4.650391 v -0.490234 h -4.730469 l -3.269531,1.097656 -3.2714842,-1.097656 z"
+ id="path2783" />
+ <path
+ style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.591009;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000"
+ d="m 3.9892109,11.549912 v 0.490234 h 4.6503907 l 3.3496094,1.126953 3.349609,-1.126953 h 4.650391 v -0.490234 h -4.730469 l -3.269531,1.097656 -3.2695313,-1.097656 z"
+ id="path2787" />
+ </g>
+ </g>
+</svg>
diff --git a/app/src/main/assets/app_config/configure.html b/app/src/main/assets/app_config/configure.html
new file mode 100644
index 0000000..a27544b
--- /dev/null
+++ b/app/src/main/assets/app_config/configure.html
@@ -0,0 +1,102 @@
+<!DOCTYPE html>
+<head>
+ <meta charset="utf-8"/>
+ <meta name='viewport' content='initial-scale=1.0, maximum-scale=1.0'>
+ <script type="text/javascript" src="js/Uri.js">
+ </script>
+ <script type="text/javascript" src="js/gadgetbridge_boilerplate.js">
+ </script>
+ <script type="text/javascript">
+ </script>
+ <style>
+ html, body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ }
+ iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: none;
+ }
+ body {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ -webkit-tap-highlight-color: rgba(0,0,0,0);
+ -webkit-touch-callout: none;
+ }
+ #config_url,#jsondata {
+ word-wrap: break-word;
+ margin: 20px 0;
+ width: 90%;
+ }
+ .btn {
+ display: inline-block;
+ position: relative;
+ height: 32px;
+ line-height: 32px;
+ border-radius: 2px;
+ font-size: 0.9em;
+ background-color: #eee;
+ color: #646464;
+ text-align: center;
+ border-style: none;
+ }
+ .btn:active {
+ border-style: none;
+ box-shadow: 0 8px 17px 0 rgba(0, 0, 0, 0.2)inset;
+ transition-delay: 0s;
+ }
+ p {
+ width: 90%;
+ }
+ #pastereturn {
+ width: 90%;
+ min-height: 3em;
+ }
+ #step1compat, #step2 {
+ display: none;
+ }
+ <!-- TODO -->
+ </style>
+</head>
+<body>
+<div id="step1" class="step">
+ <h2>URL of the configuration:</h2>
+ <div id="config_url"></div>
+ <!--<button class="btn" name="show config" value="show config" onclick="Pebble.showConfiguration()" >Show config / URL</button>-->
+ <button class="btn" name="open config" value="open config" onclick="Pebble.actuallyOpenURL()">
+ Open configuration website
+ </button>
+ <h2 class="load_presets">App presets:</h2>
+ <button class="btn load_presets" name="read config" value="read config"
+ onclick="Pebble.loadPreset()">
+ Load saved configuration
+ </button>
+</div>
+<div id="step1compat" class="step">
+ <p>In case of "network error" after saving settings in the watchapp, copy the "network error"
+ URL and paste it here:</p>
+ <textarea id="pastereturn"></textarea><br/>
+ <button class="btn" name="parse" onclick="Pebble.parseReturnedPebbleJS()">Parse legacy app
+ configuration
+ </button>
+</div>
+<div id="step2" class="step">
+ <h2>Incoming configuration data:</h2>
+ <div id="jsondata"></div>
+ <button class="btn" name="send config" value="send config" onclick="Pebble.sendConfiguration()">
+ Send data to pebble
+ </button>
+ <h2 class="store_presets">App Presets:</h2>
+ <button class="btn store_presets" name="store config" value="store config"
+ onclick="Pebble.savePreset()">
+ Store incoming configuration
+ </button>
+ <p class="store_presets">Existing presets will be deleted.</p>
+</div>
+</body>
diff --git a/app/src/main/assets/app_config/js/Uri.js b/app/src/main/assets/app_config/js/Uri.js
new file mode 100644
index 0000000..a3fe499
--- /dev/null
+++ b/app/src/main/assets/app_config/js/Uri.js
@@ -0,0 +1,458 @@
+/*!
+ * jsUri
+ * https://github.com/derek-watson/jsUri
+ *
+ * Copyright 2013, Derek Watson
+ * Released under the MIT license.
+ *
+ * Includes parseUri regular expressions
+ * http://blog.stevenlevithan.com/archives/parseuri
+ * Copyright 2007, Steven Levithan
+ * Released under the MIT license.
+ */
+
+ /*globals define, module */
+
+(function(global) {
+
+ var re = {
+ starts_with_slashes: /^\/+/,
+ ends_with_slashes: /\/+$/,
+ pluses: /\+/g,
+ query_separator: /[&;]/,
+ uri_parser: /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?((?:(([^:@\/]*)(?::([^:@\/]*))?)?@)?(\[[0-9a-fA-F:.]+\]|[^:\/?#]*)(?::(\d+|(?=:)))?(:)?)((((?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/
+ };
+
+ /**
+ * Define forEach for older js environments
+ * @see https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/forEach#Compatibility
+ */
+ if (!Array.prototype.forEach) {
+ Array.prototype.forEach = function(callback, thisArg) {
+ var T, k;
+
+ if (this == null) {
+ throw new TypeError(' this is null or not defined');
+ }
+
+ var O = Object(this);
+ var len = O.length >>> 0;
+
+ if (typeof callback !== "function") {
+ throw new TypeError(callback + ' is not a function');
+ }
+
+ if (arguments.length > 1) {
+ T = thisArg;
+ }
+
+ k = 0;
+
+ while (k < len) {
+ var kValue;
+ if (k in O) {
+ kValue = O[k];
+ callback.call(T, kValue, k, O);
+ }
+ k++;
+ }
+ };
+ }
+
+ /**
+ * unescape a query param value
+ * @param {string} s encoded value
+ * @return {string} decoded value
+ */
+ function decode(s) {
+ if (s) {
+ s = s.toString().replace(re.pluses, '%20');
+ s = decodeURIComponent(s);
+ }
+ return s;
+ }
+
+ /**
+ * Breaks a uri string down into its individual parts
+ * @param {string} str uri
+ * @return {object} parts
+ */
+ function parseUri(str) {
+ var parser = re.uri_parser;
+ var parserKeys = ["source", "protocol", "authority", "userInfo", "user", "password", "host", "port", "isColonUri", "relative", "path", "directory", "file", "query", "anchor"];
+ var m = parser.exec(str || '');
+ var parts = {};
+
+ parserKeys.forEach(function(key, i) {
+ parts[key] = m[i] || '';
+ });
+
+ return parts;
+ }
+
+ /**
+ * Breaks a query string down into an array of key/value pairs
+ * @param {string} str query
+ * @return {array} array of arrays (key/value pairs)
+ */
+ function parseQuery(str) {
+ var i, ps, p, n, k, v, l;
+ var pairs = [];
+
+ if (typeof(str) === 'undefined' || str === null || str === '') {
+ return pairs;
+ }
+
+ if (str.indexOf('?') === 0) {
+ str = str.substring(1);
+ }
+
+ ps = str.toString().split(re.query_separator);
+
+ for (i = 0, l = ps.length; i < l; i++) {
+ p = ps[i];
+ n = p.indexOf('=');
+
+ if (n !== 0) {
+ k = decode(p.substring(0, n));
+ v = decode(p.substring(n + 1));
+ pairs.push(n === -1 ? [p, null] : [k, v]);
+ }
+
+ }
+ return pairs;
+ }
+
+ /**
+ * Creates a new Uri object
+ * @constructor
+ * @param {string} str
+ */
+ function Uri(str) {
+ this.uriParts = parseUri(str);
+ this.queryPairs = parseQuery(this.uriParts.query);
+ this.hasAuthorityPrefixUserPref = null;
+ }
+
+ /**
+ * Define getter/setter methods
+ */
+ ['protocol', 'userInfo', 'host', 'port', 'path', 'anchor'].forEach(function(key) {
+ Uri.prototype[key] = function(val) {
+ if (typeof val !== 'undefined') {
+ this.uriParts[key] = val;
+ }
+ return this.uriParts[key];
+ };
+ });
+
+ /**
+ * if there is no protocol, the leading // can be enabled or disabled
+ * @param {Boolean} val
+ * @return {Boolean}
+ */
+ Uri.prototype.hasAuthorityPrefix = function(val) {
+ if (typeof val !== 'undefined') {
+ this.hasAuthorityPrefixUserPref = val;
+ }
+
+ if (this.hasAuthorityPrefixUserPref === null) {
+ return (this.uriParts.source.indexOf('//') !== -1);
+ } else {
+ return this.hasAuthorityPrefixUserPref;
+ }
+ };
+
+ Uri.prototype.isColonUri = function (val) {
+ if (typeof val !== 'undefined') {
+ this.uriParts.isColonUri = !!val;
+ } else {
+ return !!this.uriParts.isColonUri;
+ }
+ };
+
+ /**
+ * Serializes the internal state of the query pairs
+ * @param {string} [val] set a new query string
+ * @return {string} query string
+ */
+ Uri.prototype.query = function(val) {
+ var s = '', i, param, l;
+
+ if (typeof val !== 'undefined') {
+ this.queryPairs = parseQuery(val);
+ }
+
+ for (i = 0, l = this.queryPairs.length; i < l; i++) {
+ param = this.queryPairs[i];
+ if (s.length > 0) {
+ s += '&';
+ }
+ if (param[1] === null) {
+ s += param[0];
+ } else {
+ s += param[0];
+ s += '=';
+ if (typeof param[1] !== 'undefined') {
+ s += encodeURIComponent(param[1]);
+ }
+ }
+ }
+ return s.length > 0 ? '?' + s : s;
+ };
+
+ /**
+ * returns the first query param value found for the key
+ * @param {string} key query key
+ * @return {string} first value found for key
+ */
+ Uri.prototype.getQueryParamValue = function (key) {
+ var param, i, l;
+ for (i = 0, l = this.queryPairs.length; i < l; i++) {
+ param = this.queryPairs[i];
+ if (key === param[0]) {
+ return param[1];
+ }
+ }
+ };
+
+ /**
+ * returns an array of query param values for the key
+ * @param {string} key query key
+ * @return {array} array of values
+ */
+ Uri.prototype.getQueryParamValues = function (key) {
+ var arr = [], i, param, l;
+ for (i = 0, l = this.queryPairs.length; i < l; i++) {
+ param = this.queryPairs[i];
+ if (key === param[0]) {
+ arr.push(param[1]);
+ }
+ }
+ return arr;
+ };
+
+ /**
+ * removes query parameters
+ * @param {string} key remove values for key
+ * @param {val} [val] remove a specific value, otherwise removes all
+ * @return {Uri} returns self for fluent chaining
+ */
+ Uri.prototype.deleteQueryParam = function (key, val) {
+ var arr = [], i, param, keyMatchesFilter, valMatchesFilter, l;
+
+ for (i = 0, l = this.queryPairs.length; i < l; i++) {
+
+ param = this.queryPairs[i];
+ keyMatchesFilter = decode(param[0]) === decode(key);
+ valMatchesFilter = param[1] === val;
+
+ if ((arguments.length === 1 && !keyMatchesFilter) || (arguments.length === 2 && (!keyMatchesFilter || !valMatchesFilter))) {
+ arr.push(param);
+ }
+ }
+
+ this.queryPairs = arr;
+
+ return this;
+ };
+
+ /**
+ * adds a query parameter
+ * @param {string} key add values for key
+ * @param {string} val value to add
+ * @param {integer} [index] specific index to add the value at
+ * @return {Uri} returns self for fluent chaining
+ */
+ Uri.prototype.addQueryParam = function (key, val, index) {
+ if (arguments.length === 3 && index !== -1) {
+ index = Math.min(index, this.queryPairs.length);
+ this.queryPairs.splice(index, 0, [key, val]);
+ } else if (arguments.length > 0) {
+ this.queryPairs.push([key, val]);
+ }
+ return this;
+ };
+
+ /**
+ * test for the existence of a query parameter
+ * @param {string} key check values for key
+ * @return {Boolean} true if key exists, otherwise false
+ */
+ Uri.prototype.hasQueryParam = function (key) {
+ var i, len = this.queryPairs.length;
+ for (i = 0; i < len; i++) {
+ if (this.queryPairs[i][0] == key)
+ return true;
+ }
+ return false;
+ };
+
+ /**
+ * replaces query param values
+ * @param {string} key key to replace value for
+ * @param {string} newVal new value
+ * @param {string} [oldVal] replace only one specific value (otherwise replaces all)
+ * @return {Uri} returns self for fluent chaining
+ */
+ Uri.prototype.replaceQueryParam = function (key, newVal, oldVal) {
+ var index = -1, len = this.queryPairs.length, i, param;
+
+ if (arguments.length === 3) {
+ for (i = 0; i < len; i++) {
+ param = this.queryPairs[i];
+ if (decode(param[0]) === decode(key) && decodeURIComponent(param[1]) === decode(oldVal)) {
+ index = i;
+ break;
+ }
+ }
+ if (index >= 0) {
+ this.deleteQueryParam(key, decode(oldVal)).addQueryParam(key, newVal, index);
+ }
+ } else {
+ for (i = 0; i < len; i++) {
+ param = this.queryPairs[i];
+ if (decode(param[0]) === decode(key)) {
+ index = i;
+ break;
+ }
+ }
+ this.deleteQueryParam(key);
+ this.addQueryParam(key, newVal, index);
+ }
+ return this;
+ };
+
+ /**
+ * Define fluent setter methods (setProtocol, setHasAuthorityPrefix, etc)
+ */
+ ['protocol', 'hasAuthorityPrefix', 'isColonUri', 'userInfo', 'host', 'port', 'path', 'query', 'anchor'].forEach(function(key) {
+ var method = 'set' + key.charAt(0).toUpperCase() + key.slice(1);
+ Uri.prototype[method] = function(val) {
+ this[key](val);
+ return this;
+ };
+ });
+
+ /**
+ * Scheme name, colon and doubleslash, as required
+ * @return {string} http:// or possibly just //
+ */
+ Uri.prototype.scheme = function() {
+ var s = '';
+
+ if (this.protocol()) {
+ s += this.protocol();
+ if (this.protocol().indexOf(':') !== this.protocol().length - 1) {
+ s += ':';
+ }
+ s += '//';
+ } else {
+ if (this.hasAuthorityPrefix() && this.host()) {
+ s += '//';
+ }
+ }
+
+ return s;
+ };
+
+ /**
+ * Same as Mozilla nsIURI.prePath
+ * @return {string} scheme://user:password@host:port
+ * @see https://developer.mozilla.org/en/nsIURI
+ */
+ Uri.prototype.origin = function() {
+ var s = this.scheme();
+
+ if (this.userInfo() && this.host()) {
+ s += this.userInfo();
+ if (this.userInfo().indexOf('@') !== this.userInfo().length - 1) {
+ s += '@';
+ }
+ }
+
+ if (this.host()) {
+ s += this.host();
+ if (this.port() || (this.path() && this.path().substr(0, 1).match(/[0-9]/))) {
+ s += ':' + this.port();
+ }
+ }
+
+ return s;
+ };
+
+ /**
+ * Adds a trailing slash to the path
+ */
+ Uri.prototype.addTrailingSlash = function() {
+ var path = this.path() || '';
+
+ if (path.substr(-1) !== '/') {
+ this.path(path + '/');
+ }
+
+ return this;
+ };
+
+ /**
+ * Serializes the internal state of the Uri object
+ * @return {string}
+ */
+ Uri.prototype.toString = function() {
+ var path, s = this.origin();
+
+ if (this.isColonUri()) {
+ if (this.path()) {
+ s += ':'+this.path();
+ }
+ } else if (this.path()) {
+ path = this.path();
+ if (!(re.ends_with_slashes.test(s) || re.starts_with_slashes.test(path))) {
+ s += '/';
+ } else {
+ if (s) {
+ s.replace(re.ends_with_slashes, '/');
+ }
+ path = path.replace(re.starts_with_slashes, '/');
+ }
+ s += path;
+ } else {
+ if (this.host() && (this.query().toString() || this.anchor())) {
+ s += '/';
+ }
+ }
+ if (this.query().toString()) {
+ s += this.query().toString();
+ }
+
+ if (this.anchor()) {
+ if (this.anchor().indexOf('#') !== 0) {
+ s += '#';
+ }
+ s += this.anchor();
+ }
+
+ return s;
+ };
+
+ /**
+ * Clone a Uri object
+ * @return {Uri} duplicate copy of the Uri
+ */
+ Uri.prototype.clone = function() {
+ return new Uri(this.toString());
+ };
+
+ /**
+ * export via AMD or CommonJS, otherwise leak a global
+ */
+ if (typeof define === 'function' && define.amd) {
+ define(function() {
+ return Uri;
+ });
+ } else if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
+ module.exports = Uri;
+ } else {
+ global.Uri = Uri;
+ }
+}(this));
diff --git a/app/src/main/assets/app_config/js/gadgetbridge_boilerplate.js b/app/src/main/assets/app_config/js/gadgetbridge_boilerplate.js
new file mode 100644
index 0000000..679eeef
--- /dev/null
+++ b/app/src/main/assets/app_config/js/gadgetbridge_boilerplate.js
@@ -0,0 +1,297 @@
+var reportedPositionFailures = 0;
+navigator.geolocation.getCurrentPosition = function(success, failure, options) { //override because default implementation requires GPS permission
+ geoposition = JSON.parse(GBjs.getCurrentPosition());
+
+ if(options && options.maximumAge && (geoposition.timestamp < Date.now() - options.maximumAge) && reportedPositionFailures <= 10 ) {
+ reportedPositionFailures++;
+ failure({ code: 2, message: "POSITION_UNAVAILABLE"});
+ } else {
+ reportedPositionFailures = 0;
+ success(geoposition);
+ }
+};
+
+if (window.Storage){
+ var prefix = GBjs.getAppLocalstoragePrefix();
+ GBjs.gbLog("redefining local storage with prefix: " + prefix);
+
+ Storage.prototype.setItem = (function(key, value) {
+ this.call(localStorage,prefix + key, value);
+ }).bind(Storage.prototype.setItem);
+
+ Storage.prototype.getItem = (function(key) {
+ //GBjs.gbLog("I am about to return " + prefix + key);
+ return this.call(localStorage,prefix + key);
+ }).bind(Storage.prototype.getItem);
+}
+
+function loadScript(url, callback) {
+ // Adding the script tag to the head as suggested before
+ var head = document.getElementsByTagName('head')[0];
+ var script = document.createElement('script');
+ script.type = 'text/javascript';
+ script.src = url;
+
+ // Then bind the event to the callback function.
+ // There are several events for cross browser compatibility.
+ script.onreadystatechange = callback;
+ script.onload = callback;
+
+ // Fire the loading
+ head.appendChild(script);
+}
+
+function getURLVariable(variable, defaultValue) {
+ // Find all URL parameters
+ var query = location.search.substring(1);
+ var vars = query.split('&');
+ for (var i = 0; i < vars.length; i++) {
+ var pair = vars[i].split('=');
+
+ // If the query variable parameter is found, decode it to use and return it for use
+ if (pair[0] === variable) {
+ return decodeURIComponent(pair[1]);
+ }
+ }
+ return defaultValue || false;
+}
+
+function showStep(desiredStep) {
+ var steps = document.getElementsByClassName("step");
+ var testStep = null;
+ for (var i = 0; i < steps.length; i ++) {
+ if (steps[i].id == desiredStep)
+ testStep = steps[i].id;
+ }
+ if (testStep !== null) {
+ for (var i = 0; i < steps.length; i ++) {
+ steps[i].style.display = 'none';
+ }
+ document.getElementById(desiredStep).style.display="block";
+ }
+}
+
+function hideSteps() {
+ var steps = document.getElementsByClassName("step");
+ for (var i = 0; i < steps.length; i ++) {
+ steps[i].style.display = 'none';
+ }
+}
+
+function gbPebble() {
+ this.configurationURL = null;
+ this.configurationValues = null;
+
+ this.appMessageCallbackACK = {};
+ this.appMessageCallbackNACK = {};
+
+ var self = this;
+
+ self.appMessageCallbackProcessed = function (transactionId) {
+ if (self.appMessageCallbackACK[transactionId]) {
+ self.removeEventListener("ACK"+transactionId, self.appMessageCallbackACK[transactionId]);
+ self.appMessageCallbackACK[transactionId] = undefined;
+ }
+ if (self.appMessageCallbackNACK[transactionId]) {
+ self.removeEventListener("NACK"+transactionId, self.appMessageCallbackNACK[transactionId]);
+ self.appMessageCallbackNACK[transactionId] = undefined;
+ }
+ }
+ self.events = {};
+ //events processing: see http://stackoverflow.com/questions/10978311/implementing-events-in-my-own-object
+ self.addEventListener = function(name, handler) {
+ if (self.events.hasOwnProperty(name))
+ self.events[name].push(handler);
+ else
+ self.events[name] = [handler];
+ }
+
+ self.removeEventListener = function(name, handler) {
+ if (!self.events.hasOwnProperty(name))
+ return;
+
+ var index = self.events[name].indexOf(handler);
+ if (index != -1)
+ self.events[name].splice(index, 1);
+ }
+
+ self.evaluate = function(name, args) {
+ if (!self.events.hasOwnProperty(name))
+ return;
+
+ if (!args || !args.length)
+ args = [];
+
+ var evs = self.events[name], l = evs.length;
+ for (var i = 0; i < l; i++) {
+ evs[i].apply(null, args);
+ }
+ GBjs.eventFinished(name);
+ }
+
+ this.actuallyOpenURL = function() {
+ showStep("step1compat");
+ window.open(self.configurationURL.toString(), "config");
+ }
+
+ this.sendConfiguration = function() {
+ GBjs.sendAppMessage(document.getElementById("jsondata").innerHTML, false );
+ showStep("step1");
+ GBActivity.closeActivity();
+ }
+
+ this.savePreset = function() {
+ GBjs.saveAppStoredPreset(self.configurationValues);
+ }
+
+ this.loadPreset = function() {
+ showStep("step2");
+ var presetElements = document.getElementsByClassName("store_presets");
+ for (var i = 0; i < presetElements.length; i ++) {
+ presetElements[i].style.display = 'none';
+ }
+ self.configurationValues = GBjs.getAppStoredPreset();
+ document.getElementById("jsondata").innerHTML=self.configurationValues;
+ }
+
+ //needs to be called like this because of original Pebble function name
+ this.openURL = function(url) {
+ if (url.lastIndexOf("http", 0) === 0) {
+ document.getElementById("config_url").innerHTML=url;
+ var UUID = GBjs.getAppUUID();
+ self.configurationURL = new Uri(url).addQueryParam("return_to", "gadgetbridge://"+UUID+"?config=true&json=");
+ } else {
+ //TODO: add custom return_to
+ var iframe = document.getElementsByTagName('iframe')[0];
+ var oldbody = document.getElementsByTagName("body")[0];
+ if (iframe === undefined && oldbody !== undefined) {
+ iframe = document.createElement("iframe");
+ oldbody.parentNode.replaceChild(iframe,oldbody);
+ } else {
+ hideSteps();
+ document.documentElement.appendChild(iframe);
+ }
+
+ iframe.src = url;
+
+ }
+
+ }
+
+ this.getActiveWatchInfo = function() {
+ return JSON.parse(GBjs.getActiveWatchInfo());
+ }
+
+ this.sendAppMessage = function (dict, callbackAck, callbackNack){
+ try {
+ self.configurationValues = JSON.stringify(dict);
+ if (document.getElementById("step2").style.display == 'block' && document.getElementById("jsondata").innerHTML == "") { //intercept the values
+ document.getElementById("jsondata").innerHTML=self.configurationValues;
+ } else { //pass them silently
+ var needsTransaction = false;
+ if (callbackAck != undefined || callbackNack != undefined) {
+ needsTransaction = true;
+ }
+ var transactionId = GBjs.sendAppMessage(JSON.stringify(dict), needsTransaction);
+ if (needsTransaction) {
+ if (typeof callbackAck != "undefined") {
+ self.appMessageCallbackACK[transactionId] = function(e) {
+// console.log("ACK FOR " + JSON.stringify(e));
+ callbackAck(e);
+ self.appMessageCallbackProcessed(transactionId);
+ };
+
+ this.addEventListener("ACK"+transactionId, self.appMessageCallbackACK[transactionId]);
+
+ }
+ if (typeof callbackNack != "undefined") {
+ self.appMessageCallbackNACK[transactionId] = function(e) {
+// console.log("NACK FOR " + JSON.stringify(e));
+ callbackNack(e);
+ self.appMessageCallbackProcessed(transactionId);
+ };
+
+ this.addEventListener("NACK"+transactionId, self.appMessageCallbackNACK[transactionId]);
+ }
+ }
+ }
+ }
+ catch (e) {
+ GBjs.gbLog("sendAppMessage failed: " + e);
+ }
+ }
+
+ this.getAccountToken = function() {
+ return '';
+ }
+
+ this.getWatchToken = function() {
+ return GBjs.getWatchToken();
+ }
+
+ this.getTimelineToken = function() {
+ return '';
+ }
+
+ this.showSimpleNotificationOnPebble = function(title, body) {
+ GBjs.gbLog("app wanted to show: " + title + " body: "+ body);
+ }
+
+
+ this.showConfiguration = function() {
+ console.error("This watchapp doesn't support configuration");
+ GBActivity.closeActivity();
+ }
+
+ this.parseReturnedPebbleJS = function() {
+ var str = document.getElementById('pastereturn').value;
+ var needle = "pebblejs://close#";
+
+ if (str.split(needle)[1] !== undefined) {
+ var t = new Object();
+ t.response = decodeURIComponent(str.split(needle)[1]);
+ showStep("step2");
+ self.evaluate('webviewclosed',[t]);
+ } else {
+ console.error("No valid configuration found in the entered string.");
+ }
+ }
+}
+
+var Pebble = new gbPebble();
+
+var jsConfigFile = GBjs.getAppConfigurationFile();
+var storedPreset = GBjs.getAppStoredPreset();
+
+document.addEventListener('DOMContentLoaded', function(){
+if (jsConfigFile != null) {
+ loadScript(jsConfigFile, function() {
+ Pebble.evaluate('ready', [{'type': "ready"}]); //callback object apparently needed by some watchfaces
+ if(document.hasFocus() && !(getURLVariable('config') == 'true')) {
+ Pebble.evaluate('showConfiguration');
+ } else {
+ window.onfocus = function () {
+ showStep("step1");
+ GBjs.gbLog("window focused!!!");
+ Pebble.evaluate('showConfiguration');
+ };
+ }
+ if (getURLVariable('config') == 'true') {
+ showStep("step2");
+ var json_string = getURLVariable('json');
+ var t = new Object();
+ t.response = json_string;
+ if (json_string != '') {
+ Pebble.evaluate('webviewclosed',[t]);
+ }
+ } else {
+ if (storedPreset === undefined) {
+ var presetElements = document.getElementsByClassName("load_presets");
+ for (var i = 0; i < presetElements.length; i ++) {
+ presetElements[i].style.display = 'none';
+ }
+ }
+ }
+ });
+}
+}, false);
\ No newline at end of file
diff --git a/app/src/main/assets/buds_pro_case.svg b/app/src/main/assets/buds_pro_case.svg
new file mode 100644
index 0000000..985c459
--- /dev/null
+++ b/app/src/main/assets/buds_pro_case.svg
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="10.399478mm"
+ height="10.338884mm"
+ viewBox="0 0 10.399477 10.338885"
+ version="1.1"
+ id="svg5"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ sodipodi:docname="buds_pro_case.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview7"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="1"
+ fit-margin-top="1"
+ inkscape:zoom="8"
+ inkscape:cx="39.6875"
+ inkscape:cy="37.9375"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1" />
+ <defs
+ id="defs2" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-95.852619,-146.01635)">
+ <path
+ style="fill:#000000;stroke:#000000;stroke-width:0.4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 98.725767,155.07946 c -2.011931,-0.33512 -3.224531,-2.80083 -2.421739,-4.92438 0.698696,-1.8482 1.220768,-2.38464 2.716612,-2.79141 0.622809,-0.16937 3.23294,-0.2015 3.88217,-0.0478 1.27757,0.30245 1.88675,0.72802 2.36901,1.65499 0.70733,1.35957 0.89693,2.22299 0.71935,3.27582 -0.25793,1.52921 -1.29315,2.61077 -2.72307,2.84497 -0.55609,0.0911 -3.977465,0.0819 -4.542333,-0.0122 z m 4.644383,-0.15465 c 0.60321,-0.1105 1.30489,-0.48711 1.65427,-0.88788 0.30716,-0.35233 0.68597,-1.10792 0.76801,-1.53187 l 0.053,-0.27397 -0.22851,0.24421 c -0.33209,0.35491 -0.77469,0.62265 -1.34022,0.81074 -1.13605,0.37784 -5.214307,0.38683 -6.410497,0.0141 -0.55739,-0.17367 -1.193139,-0.55571 -1.433201,-0.86126 l -0.163268,-0.2078 0.04663,0.27396 c 0.07099,0.4171 0.462077,1.19611 0.773559,1.54089 0.330733,0.36608 0.957492,0.72348 1.500185,0.85545 0.505093,0.12283 4.140102,0.14063 4.780022,0.0234 z m 0.5638,-1.66846 c 0.91653,-0.23153 1.68237,-0.77465 1.90019,-1.34755 0.20798,-0.54704 -0.0205,-1.55801 -0.623,-2.75628 -0.4674,-0.9296 -1.05748,-1.37665 -2.19843,-1.66553 -0.67764,-0.17157 -3.410689,-0.14747 -4.065651,0.0359 -1.070946,0.29976 -1.649067,0.76556 -2.081871,1.67737 -0.99471,2.09563 -0.884667,3.03994 0.434692,3.73021 0.829113,0.43377 1.468822,0.5086 4.06904,0.47594 1.56307,-0.0196 2.19526,-0.0566 2.56503,-0.15002 z"
+ id="path1716" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/buds_pro_left.svg b/app/src/main/assets/buds_pro_left.svg
new file mode 100644
index 0000000..3fcbe94
--- /dev/null
+++ b/app/src/main/assets/buds_pro_left.svg
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="10.999999mm"
+ height="10.999998mm"
+ viewBox="0 0 11 10.999998"
+ version="1.1"
+ id="svg4576"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ sodipodi:docname="buds_pro_left.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview4578"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ fit-margin-top="0"
+ fit-margin-bottom="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ inkscape:zoom="16"
+ inkscape:cx="32.09375"
+ inkscape:cy="39.15625"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1"
+ showguides="true"
+ inkscape:guide-bbox="true">
+ <sodipodi:guide
+ position="-47.790315,6.0004159"
+ orientation="0,-1"
+ id="guide34420" />
+ </sodipodi:namedview>
+ <defs
+ id="defs4573" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-93.982649,-153.80574)">
+ <path
+ id="path61748"
+ style="font-size:4.23333px;line-height:124%;font-family:Sans;-inkscape-font-specification:Sans;letter-spacing:0px;word-spacing:0px;stroke:#000000;stroke-width:0.265"
+ d="m 98.636978,156.07032 h 0.367759 v 2.42358 h 1.323583 v 0.31142 h -1.691342 z m 0.315833,7.6524 c 0.356876,-0.14916 0.822437,-0.53623 0.968511,-0.80526 0.07231,-0.13321 0.02551,-0.13439 -0.475169,-0.012 -0.984441,0.24062 -1.681127,0.009 -2.306585,-0.76927 -0.26071,-0.32411 -0.295617,-0.44513 -0.339874,-1.17836 l -0.04933,-0.81715 -0.256509,0.30582 c -0.822265,0.98037 -0.459417,2.6469 0.70147,3.22181 0.48908,0.24221 1.251667,0.26583 1.757481,0.0544 z m -1.330485,0.32864 c -0.541904,-0.12922 -0.867149,-0.32727 -1.225662,-0.74633 -0.606112,-0.70845 -0.727348,-1.77669 -0.299201,-2.6363 0.135175,-0.2714 0.322096,-0.53432 0.415381,-0.58427 0.157826,-0.0845 0.159638,-0.11891 0.0261,-0.49524 -0.352113,-0.99233 -0.20943,-2.51608 0.328134,-3.50425 0.328783,-0.60438 1.0431,-1.2434 1.603455,-1.43443 1.115376,-0.38024 2.679607,0.0425 3.461337,0.93534 1.21161,1.38389 1.52834,3.21746 0.81726,4.73098 -0.23868,0.50801 -1.21551,1.49715 -1.92601,1.95027 -0.34353,0.21907 -0.56429,0.44351 -0.67787,0.68916 -0.371437,0.80334 -1.568126,1.32275 -2.522924,1.09507 z m 2.19911,-1.4967 c 0.978664,-0.37204 2.470754,-1.6715 2.795454,-2.43453 0.78312,-1.84035 -0.11926,-4.30442 -1.87124,-5.10964 -0.806242,-0.37056 -1.788981,-0.3857 -2.470056,-0.038 -1.451067,0.74068 -2.106198,2.85414 -1.464365,4.72405 0.0966,0.28145 0.176572,0.77122 0.177715,1.08843 0.0044,1.23212 0.671638,1.94724 1.817915,1.94848 0.305191,3.3e-4 0.749449,-0.0779 1.014577,-0.17873 z" />
+ <g
+ id="g1961"
+ transform="translate(5.7478236e-5,3.0300929e-4)">
+ <path
+ style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0.4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 99.687906,159.96304 c 0.03116,-0.0812 0.399384,-0.2858 0.818354,-0.45477 0.84931,-0.34253 1.0686,-0.3103 1.0686,0.15703 0,0.24663 -0.0628,0.29752 -0.70948,0.57453 -0.86444,0.37031 -0.952978,0.55914 -1.060626,0.26518 -0.107648,-0.29395 -0.14798,-0.46082 -0.116848,-0.54197 z"
+ id="path1775"
+ sodipodi:nodetypes="ccsccc" />
+ </g>
+ </g>
+</svg>
diff --git a/app/src/main/assets/buds_pro_right.svg b/app/src/main/assets/buds_pro_right.svg
new file mode 100644
index 0000000..cd0075f
--- /dev/null
+++ b/app/src/main/assets/buds_pro_right.svg
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="10.999999mm"
+ height="10.999999mm"
+ viewBox="0 0 11 10.999999"
+ version="1.1"
+ id="svg4576"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ sodipodi:docname="buds_pro_right.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview4578"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ fit-margin-top="0"
+ fit-margin-bottom="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ inkscape:zoom="11.313709"
+ inkscape:cx="36.63697"
+ inkscape:cy="29.831067"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1"
+ showguides="true"
+ inkscape:guide-bbox="true">
+ <sodipodi:guide
+ position="1.2413708,6.0162603"
+ orientation="0,-1"
+ id="guide34430" />
+ </sodipodi:namedview>
+ <defs
+ id="defs4573" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-85.996183,-154.42373)">
+ <g
+ aria-label="R"
+ id="text28286"
+ style="font-style:italic;font-size:4.23333px;line-height:124%;font-family:Sans;-inkscape-font-specification:'Sans Italic';letter-spacing:0px;word-spacing:0px;stroke-width:0.299542px"
+ transform="matrix(0.88023484,0,0,0.88636119,10.922244,18.114874)">
+ <path
+ id="path61769"
+ style="font-size:4.23333px;line-height:124%;font-family:Sans;-inkscape-font-specification:Sans;letter-spacing:0px;word-spacing:0px;stroke:#000000;stroke-width:0.299542px"
+ d="m 91.7518,157.96053 q 0.134359,0.0455 0.260449,0.1943 0.128158,0.14883 0.256315,0.40928 l 0.423747,0.84336 h -0.448552 l -0.394807,-0.79169 q -0.152962,-0.31005 -0.297656,-0.41134 -0.142627,-0.10128 -0.390674,-0.10128 H 90.70587 v 1.30431 h -0.417545 v -3.08612 h 0.942577 q 0.529166,0 0.789615,0.22118 0.260449,0.22117 0.260449,0.66766 0,0.29145 -0.136425,0.48369 -0.134359,0.19223 -0.392741,0.26665 z m -1.04593,-1.29605 v 1.09554 h 0.525032 q 0.30179,0 0.454752,-0.13849 0.15503,-0.14056 0.15503,-0.41134 0,-0.27079 -0.15503,-0.40721 -0.152962,-0.1385 -0.454752,-0.1385 z m 1.432917,8.30872 c -0.405432,-0.16828 -0.934338,-0.60499 -1.100286,-0.9085 -0.08215,-0.15028 -0.02898,-0.15162 0.53982,-0.0135 1.118385,0.27146 1.909867,0.0101 2.620416,-0.8679 0.296193,-0.36567 0.335853,-0.50221 0.386124,-1.32944 l 0.05601,-0.92191 0.291411,0.34503 c 0.934148,1.10606 0.521917,2.98625 -0.796913,3.63487 -0.555624,0.27326 -1.421972,0.29991 -1.996603,0.0614 z m -0.98681,-1.31781 c -1.111824,-0.41974 -2.806926,-1.8858 -3.175801,-2.74666 -0.889675,-2.0763 0.135483,-4.85628 2.125836,-5.76475 0.915942,-0.41805 2.032389,-0.43513 2.806137,-0.0429 1.648492,0.83564 2.39276,3.22007 1.663613,5.32971 -0.109743,0.31753 -0.200605,0.8701 -0.2019,1.22797 -0.0057,1.3901 -0.763024,2.1969 -2.065267,2.19829 -0.346714,3.8e-4 -0.851416,-0.0879 -1.152618,-0.20163 z m 2.498327,1.68859 c 0.615631,-0.14579 0.985134,-0.36923 1.392423,-0.84202 0.688578,-0.79928 0.826314,-2.00447 0.33991,-2.97429 -0.153562,-0.3062 -0.365914,-0.60283 -0.471897,-0.65918 -0.179304,-0.0953 -0.181361,-0.13416 -0.02965,-0.55873 0.400029,-1.11956 0.237925,-2.83867 -0.372776,-3.95353 -0.373514,-0.68188 -1.185013,-1.40282 -1.821627,-1.61833 -1.267129,-0.42899 -3.044181,0.048 -3.932276,1.05525 -1.376461,1.56132 -1.736294,3.62997 -0.928459,5.33754 0.271153,0.57314 1.380895,1.68909 2.188066,2.20031 0.390272,0.24715 0.641063,0.50037 0.770097,0.77751 0.421974,0.90634 1.781479,1.49234 2.86619,1.23547 z" />
+ </g>
+ <g
+ id="g3227"
+ transform="translate(2.1600298e-4,2.9611944e-4)">
+ <path
+ style="fill:#000000;stroke:#000000;stroke-width:0.4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 91.29092,160.58104 c -0.03116,-0.0812 -0.399381,-0.2858 -0.818345,-0.45477 -0.849311,-0.34253 -1.068608,-0.31031 -1.068608,0.15703 0,0.24663 0.06281,0.29752 0.709484,0.57453 0.864443,0.37031 0.947778,0.40745 1.058024,0.18934 0.110245,-0.21811 0.150577,-0.38498 0.119445,-0.46613 z"
+ id="path3121"
+ sodipodi:nodetypes="cssccc" />
+ </g>
+ </g>
+</svg>
diff --git a/app/src/main/assets/calendar_sync.svg b/app/src/main/assets/calendar_sync.svg
new file mode 100644
index 0000000..a64ad7b
--- /dev/null
+++ b/app/src/main/assets/calendar_sync.svg
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
+ sodipodi:docname="calendar_today-24px-1.svg"
+ id="svg6"
+ version="1.1"
+ width="24"
+ viewBox="0 0 24 24"
+ height="24">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ inkscape:current-layer="svg6"
+ inkscape:window-maximized="1"
+ inkscape:window-y="38"
+ inkscape:window-x="0"
+ inkscape:cy="14.262503"
+ inkscape:cx="14.320308"
+ inkscape:zoom="22.185475"
+ showgrid="false"
+ id="namedview8"
+ inkscape:window-height="1042"
+ inkscape:window-width="1920"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0"
+ guidetolerance="10"
+ gridtolerance="10"
+ objecttolerance="10"
+ borderopacity="1"
+ bordercolor="#666666"
+ pagecolor="#ffffff" />
+ <path
+ id="path2"
+ fill="none"
+ d="M0 0h24v24H0V0z" />
+ <path
+ id="path4"
+ d="m 20.018677,3 h -1 V 1 h -2 V 3 H 7.0186774 V 1 h -2 v 2 h -1 c -1.1,0 -2,0.9 -2,2 v 16 c 0,1.1 0.9,2 2,2 H 20.018677 c 1.1,0 2,-0.9 2,-2 V 5 c 0,-1.1 -0.9,-2 -2,-2 z m 0,18 H 4.0186774 V 10 H 20.018677 Z m 0,-13 H 4.0186774 V 5 H 20.018677 Z" />
+ <path
+ style="stroke-width:0.999998"
+ d="m 12.018677,11.629269 v -1.465936 l -1.954583,1.954582 1.954583,1.954582 v -1.465936 c 1.617417,0 2.931873,1.314456 2.931873,2.931873 0,0.493532 -0.122161,0.962632 -0.342052,1.368207 l 0.713423,0.713423 c 0.381143,-0.601034 0.60592,-1.314457 0.60592,-2.08163 0,-2.159814 -1.749351,-3.909165 -3.909164,-3.909165 z m 0,6.841038 c -1.617417,0 -2.9318731,-1.314456 -2.9318731,-2.931873 0,-0.493532 0.1221614,-0.962632 0.3420519,-1.368208 L 8.7154333,13.456804 c -0.3811435,0.601034 -0.6059204,1.314456 -0.6059204,2.08163 0,2.159813 1.7493508,3.909164 3.9091641,3.909164 v 1.465937 l 1.954582,-1.954583 -1.954582,-1.954582 z"
+ id="path4-3" />
+</svg>
diff --git a/app/src/main/assets/devintro.png b/app/src/main/assets/devintro.png
new file mode 100644
index 0000000..859cfde
--- /dev/null
+++ b/app/src/main/assets/devintro.png
Binary files differ
diff --git a/app/src/main/assets/devintro.xmi b/app/src/main/assets/devintro.xmi
new file mode 100644
index 0000000..97ec137
--- /dev/null
+++ b/app/src/main/assets/devintro.xmi
@@ -0,0 +1,175 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<XMI timestamp="2016-01-26T23:02:14" verified="false" xmi.version="1.2" xmlns:UML="http://schema.omg.org/spec/UML/1.3">
+ <XMI.header>
+ <XMI.documentation>
+ <XMI.exporter>umbrello uml modeller http://umbrello.kde.org</XMI.exporter>
+ <XMI.exporterVersion>1.6.9</XMI.exporterVersion>
+ <XMI.exporterEncoding>UnicodeUTF8</XMI.exporterEncoding>
+ </XMI.documentation>
+ <XMI.metamodel xmi.name="UML" href="UML.xml" xmi.version="1.3"/>
+ </XMI.header>
+ <XMI.content>
+ <UML:Model isAbstract="false" isRoot="false" isSpecification="false" name="UML Model" isLeaf="false" xmi.id="m1">
+ <UML:Namespace.ownedElement>
+ <UML:Stereotype isRoot="false" isAbstract="false" isSpecification="false" name="folder" isLeaf="false" namespace="m1" visibility="public" xmi.id="folder"/>
+ <UML:Stereotype isRoot="false" isAbstract="false" isSpecification="false" name="datatype" isLeaf="false" namespace="m1" visibility="public" xmi.id="datatype"/>
+ <UML:Stereotype isRoot="false" isAbstract="false" isSpecification="false" name="interface" isLeaf="false" namespace="m1" visibility="public" xmi.id="interface"/>
+ <UML:Model isRoot="false" isAbstract="false" isSpecification="false" name="Logical View" isLeaf="false" namespace="m1" visibility="public" xmi.id="Logical View">
+ <UML:Namespace.ownedElement>
+ <UML:Package isRoot="false" isAbstract="false" isSpecification="false" name="Datatypes" isLeaf="false" stereotype="folder" namespace="Logical View" visibility="public" xmi.id="Datatypes">
+ <UML:Namespace.ownedElement>
+ <UML:DataType isRoot="false" isAbstract="false" isSpecification="false" name="int" isLeaf="false" stereotype="datatype" namespace="Datatypes" visibility="public" xmi.id="tM1MfT3dGbDn"/>
+ <UML:DataType isRoot="false" isAbstract="false" isSpecification="false" name="char" isLeaf="false" stereotype="datatype" namespace="Datatypes" visibility="public" xmi.id="MZCr2zI6yZo6"/>
+ <UML:DataType isRoot="false" isAbstract="false" isSpecification="false" name="bool" isLeaf="false" stereotype="datatype" namespace="Datatypes" visibility="public" xmi.id="mtFc0pEuADEp"/>
+ <UML:DataType isRoot="false" isAbstract="false" isSpecification="false" name="float" isLeaf="false" stereotype="datatype" namespace="Datatypes" visibility="public" xmi.id="hIRvrcjDBt2B"/>
+ <UML:DataType isRoot="false" isAbstract="false" isSpecification="false" name="double" isLeaf="false" stereotype="datatype" namespace="Datatypes" visibility="public" xmi.id="tIHrr3vdvBFv"/>
+ <UML:DataType isRoot="false" isAbstract="false" isSpecification="false" name="short" isLeaf="false" stereotype="datatype" namespace="Datatypes" visibility="public" xmi.id="yRn8f2wKINF9"/>
+ <UML:DataType isRoot="false" isAbstract="false" isSpecification="false" name="long" isLeaf="false" stereotype="datatype" namespace="Datatypes" visibility="public" xmi.id="xwL6qLHheXWT"/>
+ <UML:DataType isRoot="false" isAbstract="false" isSpecification="false" name="unsigned int" isLeaf="false" stereotype="datatype" namespace="Datatypes" visibility="public" xmi.id="aS7VdKRbkCiP"/>
+ <UML:DataType isRoot="false" isAbstract="false" isSpecification="false" name="unsigned short" isLeaf="false" stereotype="datatype" namespace="Datatypes" visibility="public" xmi.id="EejU1wcwrx2h"/>
+ <UML:DataType isRoot="false" isAbstract="false" isSpecification="false" name="unsigned long" isLeaf="false" stereotype="datatype" namespace="Datatypes" visibility="public" xmi.id="GJMsqsMRIRuv"/>
+ <UML:DataType isRoot="false" isAbstract="false" isSpecification="false" name="string" isLeaf="false" stereotype="datatype" namespace="Datatypes" visibility="public" xmi.id="jLVTWBskpZFo"/>
+ </UML:Namespace.ownedElement>
+ </UML:Package>
+ <UML:Interface isRoot="false" isAbstract="true" isSpecification="false" name="DeviceService" isLeaf="false" stereotype="interface" namespace="Logical View" visibility="public" xmi.id="VA6qSCiBtNc5"/>
+ <UML:Interface isRoot="false" isAbstract="true" isSpecification="false" name="DeviceSupport" isLeaf="false" stereotype="interface" namespace="Logical View" visibility="public" xmi.id="AKxHpDCgOPhk"/>
+ </UML:Namespace.ownedElement>
+ <XMI.extension xmi.extender="umbrello">
+ <diagrams>
+ <diagram showgrid="0" snapgrid="0" backgroundcolor="#ffffff" zoom="100" type="1" linecolor="#ff0000" usefillcolor="1" griddotcolor="#d3d3d3" showops="1" showscope="1" localid="-1" showpackage="1" showpubliconly="0" canvaswidth="0" isopen="0" showatts="1" xmi.id="31Hk7F5Bq9ac" snapcsgrid="0" snapx="25" name="class diagram" showattsig="1" textcolor="#000000" showstereotype="1" showattribassocs="1" linewidth="0" fillcolor="#ffff00" documentation="" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" canvasheight="0" showopsig="1" snapy="25">
+ <widgets/>
+ <messages/>
+ <associations/>
+ </diagram>
+ </diagrams>
+ </XMI.extension>
+ </UML:Model>
+ <UML:Model isRoot="false" isAbstract="false" isSpecification="false" name="Use Case View" isLeaf="false" namespace="m1" visibility="public" xmi.id="Use Case View">
+ <UML:Namespace.ownedElement/>
+ </UML:Model>
+ <UML:Model isRoot="false" isAbstract="false" isSpecification="false" name="Component View" isLeaf="false" namespace="m1" visibility="public" xmi.id="Component View">
+ <UML:Namespace.ownedElement>
+ <UML:Component isRoot="false" isAbstract="false" isSpecification="false" name="Activities" executable="0" isLeaf="false" namespace="Component View" visibility="public" xmi.id="CDLTeFMNZrp5"/>
+ <UML:Component isRoot="false" isAbstract="false" isSpecification="false" name="DeviceCommunicationService" executable="0" isLeaf="false" namespace="Component View" visibility="public" xmi.id="72YBMP5WcQzE">
+ <UML:Namespace.ownedElement>
+ <UML:Port isRoot="false" isAbstract="false" isSpecification="false" name="pin" isLeaf="false" namespace="72YBMP5WcQzE" visibility="public" xmi.id="xhXzgXGeAGP8"/>
+ <UML:Port isRoot="false" isAbstract="false" isSpecification="false" name="pin2" isLeaf="false" namespace="72YBMP5WcQzE" visibility="public" xmi.id="LcpVBJq9yM8d"/>
+ </UML:Namespace.ownedElement>
+ </UML:Component>
+ <UML:Component isRoot="false" isAbstract="false" isSpecification="false" name="Frontend2" executable="0" isLeaf="false" namespace="Component View" visibility="public" xmi.id="zd0ny2K62qHM"/>
+ <UML:Artifact isRoot="false" isAbstract="false" drawas="0" isSpecification="false" name="Service" isLeaf="false" namespace="Component View" visibility="public" xmi.id="jjfTCGwVsjy4"/>
+ <UML:Abstraction supplier="VA6qSCiBtNc5" isSpecification="false" name="" namespace="Component View" client="72YBMP5WcQzE" visibility="public" xmi.id="3ZwKCCl82Cel"/>
+ <UML:Association isSpecification="false" name="invoke" namespace="Component View" visibility="public" xmi.id="ZmBbwqKcFFuU">
+ <UML:Association.connection>
+ <UML:AssociationEnd isNavigable="false" changeability="changeable" isSpecification="false" type="CDLTeFMNZrp5" name="" aggregation="none" visibility="public" xmi.id="8FlknwQC5gyi"/>
+ <UML:AssociationEnd isNavigable="true" changeability="changeable" isSpecification="false" type="VA6qSCiBtNc5" name="" aggregation="none" visibility="public" xmi.id="0RPtmPzL9Au6"/>
+ </UML:Association.connection>
+ </UML:Association>
+ <UML:Component isRoot="false" isAbstract="false" isSpecification="false" name="Concrete Device Impl." executable="0" isLeaf="false" namespace="Component View" visibility="public" xmi.id="tDF2L96KKnVj"/>
+ <UML:Association isSpecification="false" name="invoke" namespace="Component View" visibility="public" xmi.id="60vlquxGX0xa">
+ <UML:Association.connection>
+ <UML:AssociationEnd isNavigable="false" changeability="changeable" isSpecification="false" type="72YBMP5WcQzE" name="" aggregation="none" visibility="public" xmi.id="EElpRDBsrFht"/>
+ <UML:AssociationEnd isNavigable="true" changeability="changeable" isSpecification="false" type="AKxHpDCgOPhk" name="" aggregation="none" visibility="public" xmi.id="zEQUpqXtpTfd"/>
+ </UML:Association.connection>
+ </UML:Association>
+ <UML:Abstraction supplier="AKxHpDCgOPhk" isSpecification="false" name="" namespace="Component View" client="tDF2L96KKnVj" visibility="public" xmi.id="LcusapQo1BbT"/>
+ </UML:Namespace.ownedElement>
+ <XMI.extension xmi.extender="umbrello">
+ <diagrams>
+ <diagram showgrid="0" snapgrid="0" backgroundcolor="#ffffff" zoom="105" type="7" linecolor="#ff0000" usefillcolor="1" griddotcolor="#d3d3d3" showops="1" showscope="1" localid="-1" showpackage="1" showpubliconly="0" canvaswidth="1931" isopen="1" showatts="1" xmi.id="UpkLkm005Mtw" snapcsgrid="0" snapx="25" name="Gadgetbridge" showattsig="1" textcolor="#000000" showstereotype="1" showattribassocs="1" linewidth="0" fillcolor="#ffff00" documentation="" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" canvasheight="1133" showopsig="1" snapy="25">
+ <widgets>
+ <componentwidget textcolor="#000000" fillcolor="#ffff00" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" height="102" linewidth="0" width="165" showstereotype="1" linecolor="none" usefillcolor="1" usesdiagramusefillcolor="0" x="-1652,09756097561" y="-881,8487804878049" localid="2hnpF0djpL2Z" usesdiagramfillcolor="0" xmi.id="CDLTeFMNZrp5" isinstance="0"/>
+ <componentwidget textcolor="#000000" fillcolor="#ffff00" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" height="60" linewidth="0" width="246" showstereotype="1" linecolor="none" usefillcolor="1" usesdiagramusefillcolor="0" x="-1654" y="-645" localid="dMZABODC4z1H" usesdiagramfillcolor="0" xmi.id="72YBMP5WcQzE" isinstance="0"/>
+ <interfacewidget linecolor="none" usefillcolor="1" usesdiagramfillcolor="0" x="-1589,839897589076" isinstance="0" width="40" localid="bZYTIWVHJeGR" showscope="1" height="40" drawascircle="1" showpubliconly="0" showpackage="0" xmi.id="VA6qSCiBtNc5" showattributes="0" textcolor="#000000" showstereotype="1" linewidth="0" usesdiagramusefillcolor="0" fillcolor="#ffff00" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" showopsigs="601" showattsigs="601" showoperations="1" y="-716,9293791337742">
+ <floatingtext linecolor="none" usefillcolor="1" usesdiagramfillcolor="1" x="-91,07317073170748" isinstance="0" width="88" localid="wsTYjTmCdq05" height="19" posttext="" xmi.id="3iFu3ryeOW6f" role="700" textcolor="none" showstereotype="1" linewidth="none" text="DeviceService" usesdiagramusefillcolor="1" fillcolor="none" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" pretext="" y="10,97560975609758"/>
+ </interfacewidget>
+ <componentwidget textcolor="#000000" fillcolor="#ffff00" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" height="60" linewidth="0" width="195" showstereotype="1" linecolor="none" usefillcolor="1" usesdiagramusefillcolor="0" x="-1374,768315059297" y="-548,2509733721189" localid="L9H7YahrGQFd" usesdiagramfillcolor="0" xmi.id="tDF2L96KKnVj" isinstance="0"/>
+ <interfacewidget linecolor="none" usefillcolor="1" usesdiagramfillcolor="0" x="-1320,4" isinstance="0" width="40" localid="Xn4rYXasfszD" showscope="1" height="40" drawascircle="1" showpubliconly="0" showpackage="0" xmi.id="AKxHpDCgOPhk" showattributes="0" textcolor="#000000" showstereotype="1" linewidth="0" usesdiagramusefillcolor="0" fillcolor="#ffff00" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" showopsigs="601" showattsigs="601" showoperations="1" y="-640,1853658536585">
+ <floatingtext linecolor="none" usefillcolor="1" usesdiagramfillcolor="1" x="-6,602439024390151" isinstance="0" width="91" localid="Qe3g5GJDaFUO" height="19" posttext="" xmi.id="Qe4p2lZZXbQ3" role="700" textcolor="none" showstereotype="1" linewidth="none" text="DeviceSupport" usesdiagramusefillcolor="1" fillcolor="none" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" pretext="" y="-15,0634146341464"/>
+ </interfacewidget>
+ </widgets>
+ <messages/>
+ <associations>
+ <assocwidget totalcountb="2" textcolor="none" fillcolor="#ffff00" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" widgetaid="72YBMP5WcQzE" linewidth="none" totalcounta="2" linecolor="none" usefillcolor="1" usesdiagramusefillcolor="94" type="511" indexb="1" seqnum="" usesdiagramfillcolor="0" widgetbid="VA6qSCiBtNc5" xmi.id="3ZwKCCl82Cel" indexa="1">
+ <linepath layout="Polyline">
+ <startpoint startx="-1569,718796671645" starty="-645"/>
+ <endpoint endx="-1569,718796671645" endy="-676,9293791337742"/>
+ </linepath>
+ </assocwidget>
+ <assocwidget totalcountb="2" textcolor="none" fillcolor="#ffff00" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" widgetaid="CDLTeFMNZrp5" linewidth="none" totalcounta="2" linecolor="none" usefillcolor="1" usesdiagramusefillcolor="96" type="512" indexb="1" seqnum="" usesdiagramfillcolor="0" widgetbid="VA6qSCiBtNc5" xmi.id="ZmBbwqKcFFuU" indexa="1">
+ <linepath layout="Polyline">
+ <startpoint startx="-1571,551409184105" starty="-779,8487804878049"/>
+ <endpoint endx="-1571,551409184105" endy="-716,9293791337742"/>
+ </linepath>
+ <floatingtext linecolor="none" usefillcolor="1" usesdiagramfillcolor="1" x="-1567,112384793861" isinstance="0" width="44" localid="yCgBPagYnPIa" height="19" posttext="" xmi.id="LilOMj6M9VGv" role="703" textcolor="none" showstereotype="1" linewidth="none" text="invoke" usesdiagramusefillcolor="1" fillcolor="none" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" pretext="" y="-758,1110310303017"/>
+ </assocwidget>
+ <assocwidget totalcountb="2" textcolor="none" fillcolor="#ffff00" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" widgetaid="72YBMP5WcQzE" linewidth="none" totalcounta="2" linecolor="none" usefillcolor="1" usesdiagramusefillcolor="2" type="512" indexb="1" seqnum="" usesdiagramfillcolor="0" widgetbid="AKxHpDCgOPhk" xmi.id="60vlquxGX0xa" indexa="1">
+ <linepath layout="Polyline">
+ <startpoint startx="-1408" starty="-617,0146341463413"/>
+ <endpoint endx="-1320,4" endy="-617,0146341463413"/>
+ </linepath>
+ <floatingtext linecolor="none" usefillcolor="1" usesdiagramfillcolor="1" x="-1382,614634146342" isinstance="0" width="44" localid="S2UTD5uasDos" height="19" posttext="" xmi.id="BrpNNGMiEFrg" role="703" textcolor="none" showstereotype="1" linewidth="none" text="invoke" usesdiagramusefillcolor="1" fillcolor="none" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" pretext="" y="-613,3439024390243"/>
+ </assocwidget>
+ <assocwidget totalcountb="2" textcolor="none" fillcolor="#ffff00" font="Liberation Sans,10,-1,5,50,0,0,0,0,0" widgetaid="tDF2L96KKnVj" linewidth="none" totalcounta="2" linecolor="none" usefillcolor="1" usesdiagramusefillcolor="53" type="511" indexb="1" seqnum="" usesdiagramfillcolor="0" widgetbid="AKxHpDCgOPhk" xmi.id="LcusapQo1BbT" indexa="1">
+ <linepath layout="Polyline">
+ <startpoint startx="-1299,912195121952" starty="-548,2509733721189"/>
+ <endpoint endx="-1299,912195121952" endy="-600,1853658536585"/>
+ </linepath>
+ </assocwidget>
+ </associations>
+ </diagram>
+ </diagrams>
+ </XMI.extension>
+ </UML:Model>
+ <UML:Model isRoot="false" isAbstract="false" isSpecification="false" name="Deployment View" isLeaf="false" namespace="m1" visibility="public" xmi.id="Deployment View">
+ <UML:Namespace.ownedElement/>
+ </UML:Model>
+ <UML:Model isRoot="false" isAbstract="false" isSpecification="false" name="Entity Relationship Model" isLeaf="false" namespace="m1" visibility="public" xmi.id="Entity Relationship Model">
+ <UML:Namespace.ownedElement/>
+ </UML:Model>
+ </UML:Namespace.ownedElement>
+ </UML:Model>
+ </XMI.content>
+ <XMI.extensions xmi.extender="umbrello">
+ <docsettings viewid="UpkLkm005Mtw" uniqueid="BrpNNGMiEFrg" documentation=""/>
+ <listview>
+ <listitem type="800" id="Views" open="1">
+ <listitem type="821" id="Component View" open="1">
+ <listitem type="822" id="CDLTeFMNZrp5" open="1"/>
+ <listitem type="822" id="tDF2L96KKnVj" open="1"/>
+ <listitem type="822" id="72YBMP5WcQzE" open="1">
+ <listitem type="845" id="xhXzgXGeAGP8" open="1"/>
+ <listitem type="845" id="LcpVBJq9yM8d" open="1"/>
+ </listitem>
+ <listitem type="822" id="zd0ny2K62qHM" open="1"/>
+ <listitem type="819" label="Gadgetbridge" id="UpkLkm005Mtw" open="0"/>
+ <listitem type="824" id="jjfTCGwVsjy4" open="1"/>
+ </listitem>
+ <listitem type="827" id="Deployment View" open="1"/>
+ <listitem type="836" id="Entity Relationship Model" open="1"/>
+ <listitem type="801" id="Logical View" open="1">
+ <listitem type="807" label="class diagram" id="31Hk7F5Bq9ac" open="0"/>
+ <listitem type="830" id="Datatypes" open="0">
+ <listitem type="829" id="mtFc0pEuADEp" open="1"/>
+ <listitem type="829" id="MZCr2zI6yZo6" open="1"/>
+ <listitem type="829" id="tIHrr3vdvBFv" open="1"/>
+ <listitem type="829" id="hIRvrcjDBt2B" open="1"/>
+ <listitem type="829" id="tM1MfT3dGbDn" open="1"/>
+ <listitem type="829" id="xwL6qLHheXWT" open="1"/>
+ <listitem type="829" id="yRn8f2wKINF9" open="1"/>
+ <listitem type="829" id="jLVTWBskpZFo" open="1"/>
+ <listitem type="829" id="aS7VdKRbkCiP" open="1"/>
+ <listitem type="829" id="GJMsqsMRIRuv" open="1"/>
+ <listitem type="829" id="EejU1wcwrx2h" open="1"/>
+ </listitem>
+ <listitem type="817" id="VA6qSCiBtNc5" open="1"/>
+ <listitem type="817" id="AKxHpDCgOPhk" open="1"/>
+ </listitem>
+ <listitem type="802" id="Use Case View" open="1"/>
+ </listitem>
+ </listview>
+ <codegeneration>
+ <codegenerator language="C++"/>
+ </codegeneration>
+ </XMI.extensions>
+</XMI>
diff --git a/app/src/main/assets/filter_alt_plus.svg b/app/src/main/assets/filter_alt_plus.svg
new file mode 100644
index 0000000..691fe9d
--- /dev/null
+++ b/app/src/main/assets/filter_alt_plus.svg
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ enable-background="new 0 0 24 24"
+ height="24"
+ viewBox="0 0 24 24"
+ width="24"
+ version="1.1"
+ id="svg10"
+ sodipodi:docname="filter_alt_plus.svg"
+ inkscape:version="1.0 (4035a4fb49, 2020-05-01)">
+ <metadata
+ id="metadata16">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs14" />
+ <sodipodi:namedview
+ inkscape:document-rotation="0"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1042"
+ id="namedview12"
+ showgrid="false"
+ inkscape:zoom="22.185475"
+ inkscape:cx="11.10688"
+ inkscape:cy="13.735124"
+ inkscape:window-x="0"
+ inkscape:window-y="38"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg10" />
+ <g
+ id="g8">
+ <path
+ d="M0,0h24 M24,24H0"
+ fill="none"
+ id="path2" />
+ <path
+ d="M 5.0390625 4 C 4.2090625 4 3.74 4.949375 4.25 5.609375 C 6.27 8.199375 10 13 10 13 L 10 19 C 10 19.55 10.45 20 11 20 L 13 20 C 13.55 20 14 19.55 14 19 L 14 13 C 14 13 14.561178 12.276632 14.648438 12.164062 A 6.0985484 6.0985484 0 0 1 13.175781 10.810547 L 11.990234 12.300781 L 7 6 L 11.978516 6 A 6.0985484 6.0985484 0 0 1 12.707031 4 L 5.0390625 4 z "
+ id="path4" />
+ <path
+ d="M0,0h24v24H0V0z"
+ fill="none"
+ id="path6" />
+ </g>
+ <g
+ aria-label="+"
+ transform="matrix(2.3775617,0,0,2.3738594,-12.403443,-12.287874)"
+ id="text843"
+ style="font-style:italic;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:6.38504px;line-height:124%;font-family:Sans;-inkscape-font-specification:'Sans Italic';letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.420925px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1">
+ <path
+ d="m 12.25254,6.4481113 v 1.7365562 h 1.736556 V 8.7146757 H 12.25254 V 10.451232 H 11.728767 V 8.7146757 H 9.9922108 V 8.1846675 H 11.728767 V 6.4481113 Z"
+ style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:6.38504px;font-family:Sans;-inkscape-font-specification:'Sans, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:0.420925px"
+ id="path835" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/fossil_hr/battery_layout.json b/app/src/main/assets/fossil_hr/battery_layout.json
new file mode 100644
index 0000000..4d35d4c
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/battery_layout.json
@@ -0,0 +1 @@
+[{"id":0,"type":"complication_background","background":"#background","goal_ring":{"is_enable":"#goal_ring","end_angle":"#fi","is_invert":"#$e"},"dimension":{"type":"rigid","width":"#size.w","height":"#size.h"},"placement":{"type":"absolute","left":"#pos.Ue","top":"#pos.Qe"},"visible":true,"inversion":false},{"id":1,"parent_id":0,"type":"complication_content","icon":"icBattery","text_low":"#ci","dimension":{"type":"rigid","width":76,"height":76},"placement":{"type":"relative"},"visible":true,"inversion":"#$e"},{"id":2,"parent_id":1,"type":"solid","placement":{"type":"absolute","left":29,"top":23},"color":"#nt","dimension":{"type":"rigid","height":6,"width":"#it"},"visible":true,"inversion":false},{"id":3,"parent_id":1,"type":"image","image_name":"icBattCharging","draw_mode":1,"placement":{"type":"absolute","left":34,"top":21},"dimension":{"width":6,"height":9},"visible":"#et","inversion":false}]
diff --git a/app/src/main/assets/fossil_hr/complication_layout.json b/app/src/main/assets/fossil_hr/complication_layout.json
new file mode 100644
index 0000000..42be572
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/complication_layout.json
@@ -0,0 +1 @@
+[{"id":0,"type":"complication_background","background":"#background","goal_ring":{"is_enable":"#goal_ring","end_angle":"#fi","is_invert":"#$e"},"dimension":{"type":"rigid","width":"#size.w","height":"#size.h"},"placement":{"type":"absolute","left":"#pos.Ue","top":"#pos.Qe"},"visible":true,"inversion":false},{"id":1,"parent_id":0,"type":"complication_content","icon":"#icon","text_high":"#dt","text_low":"#ci","dimension":{"type":"rigid","width":76,"height":76},"placement":{"type":"relative"},"visible":true,"inversion":"#$e"}]
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/default_background.png b/app/src/main/assets/fossil_hr/default_background.png
new file mode 100644
index 0000000..f50a37b
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/default_background.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/icActiveMins.rle b/app/src/main/assets/fossil_hr/icActiveMins.rle
new file mode 100644
index 0000000..847f4ab
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icActiveMins.rle
@@ -0,0 +1,3 @@
+!)
+
+ 8��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icBattCharging.rle b/app/src/main/assets/fossil_hr/icBattCharging.rle
new file mode 100644
index 0000000..5f9a848
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icBattCharging.rle
@@ -0,0 +1 @@
+ ��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icBattEmpty.rle b/app/src/main/assets/fossil_hr/icBattEmpty.rle
new file mode 100644
index 0000000..1101489
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icBattEmpty.rle
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/icBattery.rle b/app/src/main/assets/fossil_hr/icBattery.rle
new file mode 100644
index 0000000..f198c75
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icBattery.rle
@@ -0,0 +1 @@
+xz��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icCalories.rle b/app/src/main/assets/fossil_hr/icCalories.rle
new file mode 100644
index 0000000..3c604ab
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icCalories.rle
@@ -0,0 +1,2 @@
+9
+ 6��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icHeart.rle b/app/src/main/assets/fossil_hr/icHeart.rle
new file mode 100644
index 0000000..ef8af6a
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icHeart.rle
@@ -0,0 +1,2 @@
+L
+
;��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icRainChance.rle b/app/src/main/assets/fossil_hr/icRainChance.rle
new file mode 100644
index 0000000..8f77bd9
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icRainChance.rle
@@ -0,0 +1 @@
+S
R��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icSpO2.rle b/app/src/main/assets/fossil_hr/icSpO2.rle
new file mode 100644
index 0000000..3841e25
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icSpO2.rle
@@ -0,0 +1,4 @@
+
+
+
+
��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icSteps.rle b/app/src/main/assets/fossil_hr/icSteps.rle
new file mode 100644
index 0000000..745e082
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icSteps.rle
@@ -0,0 +1,5 @@
+5
+
+
+
+6��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icTrophy.rle b/app/src/main/assets/fossil_hr/icTrophy.rle
new file mode 100644
index 0000000..c53ab42
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icTrophy.rle
@@ -0,0 +1 @@
+2
8��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icWthClearDay.rle b/app/src/main/assets/fossil_hr/icWthClearDay.rle
new file mode 100644
index 0000000..5862d0e
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icWthClearDay.rle
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+#��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icWthClearNite.rle b/app/src/main/assets/fossil_hr/icWthClearNite.rle
new file mode 100644
index 0000000..870f958
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icWthClearNite.rle
@@ -0,0 +1,7 @@
+%
+
+
+
+
+
+
7��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icWthCloudy.rle b/app/src/main/assets/fossil_hr/icWthCloudy.rle
new file mode 100644
index 0000000..63cc91f
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icWthCloudy.rle
@@ -0,0 +1 @@
+h
b��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icWthPartCloudyDay.rle b/app/src/main/assets/fossil_hr/icWthPartCloudyDay.rle
new file mode 100644
index 0000000..66d337d
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icWthPartCloudyDay.rle
@@ -0,0 +1,2 @@
+&
+
2��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icWthPartCloudyNite.rle b/app/src/main/assets/fossil_hr/icWthPartCloudyNite.rle
new file mode 100644
index 0000000..9880060
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icWthPartCloudyNite.rle
@@ -0,0 +1,2 @@
+#
+
2��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icWthRainy.rle b/app/src/main/assets/fossil_hr/icWthRainy.rle
new file mode 100644
index 0000000..693adaa
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icWthRainy.rle
@@ -0,0 +1 @@
+8
<��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icWthSnowy.rle b/app/src/main/assets/fossil_hr/icWthSnowy.rle
new file mode 100644
index 0000000..5d1bf68
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icWthSnowy.rle
@@ -0,0 +1 @@
+
��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icWthStormy.rle b/app/src/main/assets/fossil_hr/icWthStormy.rle
new file mode 100644
index 0000000..0d3ab1f
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icWthStormy.rle
@@ -0,0 +1,2 @@
+9
+53��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/icWthWindy.rle b/app/src/main/assets/fossil_hr/icWthWindy.rle
new file mode 100644
index 0000000..d0697ee
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/icWthWindy.rle
@@ -0,0 +1,2 @@
+8
+
53��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/image_layout.json b/app/src/main/assets/fossil_hr/image_layout.json
new file mode 100644
index 0000000..1ed7322
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/image_layout.json
@@ -0,0 +1 @@
+[{"id":0,"type":"container","direction":1,"main_alignment":1,"cross_alignment":1,"dimension":{"type":"rigid","width":240,"height":240},"placement":{"type":"absolute","left":0,"top":0},"visible":true,"inversion":false},{"id":1,"parent_id":0,"type":"image","image_name":"#name","draw_mode":1,"placement":{"type":"absolute","left":"#pos.Ue","top":"#pos.Qe"},"dimension":{"width":"#size.w","height":"#size.h"},"visible":true,"inversion":false}]
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/menu_layout.json b/app/src/main/assets/fossil_hr/menu_layout.json
new file mode 100644
index 0000000..6be3d62
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/menu_layout.json
@@ -0,0 +1,207 @@
+[
+ {
+ "id": 0,
+ "type": "container",
+ "direction": 1,
+ "main_alignment": 0,
+ "cross_alignment": 1,
+ "dimension": {
+ "type": "rigid",
+ "width": 240,
+ "height": 240
+ },
+ "placement": {
+ "type": "absolute",
+ "left": 0,
+ "top": 0
+ },
+ "visible": true,
+ "inversion": false
+ },
+ {
+ "id": 1,
+ "parent_id": 0,
+ "type": "container",
+ "direction": 1,
+ "main_alignment": 1,
+ "cross_alignment": 2,
+ "dimension": {
+ "type": "rigid",
+ "width": 130,
+ "height": 34
+ },
+ "placement": {
+ "type": "absolute",
+ "left": 75,
+ "top": 45
+ },
+ "visible": true,
+ "inversion": false
+ },
+ {
+ "id": 2,
+ "parent_id": 1,
+ "type": "text",
+ "text": "#top_short_press_label",
+ "ppem": 17,
+ "color": 3,
+ "placement": {
+ "type": "relative"
+ },
+ "visible": true,
+ "inversion": false
+ },
+ {
+ "id": 3,
+ "parent_id": 1,
+ "type": "text",
+ "text": "#top_long_press_label",
+ "ppem": 17,
+ "color": 3,
+ "ascent": 17,
+ "placement": {
+ "type": "relative"
+ },
+ "visible": true,
+ "inversion": false
+ },
+ {
+ "id": 4,
+ "parent_id": 0,
+ "type": "container",
+ "direction": 1,
+ "main_alignment": 1,
+ "cross_alignment": 2,
+ "dimension": {
+ "type": "rigid",
+ "width": 80,
+ "height": 34
+ },
+ "placement": {
+ "type": "absolute",
+ "left": 135,
+ "top": 103
+ },
+ "visible": true,
+ "inversion": false
+ },
+ {
+ "id": 5,
+ "parent_id": 4,
+ "type": "text",
+ "text": "#middle_short_press_label",
+ "ppem": 17,
+ "color": 3,
+ "placement": {
+ "type": "relative"
+ },
+ "visible": true,
+ "inversion": false
+ },
+ {
+ "id": 6,
+ "parent_id": 4,
+ "type": "text",
+ "text": "#middle_long_press_label",
+ "ppem": 17,
+ "color": 3,
+ "ascent": 17,
+ "placement": {
+ "type": "relative"
+ },
+ "visible": true,
+ "inversion": false
+ },
+ {
+ "id": 7,
+ "parent_id": 0,
+ "type": "container",
+ "direction": 1,
+ "main_alignment": 1,
+ "cross_alignment": 2,
+ "dimension": {
+ "type": "rigid",
+ "width": 130,
+ "height": 34
+ },
+ "placement": {
+ "type": "absolute",
+ "left": 75,
+ "top": 161
+ },
+ "visible": true,
+ "inversion": false
+ },
+ {
+ "id": 8,
+ "parent_id": 7,
+ "type": "text",
+ "text": "#bottom_short_press_label",
+ "ppem": 17,
+ "color": 3,
+ "placement": {
+ "type": "relative"
+ },
+ "visible": true,
+ "inversion": false
+ },
+ {
+ "id": 9,
+ "parent_id": 7,
+ "type": "text",
+ "text": "#bottom_long_press_label",
+ "ppem": 17,
+ "color": 3,
+ "ascent": 17,
+ "placement": {
+ "type": "relative"
+ },
+ "visible": true,
+ "inversion": false
+ },
+ {
+ "id": 10,
+ "parent_id": 0,
+ "type": "text",
+ "text": "#menu_title",
+ "ppem": 25,
+ "color": 3,
+ "ascent": 35,
+ "placement": {
+ "type": "relative"
+ },
+ "visible": true,
+ "inversion": false
+ },
+ {
+ "id": 11,
+ "parent_id": 0,
+ "type": "text_page",
+ "text": "#message_to_display",
+ "ppem": 17,
+ "color": 3,
+ "ascent": 17,
+ "cross_alignment": 2,
+ "line_width": [
+ 80,
+ 85,
+ 90,
+ 90,
+ 85,
+ 80
+ ],
+ "dimension": {
+ "type": "rigid",
+ "width": 80,
+ "height": 100
+ },
+ "placement": {
+ "type": "absolute",
+ "left": 20,
+ "top": 60
+ },
+ "visible": true,
+ "inversion": false
+ }
+ ]
+
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/navigationApp.wapp b/app/src/main/assets/fossil_hr/navigationApp.wapp
new file mode 100644
index 0000000..d581a7f
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/navigationApp.wapp
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/openSourceWatchface.bin b/app/src/main/assets/fossil_hr/openSourceWatchface.bin
new file mode 100644
index 0000000..6f444e6
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/openSourceWatchface.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widget2ndTZ.bin b/app/src/main/assets/fossil_hr/widget2ndTZ.bin
new file mode 100644
index 0000000..4291447
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widget2ndTZ.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widget2ndTZ_preview.png b/app/src/main/assets/fossil_hr/widget2ndTZ_preview.png
new file mode 100644
index 0000000..0879bac
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widget2ndTZ_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetActiveMins.bin b/app/src/main/assets/fossil_hr/widgetActiveMins.bin
new file mode 100644
index 0000000..8972ee5
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetActiveMins.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetActiveMins_preview.png b/app/src/main/assets/fossil_hr/widgetActiveMins_preview.png
new file mode 100644
index 0000000..d99afe3
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetActiveMins_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetBattery.bin b/app/src/main/assets/fossil_hr/widgetBattery.bin
new file mode 100644
index 0000000..ccf4540
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetBattery.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetBattery_preview.png b/app/src/main/assets/fossil_hr/widgetBattery_preview.png
new file mode 100644
index 0000000..801e8bf
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetBattery_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetCalories.bin b/app/src/main/assets/fossil_hr/widgetCalories.bin
new file mode 100644
index 0000000..e15ba04
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetCalories.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetCalories_preview.png b/app/src/main/assets/fossil_hr/widgetCalories_preview.png
new file mode 100644
index 0000000..e703877
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetCalories_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetChanceOfRain.bin b/app/src/main/assets/fossil_hr/widgetChanceOfRain.bin
new file mode 100644
index 0000000..ba16b7b
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetChanceOfRain.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetChanceOfRain_preview.png b/app/src/main/assets/fossil_hr/widgetChanceOfRain_preview.png
new file mode 100644
index 0000000..c45fe90
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetChanceOfRain_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetCustom.bin b/app/src/main/assets/fossil_hr/widgetCustom.bin
new file mode 100644
index 0000000..b41fb05
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetCustom.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetCustom_preview.png b/app/src/main/assets/fossil_hr/widgetCustom_preview.png
new file mode 100644
index 0000000..abdc909
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetCustom_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetDate.bin b/app/src/main/assets/fossil_hr/widgetDate.bin
new file mode 100644
index 0000000..aeb1440
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetDate.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetDate_preview.png b/app/src/main/assets/fossil_hr/widgetDate_preview.png
new file mode 100644
index 0000000..afca5ee
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetDate_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetHR.bin b/app/src/main/assets/fossil_hr/widgetHR.bin
new file mode 100644
index 0000000..2cf4965
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetHR.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetHR_preview.png b/app/src/main/assets/fossil_hr/widgetHR_preview.png
new file mode 100644
index 0000000..5e50907
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetHR_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetSpO2.bin b/app/src/main/assets/fossil_hr/widgetSpO2.bin
new file mode 100644
index 0000000..78bb18e
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetSpO2.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetSpO2_preview.png b/app/src/main/assets/fossil_hr/widgetSpO2_preview.png
new file mode 100644
index 0000000..0638b80
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetSpO2_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetSteps.bin b/app/src/main/assets/fossil_hr/widgetSteps.bin
new file mode 100644
index 0000000..c7ea156
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetSteps.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetSteps_preview.png b/app/src/main/assets/fossil_hr/widgetSteps_preview.png
new file mode 100644
index 0000000..6bcf611
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetSteps_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetUV.bin b/app/src/main/assets/fossil_hr/widgetUV.bin
new file mode 100644
index 0000000..0947a8d
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetUV.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetUV_preview.png b/app/src/main/assets/fossil_hr/widgetUV_preview.png
new file mode 100644
index 0000000..38b25d8
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetUV_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetWeather.bin b/app/src/main/assets/fossil_hr/widgetWeather.bin
new file mode 100644
index 0000000..7cd0913
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetWeather.bin
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widgetWeather_preview.png b/app/src/main/assets/fossil_hr/widgetWeather_preview.png
new file mode 100644
index 0000000..b6d54f4
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widgetWeather_preview.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widget_bg_dashed_circle.png b/app/src/main/assets/fossil_hr/widget_bg_dashed_circle.png
new file mode 100644
index 0000000..ef10529
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widget_bg_dashed_circle.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widget_bg_double_circle.png b/app/src/main/assets/fossil_hr/widget_bg_double_circle.png
new file mode 100644
index 0000000..ec18cf8
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widget_bg_double_circle.png
Binary files differ
diff --git a/app/src/main/assets/fossil_hr/widget_bg_error.rle b/app/src/main/assets/fossil_hr/widget_bg_error.rle
new file mode 100644
index 0000000..0c34028
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widget_bg_error.rle
@@ -0,0 +1,15 @@
+LLi
+
6
0 , ( $ $
!
+
+
"
&
%
#
+ !
+
+
+
+
+
+
!
#
+
%
&
"
+
+
!
$ $ ( , 0
6
+
i��
\ No newline at end of file
diff --git a/app/src/main/assets/fossil_hr/widget_bg_thin_circle.png b/app/src/main/assets/fossil_hr/widget_bg_thin_circle.png
new file mode 100644
index 0000000..1fec781
--- /dev/null
+++ b/app/src/main/assets/fossil_hr/widget_bg_thin_circle.png
Binary files differ
diff --git a/app/src/main/assets/galaxy_buds.svg b/app/src/main/assets/galaxy_buds.svg
new file mode 100644
index 0000000..1c3dd35
--- /dev/null
+++ b/app/src/main/assets/galaxy_buds.svg
@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="35.69236mm"
+ height="42.231304mm"
+ viewBox="0 0 35.69236 42.231304"
+ version="1.1"
+ id="svg5"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ sodipodi:docname="galaxy_buds.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview7"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ inkscape:zoom="2.5407127"
+ inkscape:cx="24.599397"
+ inkscape:cy="100.56234"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1" />
+ <defs
+ id="defs2" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-58.009475,-114.68386)">
+ <g
+ id="g11302">
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 62.481984,130.19859 c -1.422876,1.97773 -0.400592,5.02874 3.539949,5.03091"
+ id="path10585"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 66.103138,121.7957 c 2.666757,-1.07007 4.567397,-1.03074 7.019236,-0.32613 1.148282,0.32999 2.049576,1.68227 2.142569,2.8734 0.428995,5.49489 -2.903496,8.17341 -6.263367,7.93085 -2.05934,-0.14867 -4.130587,-2.74489 -4.627575,-5.01288 -0.41816,-1.90826 -0.08389,-4.73774 1.729137,-5.46524 z"
+ id="path5256"
+ sodipodi:nodetypes="ssssss" />
+ <path
+ style="fill:none;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 68.424274,119.2465 c 3.534371,-0.50931 7.153563,0.99406 8.625608,4.36799 1.972926,4.52216 -0.871682,11.86623 -6.124093,12.64208 -9.667201,1.42779 -12.807218,-15.52506 -2.501515,-17.01007"
+ id="path3567" />
+ <path
+ style="fill:none;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 70.54094,115.0684 c -1.528272,0.40147 -2.479848,2.50023 -3.65606,3.48042 -1.57278,1.31064 -3.329209,2.02976 -4.365471,3.91113 -0.788477,1.43151 -0.572308,3.47576 -1.409777,4.73562 -0.43788,0.65873 -1.309196,0.96445 -1.803823,1.61735 -1.540471,2.03338 -1.370542,5.10243 0.479425,6.88871 1.219739,1.17764 3.141402,1.6612 4.790555,1.28077 0.702868,-0.16221 1.380432,-0.74583 2.116667,-0.68541 1.719445,0.14105 2.786803,0.97944 4.618181,0.50261 6.467763,-1.6839 8.890577,-9.24373 7.458556,-15.10934 -0.757382,-3.10205 -4.540443,-7.59068 -8.228253,-6.62186"
+ id="path3559" />
+ </g>
+ <g
+ id="g11302-3"
+ transform="matrix(-1,0,0,1,151.71131,19.553843)">
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 62.481984,130.19859 c -1.422876,1.97773 -0.400592,5.02874 3.539949,5.03091"
+ id="path10585-6"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 66.103138,121.7957 c 2.666757,-1.07007 4.567397,-1.03074 7.019236,-0.32613 1.148282,0.32999 2.049576,1.68227 2.142569,2.8734 0.428995,5.49489 -2.903496,8.17341 -6.263367,7.93085 -2.05934,-0.14867 -4.130587,-2.74489 -4.627575,-5.01288 -0.41816,-1.90826 -0.08389,-4.73774 1.729137,-5.46524 z"
+ id="path5256-7"
+ sodipodi:nodetypes="ssssss" />
+ <path
+ style="fill:none;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 68.424274,119.2465 c 3.534371,-0.50931 7.153563,0.99406 8.625608,4.36799 1.972926,4.52216 -0.871682,11.86623 -6.124093,12.64208 -9.667201,1.42779 -12.807218,-15.52506 -2.501515,-17.01007"
+ id="path3567-5" />
+ <path
+ style="fill:none;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 70.54094,115.0684 c -1.528272,0.40147 -2.479848,2.50023 -3.65606,3.48042 -1.57278,1.31064 -3.329209,2.02976 -4.365471,3.91113 -0.788477,1.43151 -0.572308,3.47576 -1.409777,4.73562 -0.43788,0.65873 -1.309196,0.96445 -1.803823,1.61735 -1.540471,2.03338 -1.370542,5.10243 0.479425,6.88871 1.219739,1.17764 3.141402,1.6612 4.790555,1.28077 0.702868,-0.16221 1.380432,-0.74583 2.116667,-0.68541 1.719445,0.14105 2.786803,0.97944 4.618181,0.50261 6.467763,-1.6839 8.890577,-9.24373 7.458556,-15.10934 -0.757382,-3.10205 -4.540443,-7.59068 -8.228253,-6.62186"
+ id="path3559-3" />
+ </g>
+ </g>
+</svg>
diff --git a/app/src/main/assets/gps_edit.svg b/app/src/main/assets/gps_edit.svg
new file mode 100644
index 0000000..f189dbf
--- /dev/null
+++ b/app/src/main/assets/gps_edit.svg
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ height="24"
+ viewBox="0 0 23.999999 24"
+ width="24"
+ fill="#000000"
+ version="1.1"
+ id="svg1360"
+ sodipodi:docname="gps_edit.svg"
+ inkscape:version="1.0.2 (e86c870879, 2021-01-15)">
+ <metadata
+ id="metadata1366">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs1364" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1042"
+ id="namedview1362"
+ showgrid="false"
+ inkscape:zoom="22.627417"
+ inkscape:cx="16.91793"
+ inkscape:cy="12.554585"
+ inkscape:window-x="0"
+ inkscape:window-y="38"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg1360"
+ inkscape:document-rotation="0" />
+ <path
+ d="M 0,0 H 24 V 23.999999 H 0 Z"
+ fill="none"
+ id="path1356" />
+ <path
+ id="path1358"
+ d="M 11 1 L 11 3.0605469 C 6.83 3.5205469 3.5205469 6.8300004 3.0605469 11 L 1 11 L 1 13 L 3.0605469 13 C 3.1662398 13.958129 3.428073 14.867751 3.8105469 15.710938 L 5.3476562 14.171875 C 5.1249836 13.487952 5 12.758847 5 12 C 5 8.1300004 8.13 5 12 5 C 12.757962 5 13.486688 5.1235288 14.169922 5.3457031 L 15.707031 3.8085938 C 14.864949 3.4271153 13.956627 3.166074 13 3.0605469 L 13 1 L 11 1 z M 20.1875 8.2851562 L 18.650391 9.8203125 C 18.874661 10.50644 19 11.238321 19 12 C 19 15.869999 15.87 19 12 19 C 11.235489 19 10.500828 18.87431 9.8125 18.648438 L 8.2753906 20.183594 C 9.1223377 20.569471 10.036747 20.833195 11 20.939453 L 11 23 L 13 23 L 13 20.939453 C 17.17 20.479453 20.479453 17.17 20.939453 13 L 23 13 L 23 11 L 20.939453 11 C 20.833594 10.040369 20.57097 9.1294462 20.1875 8.2851562 z " />
+ <path
+ d="M 4.6773789,-4.5166116 H 28.677379 V 19.483388 H 4.6773789 Z"
+ fill="none"
+ id="path1344" />
+ <path
+ d="m 14.05875,9.0199994 0.92,0.92 -9.0600003,9.0599996 h -0.92 v -0.92 L 14.05875,9.0199993 m 3.6,-6.0199997 c -0.25,0 -0.51,0.1 -0.7,0.29 l -1.83,1.83 3.75,3.7499997 1.83,-1.8299997 c 0.39,-0.39 0.39,-1.02 0,-1.41 l -2.34,-2.34 c -0.2,-0.2 -0.45,-0.29 -0.71,-0.29 z m -3.6,3.19 L 2.9987497,17.249999 v 3.75 h 3.75 L 17.80875,9.9399994 Z"
+ id="path948" />
+</svg>
diff --git a/app/src/main/assets/ic_activity_bike_trainer.svg b/app/src/main/assets/ic_activity_bike_trainer.svg
new file mode 100644
index 0000000..9c4cebc
--- /dev/null
+++ b/app/src/main/assets/ic_activity_bike_trainer.svg
@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
+ sodipodi:docname="ic_activity_bike_trainer.svg"
+ version="1.1"
+ id="vector"
+ viewBox="0 0 24 24">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6" />
+ <sodipodi:namedview
+ inkscape:current-layer="vector"
+ inkscape:window-maximized="1"
+ inkscape:window-y="38"
+ inkscape:window-x="0"
+ inkscape:cy="10.532374"
+ inkscape:cx="4.6563454"
+ inkscape:zoom="22.185475"
+ inkscape:guide-bbox="true"
+ showguides="true"
+ showgrid="false"
+ id="namedview4"
+ inkscape:window-height="1042"
+ inkscape:window-width="1920"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0"
+ guidetolerance="10"
+ gridtolerance="10"
+ objecttolerance="10"
+ borderopacity="1"
+ bordercolor="#666666"
+ pagecolor="#ffffff">
+ <sodipodi:guide
+ id="guide833"
+ orientation="0,-1"
+ position="12.722976,2.3032605" />
+ </sodipodi:namedview>
+ <path
+ sodipodi:nodetypes="cccccsccscccccccsssss"
+ fill="#000000"
+ d="m 13.748344,10.511707 h 4.2 V 8.7117075 h -3.2 l -1.94,-3.27 c -0.29,-0.5 -0.86,-0.83 -1.46,-0.83 -0.47,0 -0.9,0.19 -1.2,0.5 l -3.6999997,3.69 c -0.31,0.31 -0.5,0.71 -0.5,1.2099995 0,0.63 0.33,1.16 0.85,1.47 l 3.3499997,2.03 0.08147,2.922976 h 1.8 l -0.08147,-4.422976 -2.2499997,-1.65 2.3199997,-2.3499995 m 2.93,-2.7 c 1,0 1.8,-0.8 1.8,-1.8 0,-1 -0.8,-1.8 -1.8,-1.8 -1,0 -1.8,0.8 -1.8,1.8 0,1 0.8,1.8 1.8,1.8 z"
+ id="path" />
+ <path
+ sodipodi:nodetypes="ccccc"
+ id="path835"
+ d="M 1.8662052,21.696739 H 21.648561 l -6.442351,-10.036462 3.446621,-0.06429 1.618603,-1.9916461"
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+ <path
+ id="path837"
+ d="M 4.4629733,21.696739 16.703565,14.024667"
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+ <circle
+ r="2.5"
+ cy="17.809311"
+ cx="19.149721"
+ id="path855"
+ style="opacity:1;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.997028;paint-order:fill markers stroke" />
+ <path
+ id="path857"
+ d="m 5.832492,11.992861 2.739362,0.106092"
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+ <circle
+ r="1"
+ style="opacity:1;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.997028;paint-order:fill markers stroke"
+ id="path855-3"
+ cx="9.7781563"
+ cy="18.29324" />
+</svg>
diff --git a/app/src/main/assets/ic_activity_eliptical.svg b/app/src/main/assets/ic_activity_eliptical.svg
new file mode 100644
index 0000000..91809b1
--- /dev/null
+++ b/app/src/main/assets/ic_activity_eliptical.svg
@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
+ sodipodi:docname="ic_activity_eliptical.svg"
+ version="1.1"
+ id="vector"
+ viewBox="0 0 24 24">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6" />
+ <sodipodi:namedview
+ inkscape:current-layer="vector"
+ inkscape:window-maximized="1"
+ inkscape:window-y="38"
+ inkscape:window-x="0"
+ inkscape:cy="18.099999"
+ inkscape:cx="19.838246"
+ inkscape:zoom="15.6875"
+ inkscape:guide-bbox="true"
+ showguides="true"
+ showgrid="false"
+ id="namedview4"
+ inkscape:window-height="1042"
+ inkscape:window-width="1920"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0"
+ guidetolerance="10"
+ gridtolerance="10"
+ objecttolerance="10"
+ borderopacity="1"
+ bordercolor="#666666"
+ pagecolor="#ffffff">
+ <sodipodi:guide
+ id="guide835"
+ orientation="0,-1"
+ position="10.705202,0.53870408" />
+ <sodipodi:guide
+ id="guide837"
+ orientation="1,0"
+ position="0.48455126,0.20064505" />
+ </sodipodi:namedview>
+ <path
+ sodipodi:nodetypes="cssscccccccccccccsccccc"
+ style="stroke-width:0.999999"
+ fill="#000000"
+ d="M 12.567421,5.2119378 C 13.537916,5.3830625 14.492582,4.7067939 14.662138,3.7452018 14.837972,2.7479951 14.165897,1.8216099 13.195401,1.6504853 12.207098,1.4762208 11.276519,2.1200148 11.100686,3.1172216 10.93113,4.0788137 11.579118,5.0376734 12.567421,5.2119378 M 7.1741201,17.003424 8.7521215,13.26063 l 1.5646755,2.111986 -0.9419703,5.342178 1.7807263,0.31399 1.177463,-6.677723 -1.564676,-2.111986 1.014106,-2.5753205 c 0.913076,1.5380665 4.42584,0.3836265 6.360238,-0.3904418 l -0.0021,-1.7019002 C 16.393083,8.0559499 13.64167,9.5325619 13.143408,8.1410804 L 12.501098,6.5773115 C 12.242289,5.9624884 11.76773,5.5299534 11.153379,5.4216269 10.877367,5.3729584 10.695638,5.4143579 10.419625,5.3656903 8.9321383,5.7066865 7.2727228,11.675325 6.034151,14.617469 l -3.093174,7.077475 1.9665017,0.144008 z"
+ id="path" />
+ <path
+ sodipodi:nodetypes="ccc"
+ id="path839"
+ d="M 21.159329,22.868639 17.313949,11.569004 21.332334,6.8627307"
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="M 22.604212,23.183313 15.844554,11.825186 19.319953,5.7636432"
+ id="path839-3"
+ sodipodi:nodetypes="ccc" />
+ <path
+ sodipodi:nodetypes="cc"
+ id="path874"
+ d="M 22.604212,23.183313 7.1729532,20.181342"
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+ <path
+ sodipodi:nodetypes="cc"
+ id="path876"
+ d="M 22.604212,23.183313 2.6116474,22.118514"
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+</svg>
diff --git a/app/src/main/assets/ic_activity_rope_jump.svg b/app/src/main/assets/ic_activity_rope_jump.svg
new file mode 100644
index 0000000..05f0ddf
--- /dev/null
+++ b/app/src/main/assets/ic_activity_rope_jump.svg
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
+ sodipodi:docname="ic_activity_rope_jump.svg"
+ version="1.1"
+ id="vector"
+ viewBox="0 0 24 24">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6" />
+ <sodipodi:namedview
+ inkscape:current-layer="vector"
+ inkscape:window-maximized="1"
+ inkscape:window-y="38"
+ inkscape:window-x="0"
+ inkscape:cy="14.667682"
+ inkscape:cx="11.804698"
+ inkscape:zoom="22.185475"
+ inkscape:guide-bbox="true"
+ showguides="true"
+ showgrid="false"
+ id="namedview4"
+ inkscape:window-height="1042"
+ inkscape:window-width="1920"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0"
+ guidetolerance="10"
+ gridtolerance="10"
+ objecttolerance="10"
+ borderopacity="1"
+ bordercolor="#666666"
+ pagecolor="#ffffff">
+ <sodipodi:guide
+ id="guide833"
+ orientation="1,0"
+ position="5.9920319,5.2589641" />
+ <sodipodi:guide
+ id="guide835"
+ orientation="1,0"
+ position="7.7768924,2.7729084" />
+ <sodipodi:guide
+ id="guide837"
+ orientation="0,-1"
+ position="8.3187251,10.199203" />
+ <sodipodi:guide
+ id="guide841"
+ orientation="1,0"
+ position="10.533068,1.904977" />
+ <sodipodi:guide
+ id="guide843"
+ orientation="0,-1"
+ position="5.9920319,16.953232" />
+ <sodipodi:guide
+ id="guide845"
+ orientation="0,-1"
+ position="8.1912351,20.270916" />
+ <sodipodi:guide
+ id="guide847"
+ orientation="0,-1"
+ position="15.618327,4.7080987" />
+ </sodipodi:namedview>
+ <path
+ sodipodi:nodetypes="ccccscccccccccccccccsssss"
+ fill="#000000"
+ d="M 18.984981,12.410176 19.73807,11.048671 16.950341,9.4438816 14.113239,5.3276854 c -0.3,-0.5 -1.911998,-0.4887596 -2.531878,-0.5009799 -0.640961,-0.012636 -1.508142,0.0073 -2.0091581,0.5009799 L 7.4123098,9.2551763 4.157218,11.49146 5.1898016,12.474086 8.7091471,10.546488 9.5722029,8.6453695 9.5407575,22.105654 l 1.7848605,0.01321 v -8.318067 h 0.956175 l 0.05554,5.491104 h 1.800001 l -0.05554,-5.491099 0.03144,-5.1554326 1.32336,1.7364416 M 11.688422,4.3955121 c 1,0 1.8,-0.8 1.8,-1.8 0,-1 -0.8,-1.79999996 -1.8,-1.79999996 -1,0 -1.7999996,0.79999996 -1.7999996,1.79999996 0,1 0.7999996,1.8 1.7999996,1.8 z"
+ id="path" />
+ <path
+ sodipodi:nodetypes="czc"
+ id="path851"
+ d="m 4.4844489,12.560347 c 0,0 3.9068788,7.966617 7.7302321,8.362194 3.823353,0.395577 7.636681,-8.779329 7.636681,-8.779329"
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+</svg>
diff --git a/app/src/main/assets/ic_activity_threadmill.svg b/app/src/main/assets/ic_activity_threadmill.svg
new file mode 100644
index 0000000..ad5cff2
--- /dev/null
+++ b/app/src/main/assets/ic_activity_threadmill.svg
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
+ sodipodi:docname="ic_activity_threadmill.svg"
+ version="1.1"
+ id="vector"
+ viewBox="0 0 24 24">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6" />
+ <sodipodi:namedview
+ inkscape:current-layer="vector"
+ inkscape:window-maximized="1"
+ inkscape:window-y="38"
+ inkscape:window-x="0"
+ inkscape:cy="18.620603"
+ inkscape:cx="19.661134"
+ inkscape:zoom="15.6875"
+ inkscape:guide-bbox="true"
+ showguides="true"
+ showgrid="false"
+ id="namedview4"
+ inkscape:window-height="1042"
+ inkscape:window-width="1920"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0"
+ guidetolerance="10"
+ gridtolerance="10"
+ objecttolerance="10"
+ borderopacity="1"
+ bordercolor="#666666"
+ pagecolor="#ffffff">
+ <sodipodi:guide
+ id="guide835"
+ orientation="0,-1"
+ position="10.705202,0.53870408" />
+ <sodipodi:guide
+ id="guide837"
+ orientation="1,0"
+ position="0.48455126,0.20064505" />
+ </sodipodi:namedview>
+ <path
+ sodipodi:nodetypes="cssscccccccccccccscccccccccc"
+ style="stroke-width:0.999999"
+ fill="#000000"
+ d="M 12.567421,5.2119378 C 13.537916,5.3830625 14.492582,4.7067939 14.662138,3.7452018 14.837972,2.7479951 14.165897,1.8216099 13.195401,1.6504853 12.207098,1.4762208 11.276519,2.1200148 11.100686,3.1172216 10.93113,4.0788137 11.579118,5.0376734 12.567421,5.2119378 M 7.1741201,17.003424 8.7521215,13.26063 l 1.5646755,2.111986 -0.9419703,5.342178 1.7807263,0.31399 1.177463,-6.677723 -1.564676,-2.111986 1.014106,-2.5753205 c 0.913076,1.5380665 4.42584,0.3836265 6.360238,-0.3904418 l -0.0021,-1.7019002 C 16.393083,8.0559499 13.64167,9.5325619 13.143408,8.1410804 L 12.501098,6.5773115 C 12.242289,5.9624884 11.76773,5.5299534 11.153379,5.4216269 10.877367,5.3729584 10.695638,5.4143579 10.419625,5.3656903 L 5.4532525,6.5096846 4.712236,10.712199 6.4929622,11.026189 7.0298852,7.9811466 8.7335316,7.6389135 6.034151,14.617469 1.8283669,12.95783 1.1582315,14.67576 Z"
+ id="path" />
+ <path
+ sodipodi:nodetypes="ccccc"
+ id="path833"
+ d="M 0.48455126,22.642102 20.905809,19.702714 23.383671,23.461296 H 0.48455126 Z"
+ style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+ <path
+ sodipodi:nodetypes="ccc"
+ id="path839"
+ d="m 22.604212,23.183313 -6.253574,-9.471005 3.452827,-1.399809"
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+</svg>
diff --git a/app/src/main/assets/ic_device_flipper.svg b/app/src/main/assets/ic_device_flipper.svg
new file mode 100644
index 0000000..1712dcc
--- /dev/null
+++ b/app/src/main/assets/ic_device_flipper.svg
@@ -0,0 +1,1772 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_flipper.svg"
+ inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="22.627418"
+ inkscape:cx="16.793785"
+ inkscape:cy="13.589708"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer2"
+ inkscape:pagecheckerboard="0"
+ inkscape:showpageshadow="2"
+ inkscape:deskcolor="#d1d1d1">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#1f7fdb;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#4dabf5;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline">
+ <rect
+ style="fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ <g
+ id="dolphin_00000004540763674616766980000002934822554439179943_"
+ transform="matrix(0.41463415,0,0,0.41463415,-16.349253,2.403196)"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <g
+ id="g1524"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="87.2,36.4 87.8,36.4 87.8,35.8 87.2,35.8 86.7,35.8 86.1,35.8 85.6,35.8 85,35.8 85,36.4 85.6,36.4 86.1,36.4 86.7,36.4 "
+ id="polygon1522"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1528"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="69.5,36.3 70,36.3 70.6,36.3 70.6,35.8 70,35.8 69.5,35.8 "
+ id="polygon1526"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1532"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="59"
+ y="35.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.9529,95.0224)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1530"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1536"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="57.799999"
+ y="35.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8553,93.9078)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1534"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1540"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="56.700001"
+ y="35.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,20.7578,92.7932)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1538"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1544"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="87.8,35.3 87.8,35.8 88.3,35.8 88.9,35.9 89.5,35.9 90,35.9 90,35.3 89.5,35.3 88.9,35.3 88.3,35.3 88.3,34.7 87.8,34.7 87.2,34.7 87.2,35.3 "
+ id="polygon1542"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1548"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="68.900002"
+ y="35.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,33.3884,104.5052)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1546"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1552"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="59.5"
+ y="35.099998"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.059,95.031)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1550"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1556"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="58.400002"
+ y="35.099998"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.9614,93.9163)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1554"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1560"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="57.299999"
+ y="35.099998"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8638,92.8017)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1558"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1564"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="56.200001"
+ y="35.099998"
+ transform="matrix(0.00513464,-1,1,0.00513464,20.7663,91.6871)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1562"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1568"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="91.1,35.3 91.7,35.3 91.7,34.8 91.1,34.8 90.6,34.8 90,34.7 90,35.3 90.6,35.3 "
+ id="polygon1566"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1572"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="60.099998"
+ y="34.599998"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.1651,95.0395)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1570"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1576"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="59"
+ y="34.599998"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.0675,93.9249)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1574"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1580"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="57.900002"
+ y="34.599998"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.9699,92.8102)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1578"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1584"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="56.700001"
+ y="34.599998"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8724,91.6956)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1582"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1588"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="55.599998"
+ y="34.599998"
+ transform="matrix(0.00513464,-1,1,0.00513464,20.7748,90.581)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1586"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1592"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="92.2,34.8 92.8,34.8 92.8,34.2 92.2,34.2 91.7,34.2 91.7,34.8 "
+ id="polygon1590"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1596"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="87.2,34.2 86.7,34.2 86.1,34.2 86.1,34.7 86.7,34.7 87.2,34.7 "
+ id="polygon1594"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1600"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="59.5"
+ y="34"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.1736,93.9334)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1598"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1604"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="58.400002"
+ y="34"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.076,92.8188)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1602"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1608"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="57.299999"
+ y="34"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.9785,91.7041)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1606"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1612"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="56.200001"
+ y="34"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8809,90.5895)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1610"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1616"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="55.099998"
+ y="34"
+ transform="matrix(0.00513464,-1,1,0.00513464,20.7833,89.4749)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1614"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1620"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="59"
+ y="33.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.1821,92.8273)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1618"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1624"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="57.900002"
+ y="33.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.0846,91.7127)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1622"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1628"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="56.700001"
+ y="33.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.987,90.598)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1626"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1632"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="55.599998"
+ y="33.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8894,89.4834)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1630"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1636"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="85,31.9 85.6,32 85.6,31.4 86.1,31.4 86.2,30.8 86.7,30.8 86.7,30.3 87.3,30.3 87.3,29.7 87.8,29.7 87.8,29.2 88.4,29.2 88.4,28.6 88.9,28.6 88.9,28.1 89.5,28.1 89.5,27.5 90.1,27.5 90.1,27 90.6,27 90.6,26.4 91.2,26.4 91.2,25.9 91.7,25.9 91.7,25.3 92.3,25.3 92.3,24.8 92.8,24.8 92.8,24.2 93.4,24.2 93.4,23.7 93.4,23.1 93.4,22.6 93.4,22 92.9,22 92.9,21.5 92.3,21.5 91.7,21.4 91.2,21.4 91.2,20.9 90.6,20.9 90.1,20.9 89.5,20.9 89,20.9 88.4,20.9 87.9,20.9 87.3,20.9 86.8,20.9 86.2,20.9 86.2,21.4 85.6,21.4 85.1,21.4 85.1,22 84.5,22 84,22 84,22.5 83.4,22.5 82.9,22.5 82.9,22 82.3,22 82.3,21.4 82.3,20.8 81.8,20.8 81.8,20.3 81.8,19.7 81.2,19.7 81.2,19.2 80.7,19.2 80.7,18.6 80.1,18.6 80.1,18.1 79.6,18.1 79.6,17.5 79,17.5 78.5,17.5 78.5,16.9 77.9,16.9 77.4,16.9 77.4,16.4 76.8,16.4 76.2,16.4 75.7,16.4 75.7,15.8 75.1,15.8 74.6,15.8 74,15.8 73.5,15.8 72.9,15.8 72.9,15.3 72.4,15.2 71.8,15.2 71.3,15.2 70.7,15.2 70.2,15.2 69.6,15.2 69,15.2 68.5,15.2 67.9,15.2 67.4,15.2 66.8,15.2 66.3,15.2 65.7,15.2 65.2,15.2 64.6,15.2 64.6,15.8 64,15.8 63.5,15.8 62.9,15.8 62.4,15.8 62.4,16.3 61.8,16.3 61.3,16.3 60.7,16.3 60.7,16.9 60.2,16.9 60.2,17.4 59.6,17.4 59.1,17.4 59,18 58.5,18 57.9,17.9 57.9,18.5 57.4,18.5 57.4,19.1 56.8,19.1 56.8,19.6 56.3,19.6 56.3,20.2 55.7,20.2 55.7,20.7 55.2,20.7 55.1,21.3 55.1,21.8 54.6,21.8 54.6,22.4 54.6,22.9 54,22.9 54,23.5 54,24 53.5,24 53.5,24.6 53.5,25.1 53.5,25.7 54,25.7 54,25.1 54,24.6 54.6,24.6 54.6,24 54.6,23.5 55.1,23.5 55.1,22.9 55.1,22.4 55.7,22.4 55.7,21.8 55.7,21.3 56.3,21.3 56.3,20.7 56.8,20.7 56.8,20.2 57.4,20.2 57.4,19.6 57.9,19.6 57.9,19.1 58.5,19.1 58.5,18.5 59,18.5 59.6,18.5 59.6,18 60.2,18 60.7,18 60.7,17.4 61.3,17.4 61.8,17.4 62.4,17.4 62.9,17.4 63.5,17.4 64,17.4 64.6,17.4 65.1,17.4 65.7,17.4 66.3,17.4 66.8,17.4 67.4,17.4 67.4,16.9 66.8,16.9 66.3,16.9 65.7,16.9 65.2,16.9 64.6,16.9 64,16.9 63.5,16.9 62.9,16.9 62.9,16.3 63.5,16.3 64,16.3 64.6,16.3 65.2,16.3 65.2,15.8 65.7,15.8 66.3,15.8 66.8,15.8 67.4,15.8 67.9,15.8 68.5,15.8 69,15.8 69.6,15.8 70.1,15.8 70.7,15.8 71.3,15.8 71.8,15.8 72.4,15.8 72.4,16.4 72.9,16.4 72.9,16.9 73.5,16.9 73.5,16.4 74,16.4 74,16.9 74.6,16.9 75.1,16.9 75.7,16.9 76.2,16.9 76.8,16.9 76.8,17.5 76.8,18 77.3,18 77.9,18.1 78.5,18.1 79,18.1 79,18.6 79.6,18.6 79.6,19.2 80.1,19.2 80.1,19.7 80.7,19.7 80.7,20.3 81.2,20.3 81.2,20.8 81.2,21.4 81.2,21.9 80.7,21.9 80.6,22.5 81.2,22.5 81.8,22.5 82.3,22.5 82.3,23.1 82.3,23.6 82.9,23.6 83.4,23.6 83.4,23.1 84,23.1 84.5,23.1 84.5,22.5 85.1,22.5 85.6,22.5 85.6,22 86.2,22 86.8,22 86.8,21.4 87.3,21.4 87.9,21.4 88.4,21.4 89,21.4 89.5,21.4 90.1,21.4 90.6,21.4 90.6,22 91.2,22 91.7,22 92.3,22 92.3,22.6 92.8,22.6 92.8,23.1 92.3,23.1 91.7,23.1 91.2,23.1 90.6,23.1 90.6,23.7 90.1,23.7 89.5,23.7 89,23.7 89,24.2 88.4,24.2 87.8,24.2 87.8,24.8 87.3,24.8 86.7,24.7 86.7,25.3 86.2,25.3 86.2,25.9 85.6,25.9 85.6,26.4 85.1,26.4 85.1,27 84.5,27 84.5,27.5 84,27.5 83.9,28.1 83.4,28.1 83.4,28.6 82.8,28.6 82.8,29.2 82.3,29.2 82.3,29.7 81.7,29.7 81.7,30.3 81.2,30.3 81.2,30.8 80.6,30.8 80.6,31.4 80,31.4 80,31.9 79.5,31.9 79.5,32.5 78.9,32.5 78.9,33 78.4,33 78.4,33.6 77.8,33.6 77.8,34.1 77.3,34.1 77.3,34.7 76.7,34.7 76.2,34.7 76.1,35.2 75.6,35.2 75,35.2 75,35.8 74.5,35.8 73.9,35.8 73.4,35.8 73.4,36.3 72.8,36.3 72.3,36.3 71.7,36.3 71.2,36.3 70.6,36.3 70.6,36.9 71.1,36.9 71.7,36.9 72.3,36.9 72.8,36.9 73.4,36.9 73.9,36.9 74.5,36.9 75,36.9 75.6,36.9 76.1,36.9 76.7,36.9 77.2,36.9 77.8,36.9 78.4,36.9 78.9,36.9 79.5,36.9 80,36.9 80.6,36.9 81.1,36.9 81.7,36.9 82.2,36.9 82.8,36.9 83.3,36.9 83.9,36.9 84.5,36.9 85,36.9 85,36.4 84.5,36.4 83.9,36.4 83.4,36.4 82.8,36.4 82.2,36.4 81.7,36.4 81.1,36.4 80.6,36.4 80,36.4 79.5,36.4 78.9,36.4 78.4,36.4 78.4,35.8 78.4,35.2 78.9,35.2 78.9,34.7 79.5,34.7 79.5,34.1 80,34.1 80,33.6 80.6,33.6 81.1,33.6 81.7,33.6 82.3,33.6 82.8,33.6 83.4,33.6 83.9,33.6 83.9,34.2 84.5,34.2 85,34.2 85.6,34.2 86.1,34.2 86.1,33.6 86.7,33.6 87.2,33.6 87.8,33.6 88.4,33.6 88.9,33.6 89.5,33.6 90,33.6 90.6,33.6 91.1,33.6 91.7,33.6 92.2,33.6 92.8,33.7 92.8,34.2 93.3,34.2 93.9,34.2 93.9,34.8 93.9,35.3 93.9,35.9 93.3,35.9 93.3,36.4 92.8,36.4 92.8,37 92.2,37 92.2,37.5 91.7,37.5 91.7,38.1 91.1,38.1 90.6,38.1 90.5,38.6 90,38.6 89.4,38.6 88.9,38.6 88.9,39.2 88.3,39.2 87.8,39.2 87.2,39.2 87.2,39.7 86.7,39.7 86.1,39.7 85.6,39.7 85.5,40.3 85,40.3 84.4,40.3 83.9,40.3 83.3,40.3 83.3,40.8 82.8,40.8 82.2,40.8 81.7,40.8 81.1,40.8 81.1,41.4 80.6,41.4 80,41.4 79.4,41.3 79.4,41.9 78.9,41.9 78.9,42.5 79.4,42.5 80,42.5 80.5,42.5 81.1,42.5 81.7,42.5 82.2,42.5 82.8,42.5 82.8,41.9 83.3,41.9 83.9,41.9 83.9,41.4 83.9,40.8 84.4,40.8 85,40.8 85.5,40.8 86.1,40.8 86.1,40.3 86.7,40.3 87.2,40.3 87.8,40.3 87.8,39.7 88.3,39.7 88.9,39.7 89.4,39.7 89.4,39.2 90,39.2 90.5,39.2 91.1,39.2 91.1,38.6 91.7,38.6 92.2,38.6 92.2,38.1 92.8,38.1 92.8,37.5 93.3,37.5 93.3,37 93.9,37 93.9,36.4 94.4,36.4 94.4,35.9 94.4,35.3 94.4,34.8 94.5,34.2 94.5,33.7 93.9,33.7 93.9,33.1 93.3,33.1 92.8,33.1 92.2,33.1 91.7,33.1 91.1,33.1 90.6,33.1 90,33.1 89.5,33.1 88.9,33.1 88.4,33.1 87.8,33.1 87.2,33.1 86.7,33.1 86.1,33.1 85.6,33.1 85,33.1 84.5,33.1 84.5,32.5 85,32.5 "
+ id="polygon1634"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1640"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="58.400002"
+ y="32.900002"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.1907,91.7212)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1638"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1644"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="57.299999"
+ y="32.900002"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.0931,90.6066)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1642"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1648"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="56.200001"
+ y="32.900002"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.9955,89.492)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1646"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1652"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="55.099998"
+ y="32.900002"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8979,88.3773)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1650"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1656"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="57.900002"
+ y="32.400002"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.1992,90.6151)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1654"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1660"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="56.799999"
+ y="32.400002"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.1016,89.5005)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1658"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1664"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="55.599998"
+ y="32.400002"
+ transform="matrix(0.00513464,-1,1,0.00513464,23.004,88.3858)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1662"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1668"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="60.599998"
+ y="31.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,28.5004,92.8528)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1666"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1672"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="61.2,32.4 61.2,32.9 61.2,33.5 60.6,33.5 60.6,32.9 60.1,32.9 60.1,32.4 59.5,32.4 59.5,31.8 59.5,31.3 59,31.3 59,31.8 58.4,31.8 58.4,32.4 59,32.4 59,32.9 59.5,32.9 59.5,33.5 60.1,33.5 60.1,34 60.6,34 60.6,34.6 61.2,34.6 61.2,35.2 61.7,35.2 61.7,34.6 61.7,34 61.7,33.5 62.3,33.5 62.3,32.9 62.3,32.4 61.7,32.4 "
+ id="polygon1670"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1676"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="57.299999"
+ y="31.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.2077,89.509)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1674"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1680"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="56.200001"
+ y="31.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.1101,88.3944)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1678"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1684"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="55.099998"
+ y="31.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,23.0125,87.2798)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1682"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1688"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="57.900002"
+ y="31.299999"
+ transform="matrix(0.00513464,-1,1,0.00513464,26.3138,89.5175)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1686"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1692"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="56.799999"
+ y="31.299999"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.2162,88.4029)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1690"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1696"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="55.700001"
+ y="31.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.1186,87.2883)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1694"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1700"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="60.1,31.3 60.1,31.8 60.6,31.8 60.6,31.3 60.6,30.7 60.1,30.7 "
+ id="polygon1698"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1704"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="57.299999"
+ y="30.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,26.3223,88.4114)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1702"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1708"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="56.200001"
+ y="30.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.2247,87.2968)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1706"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1712"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <path
+ d="M 68.4,30.2 V 29.6 29 28.4 27.8 27.2 H 67.8 V 26.6 26 H 67.2 V 25.4 H 66.6 66 V 24.8 H 65.4 64.8 64.2 63.6 63 v 0.6 H 62.4 61.8 V 26 h -0.6 v 0.6 0.6 h -0.6 v 0.6 0.6 0.6 0.6 0.6 h 0.6 v 0.6 0.6 h 0.6 v 0.6 0.6 h 0.6 V 32 h 0.6 0.6 v -0.6 h 0.6 0.6 0.6 v -0.6 h 0.6 0.6 0.6 0.6 0.6 0.6 V 30.2 Z M 63.4,28 V 27.4 H 64 64.6 V 28 28.6 H 64 63.4 Z"
+ id="path1710"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1716"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="56.799999"
+ y="30.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,26.3308,87.3053)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1714"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1720"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="71.2,29.7 71.2,29.1 71.2,28.6 71.2,28 71.2,27.4 71.2,26.9 71.2,26.3 70.6,26.3 70.6,26.9 70.6,27.4 70.6,28 70.6,28.5 70.6,29.1 70.6,29.7 70.6,30.2 71.2,30.2 71.7,30.2 72.3,30.2 72.3,29.7 71.7,29.7 "
+ id="polygon1718"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1724"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="60.1,27.4 60.1,26.8 59.6,26.8 59.6,27.4 59.6,27.9 59.5,28.5 59.5,29 59.5,29.6 59.5,30.2 59.5,30.7 60.1,30.7 60.1,30.2 60.1,29.6 60.1,29 60.1,28.5 60.1,27.9 "
+ id="polygon1722"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1728"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="72.8,29.7 73.4,29.7 73.4,29.1 72.9,29.1 72.3,29.1 72.3,29.7 "
+ id="polygon1726"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1732"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="74,29.1 74.5,29.1 74.5,28.6 74,28.6 73.4,28.6 73.4,29.1 "
+ id="polygon1730"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1736"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="75.1,28.6 75.6,28.6 75.6,28 75.1,28 74.5,28 74.5,28.6 "
+ id="polygon1734"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1740"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="76.2,28 76.7,28 76.7,27.5 76.2,27.5 75.6,27.5 75.6,28 "
+ id="polygon1738"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1744"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="69.5,28 69.5,28.5 69.5,29.1 69.5,29.7 69.5,30.2 69.5,30.8 69,30.8 69,31.3 69.5,31.3 70.1,31.3 70.1,30.8 70.1,30.2 70.1,29.7 70.1,29.1 70.1,28.5 70.1,28 70.1,27.4 70.1,26.9 69.5,26.9 69.5,27.4 "
+ id="polygon1742"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1748"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="76.699997"
+ y="26.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,49.4311,104.0757)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1746"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1752"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="77.9,26.9 78.4,26.9 78.4,26.4 77.9,26.4 77.3,26.4 77.3,26.9 "
+ id="polygon1750"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1756"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="69.5,26.3 69.5,25.8 69,25.8 69,26.3 69,26.9 69.5,26.9 "
+ id="polygon1754"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1760"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="60.1,25.7 60.1,26.3 60.1,26.8 60.7,26.8 60.7,26.3 60.7,25.7 "
+ id="polygon1758"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1764"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="59,26.3 58.5,26.3 58.4,26.8 58.4,27.4 58.4,27.9 58.4,28.5 58.4,29 58.4,29.6 58.4,30.1 57.9,30.1 57.9,30.7 58.4,30.7 58.4,31.3 59,31.3 59,30.7 59,30.2 59,29.6 59,29 59,28.5 59,27.9 59,27.4 59,26.8 "
+ id="polygon1762"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1768"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="78.400002"
+ y="25.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,52.1921,104.65)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1766"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1772"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="79.5,25.8 80.1,25.8 80.1,25.3 79.5,25.3 79,25.3 79,25.8 "
+ id="polygon1770"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1776"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="75.1,25.2 74.5,25.2 74,25.2 74,25.8 74.5,25.8 75.1,25.8 75.6,25.8 76.2,25.8 76.2,25.3 75.6,25.2 "
+ id="polygon1774"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1780"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="70.1,25.8 70.1,26.3 70.6,26.3 70.7,25.8 70.7,25.2 70.1,25.2 "
+ id="polygon1778"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1784"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="68.400002"
+ y="25.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,42.8712,94.0697)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1782"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1788"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="60.700001"
+ y="25.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,35.1881,86.2674)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1786"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1792"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="59.6,26.3 59.6,25.7 59.6,25.2 59,25.2 59,25.7 59,26.3 "
+ id="polygon1790"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1796"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="80.099998"
+ y="24.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,54.9531,105.2244)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1794"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1800"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="76.8,25.3 77.3,25.3 77.3,24.7 76.8,24.7 76.2,24.7 76.2,25.3 "
+ id="polygon1798"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1804"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="73.400002"
+ y="24.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,48.3676,98.5367)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1802"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1808"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="69.5"
+ y="24.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,44.5261,94.6355)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1806"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1812"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="67.900002"
+ y="24.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,42.8797,92.9636)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1810"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1816"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="61.200001"
+ y="24.6"
+ transform="matrix(0.00513464,-1,1,0.00513464,36.2942,86.2759)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1814"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1820"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="59.599998"
+ y="24.6"
+ transform="matrix(0.00513464,-1,1,0.00513464,34.6479,84.604)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1818"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1824"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="81.2,24.7 81.7,24.7 81.8,24.2 81.2,24.2 80.6,24.2 80.6,24.7 "
+ id="polygon1822"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1828"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="77.9,24.7 78.4,24.7 78.4,24.2 77.9,24.1 77.3,24.1 77.3,24.7 "
+ id="polygon1826"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1832"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="72.900002"
+ y="24.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,48.3761,97.4306)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1830"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1836"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="69"
+ y="24.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,44.5346,93.5294)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1834"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1840"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="67.9,24.1 67.3,24.1 66.8,24.1 66.8,24.6 67.3,24.7 67.9,24.7 "
+ id="polygon1838"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1844"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="62.3,24.1 61.8,24.1 61.8,24.6 62.3,24.6 62.9,24.6 62.9,24.1 "
+ id="polygon1842"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1848"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="60.099998"
+ y="24.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,35.754,84.6125)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1846"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1852"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="81.800003"
+ y="23.6"
+ transform="matrix(0.00513464,-1,1,0.00513464,57.7141,105.7987)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1850"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1856"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="78.400002"
+ y="23.6"
+ transform="matrix(0.00513464,-1,1,0.00513464,54.4213,102.4549)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1854"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1860"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="66.2,24.1 66.8,24.1 66.8,23.5 66.2,23.5 65.7,23.5 65.1,23.5 64.6,23.5 64,23.5 63.5,23.5 62.9,23.5 62.9,24.1 63.5,24.1 64,24.1 64.6,24.1 65.1,24.1 65.7,24.1 "
+ id="polygon1858"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1864"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="60.700001"
+ y="23.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,36.8601,84.621)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1862"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1868"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="79.5,23.6 80.1,23.6 80.1,23.1 79.5,23 79,23 79,23.6 "
+ id="polygon1866"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1872"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="76.199997"
+ y="23"
+ transform="matrix(0.00513464,-1,1,0.00513464,52.7835,99.6768)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1870"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1876"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="72.3,23.6 72.3,24.1 72.9,24.1 72.9,23.6 72.9,23 72.9,22.5 72.3,22.5 72.3,23 "
+ id="polygon1874"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1880"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="62.3,23 62.9,23 63.5,23 64,23 64.6,23 65.1,23 65.7,23 66.2,23 66.8,23 67.3,23 67.3,23.5 67.9,23.5 68.4,23.5 68.4,24.1 69,24.1 69,23.5 69.6,23.6 69.6,23 69.6,22.4 69,22.4 69,23 68.4,23 68.5,22.4 67.9,22.4 67.9,21.9 67.3,21.9 67.3,22.4 66.8,22.4 66.2,22.4 65.7,22.4 65.1,22.4 64.6,22.4 64,22.4 63.5,22.4 62.9,22.4 62.4,22.4 "
+ id="polygon1878"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1884"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="61.8,23.5 62.3,23.5 62.3,23 61.8,23 61.2,23 61.2,23.5 "
+ id="polygon1882"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1888"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="80.099998"
+ y="22.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,57.1823,103.0292)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1886"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1892"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="75.699997"
+ y="22.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,52.792,98.5708)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1890"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1896"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="77.900002"
+ y="21.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.5445,100.2512)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1894"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1900"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="75.099998"
+ y="21.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,52.8005,97.4647)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1898"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1904"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="72.9,21.4 72.9,21.9 72.9,22.5 73.4,22.5 73.4,21.9 73.4,21.4 "
+ id="polygon1902"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1908"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="68.5"
+ y="21.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,46.215,90.777)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1906"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1912"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="77.300003"
+ y="21.4"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.553,99.1451)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1910"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1916"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="67.900002"
+ y="21.299999"
+ transform="matrix(0.00513464,-1,1,0.00513464,46.2236,89.6709)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1914"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1920"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="79.5"
+ y="20.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,58.3054,100.8255)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1918"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1924"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="76.800003"
+ y="20.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.5615,98.039)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1922"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1928"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="73.400002"
+ y="20.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,52.2688,94.6952)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1926"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1932"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="68.5"
+ y="20.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,47.3297,89.6794)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1930"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1936"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="79"
+ y="20.299999"
+ transform="matrix(0.00513464,-1,1,0.00513464,58.314,99.7194)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1934"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1940"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="76.199997"
+ y="20.299999"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.57,96.9329)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1938"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1944"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="74"
+ y="20.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,53.3749,94.7037)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1942"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1948"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="69.6,22.4 70.1,22.4 70.1,21.9 70.1,21.3 70.7,21.3 70.7,20.8 70.7,20.2 70.1,20.2 70.1,20.8 69.6,20.8 69.6,21.3 69,21.3 69,21.9 69.6,21.9 "
+ id="polygon1946"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1952"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="69"
+ y="20.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,48.4358,89.6879)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1950"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1956"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="67.900002"
+ y="20.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,47.3382,88.5733)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1954"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1960"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="78.400002"
+ y="19.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,58.3225,98.6133)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1958"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1964"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="74.599998"
+ y="19.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,54.481,94.7122)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1962"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1968"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="70.699997"
+ y="19.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,50.6394,90.811)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1966"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1972"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="69.599998"
+ y="19.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,49.5419,89.6964)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1970"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1976"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="68.5"
+ y="19.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,48.4443,88.5818)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1974"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1980"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="77.900002"
+ y="19.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,58.331,97.5073)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1978"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1984"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="75.099998"
+ y="19.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.5871,94.7207)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1982"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1988"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="70.099998"
+ y="19.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,50.6479,89.7049)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1986"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1992"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="69"
+ y="19.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,49.5504,88.5903)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1990"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g1996"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="75.699997"
+ y="18.6"
+ transform="matrix(0.00513464,-1,1,0.00513464,56.6931,94.7292)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1994"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g2000"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="70.1,18.6 70.7,18.6 70.7,18 70.1,18 69.6,18 69,18 69,18.6 69.6,18.6 69.6,19.1 70.1,19.1 "
+ id="polygon1998"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g2004"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="76.199997"
+ y="18"
+ transform="matrix(0.00513464,-1,1,0.00513464,57.7992,94.7377)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2002"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g2008"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="72.400002"
+ y="18"
+ transform="matrix(0.00513464,-1,1,0.00513464,53.9577,90.8366)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2006"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g2012"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="71.8,19.1 72.3,19.1 72.4,18.6 71.8,18.6 71.8,18 71.2,18 71.2,18.6 70.7,18.6 70.7,19.1 71.2,19.1 71.2,19.7 71.8,19.7 "
+ id="polygon2010"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g2016"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="72.900002"
+ y="17.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.0638,90.8451)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2014"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g2020"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="71.800003"
+ y="17.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,53.9662,89.7305)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2018"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g2024"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <polygon
+ points="67.9,18 68.5,18 69,18 69,17.5 68.5,17.4 67.9,17.4 67.4,17.4 67.4,18 "
+ id="polygon2022"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g2028"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="73.5"
+ y="16.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,56.1699,90.8536)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2026"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+ <g
+ id="g2032"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1">
+ <rect
+ x="72.400002"
+ y="16.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.0723,89.739)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2030"
+ style="stroke-width:2.41176;fill:#fcfeff;fill-opacity:1" />
+
+ </g>
+
+</g>
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.0079936879"
+ x="0.010951031"
+ id="image4124"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAAAA3NCSVQICAjb4U/gAAAACXBIWXMA ADFOAAAxTgGacD5yAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAABF0RVh0 VGl0bGUAUGViYmxlIEljb26KvwD9AAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAAY3RFWHRDb3B5 cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0dHA6Ly9jcmVh dGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzMuMC/eBBrlAAABy1BMVEUAAAAAAAAA AP8AAIAAAFUAAKoAVaoAAIAAQIAAM2YAM5kzM2YzM5krK4AkJG0kJJIgIIAcHHEcHI4aGoAXF3QX F4sXLnQXLosVK4AUJ3YUJ4kSJIAkJIARInciInciIogQIIAgIIAeHngeHoccHIAcK4AbKHkbKIYa GoAaJoAYJHkXI4AXI4sWIXoWIYUVIIAfH3ofH4UdJ4ATHIQcJnsbJIAaI4QaIoAZIXsZIYQYIHgY IIAXH4MXJ3wXHoAXJoBLpfAWJHwdJHwdJIMcI4Acas0VIoMbIoAaIXwaIIAZH3wZH4MZJXwYHoAY HoYYJIAehtsYHoIefdtHoO0jetZGovNEn+4gg+EfgN5Ep/VKqfREpvQgfdkgfttKqvQggd4hjesw nPMwnPRJqfUhgd4hjutJqfUgfttNq/VNq/QhlPEhlPIhlvMkmPMglvIff9sgjeoglPAhk/AhlPEh lPIhlvMkl/MkmPMlmPMmmPMnmfMpmvMzn/Q0n/Q1n/Q2oPRIqfVNq/VasfZbsvZhtPZitfdtuveQ yvmXzvmj0/qk1Pqu2fu83/vR6f3T6v3Z7f3i8f3j8v3l8/7m8/7p9f7q9f7x+P7y+f7////ougeB AAAAb3RSTlMAAQECAwMDBAQFBQUFBgcHCAkJCgsLCwsMDQ0ODg8PDxAQERESEhMTFBQVFhYXFxgZ GRobGxwdHh8fICAhISIiIiMjIyQkJSYnKCkpKSoqKiorKyssLC2xs7O0tba2tu7u7u7u7+/w8fHy +/v7+/ywJhX9AAAC0klEQVRo3u2a+VfTQBCAm00iW0nSTVNKbKQYbIMUC6jU4q2IB2rF+74vPEAR LSjIIXgAAiqef65py6MNJe17y1RF8/26L/N1N9O8ZGZcLof/DiYFoiV1cZHwiGVZjh7zalRAYYbn sFsQ6RFwGccyBeJjwSMrPn8lHX6fQqRy3tbAsFiU1So9FA4bNIRD63XNTwTO5pSYc9fuvwbgwaW9 SxqYs/eGBgcAGBy+fRrlpRPDsJdfJoEYuMgvzibz/vK3kmDcFMuxNZvM/BG74QTdFV6PJZsYlhe9 XXCCrlCVStwcyhG45QCkoCmiV0o4uwXEib4aQMHjbc0RTXYvCMx/mGdNHaSgraWxRhEXzsgUEC0C KWjfszlUYRWsrYcUHGttNlSRzxUEo5CCjrZYrSqVVlD3Nwj6vqcZsovzKrPeTy3o/5lmxE4wnFl/ 4QhKLvjy2YY5IEExHMH0B1umQQSj9o+EUUewIBh/m8c4qOBbfu58dQRLCmYnJmZLKphKJicdwR8X vJsymSmhIM37FSv4OJlhxnlUOAJHAC8Ye5PHmPNe9PsEJX/5dT5AigvmPtkw53wnr3xB34809uWc zDp9OWflV7xgBaBlzY79MWORALhuui+vbhoArfwe3rUp5MtWfl2Il1QDUtAaj+rKakv1XdEhBfGm Wo1kq+/pM1Ih+wdRI6iIq3IFvEAewgke6QHFg1mU26PjhbtwghsKEbG1X8ew3PmnUPGfXRAwv7hr yqAD13t6nwPQ23PnBMeivG4gg85c7XwCQOeVU+avZ5bslcrrNm7dfehIInGSguOJo+0Hd27ZoOam j3ULvOjTI02x+PYddLTEY42GJpfnpo/L0g50k8pqI1IfbWikoSFaXxfSFKnMrqFsZpKbKKoWDFbT EdQCqlfCdhtIG7AoESLTQogk4EJd/VRTn8fLgecKTg3MjyUsA4SKDD7MT1bQ8++NmvwCRmydh2Fg UvEAAAAASUVORK5CYII= "
+ style="opacity:0.490741;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.710279"
+ width="28.710279" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_flipper_disabled.svg b/app/src/main/assets/ic_device_flipper_disabled.svg
new file mode 100644
index 0000000..5550696
--- /dev/null
+++ b/app/src/main/assets/ic_device_flipper_disabled.svg
@@ -0,0 +1,1825 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_flipper_disabled.svg"
+ inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="22.627417"
+ inkscape:cx="0"
+ inkscape:cy="10.938058"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer2"
+ inkscape:pagecheckerboard="0"
+ inkscape:showpageshadow="2"
+ inkscape:deskcolor="#d1d1d1">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#9f9f9f;fill-opacity:0.941176;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline">
+ <rect
+ style="fill:#8a8a8a;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer6"
+ inkscape:label="Foreground"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer7"
+ inkscape:label="Backplane"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer8"
+ inkscape:label="Frontplane"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer9"
+ inkscape:label="Base"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer10"
+ inkscape:label="Top"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer11"
+ inkscape:label="Toptop"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer12"
+ inkscape:label="Toptoptop"
+ style="display:inline" />
+ </g>
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#8a8a8a;fill-opacity:0.94117647"
+ d="m 12.48275,10.865376 h 3.907 v 6.978 h -3.907 z"
+ android:strokeWidth="1"
+ id="path2735" />
+ <g
+ id="dolphin_00000004540763674616766980000002934822554439179943_"
+ transform="matrix(0.41463415,0,0,0.41463415,-16.349253,2.403196)"
+ style="display:inline;fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <g
+ id="g1524"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="85.6,36.4 86.1,36.4 86.7,36.4 87.2,36.4 87.8,36.4 87.8,35.8 87.2,35.8 86.7,35.8 86.1,35.8 85.6,35.8 85,35.8 85,36.4 "
+ id="polygon1522"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1528"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="70.6,35.8 70,35.8 69.5,35.8 69.5,36.3 70,36.3 70.6,36.3 "
+ id="polygon1526"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1532"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="59"
+ y="35.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.9529,95.0224)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1530"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1536"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="57.799999"
+ y="35.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8553,93.9078)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1534"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1540"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="56.700001"
+ y="35.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,20.7578,92.7932)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1538"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1544"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="88.3,35.3 88.3,34.7 87.8,34.7 87.2,34.7 87.2,35.3 87.8,35.3 87.8,35.8 88.3,35.8 88.9,35.9 89.5,35.9 90,35.9 90,35.3 89.5,35.3 88.9,35.3 "
+ id="polygon1542"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1548"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="68.900002"
+ y="35.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,33.3884,104.5052)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1546"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1552"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="59.5"
+ y="35.099998"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.059,95.031)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1550"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1556"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="58.400002"
+ y="35.099998"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.9614,93.9163)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1554"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1560"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="57.299999"
+ y="35.099998"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8638,92.8017)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1558"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1564"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="56.200001"
+ y="35.099998"
+ transform="matrix(0.00513464,-1,1,0.00513464,20.7663,91.6871)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1562"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1568"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="91.7,35.3 91.7,34.8 91.1,34.8 90.6,34.8 90,34.7 90,35.3 90.6,35.3 91.1,35.3 "
+ id="polygon1566"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1572"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="60.099998"
+ y="34.599998"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.1651,95.0395)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1570"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1576"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="59"
+ y="34.599998"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.0675,93.9249)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1574"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1580"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="57.900002"
+ y="34.599998"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.9699,92.8102)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1578"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1584"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="56.700001"
+ y="34.599998"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8724,91.6956)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1582"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1588"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="55.599998"
+ y="34.599998"
+ transform="matrix(0.00513464,-1,1,0.00513464,20.7748,90.581)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1586"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1592"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="92.2,34.2 91.7,34.2 91.7,34.8 92.2,34.8 92.8,34.8 92.8,34.2 "
+ id="polygon1590"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1596"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="86.1,34.7 86.7,34.7 87.2,34.7 87.2,34.2 86.7,34.2 86.1,34.2 "
+ id="polygon1594"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1600"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="59.5"
+ y="34"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.1736,93.9334)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1598"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1604"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="58.400002"
+ y="34"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.076,92.8188)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1602"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1608"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="57.299999"
+ y="34"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.9785,91.7041)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1606"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1612"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="56.200001"
+ y="34"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8809,90.5895)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1610"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1616"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="55.099998"
+ y="34"
+ transform="matrix(0.00513464,-1,1,0.00513464,20.7833,89.4749)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1614"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1620"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="59"
+ y="33.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.1821,92.8273)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1618"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1624"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="57.900002"
+ y="33.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.0846,91.7127)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1622"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1628"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="56.700001"
+ y="33.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.987,90.598)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1626"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1632"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="55.599998"
+ y="33.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8894,89.4834)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1630"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1636"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="87.8,29.7 87.8,29.2 88.4,29.2 88.4,28.6 88.9,28.6 88.9,28.1 89.5,28.1 89.5,27.5 90.1,27.5 90.1,27 90.6,27 90.6,26.4 91.2,26.4 91.2,25.9 91.7,25.9 91.7,25.3 92.3,25.3 92.3,24.8 92.8,24.8 92.8,24.2 93.4,24.2 93.4,23.7 93.4,23.1 93.4,22.6 93.4,22 92.9,22 92.9,21.5 92.3,21.5 91.7,21.4 91.2,21.4 91.2,20.9 90.6,20.9 90.1,20.9 89.5,20.9 89,20.9 88.4,20.9 87.9,20.9 87.3,20.9 86.8,20.9 86.2,20.9 86.2,21.4 85.6,21.4 85.1,21.4 85.1,22 84.5,22 84,22 84,22.5 83.4,22.5 82.9,22.5 82.9,22 82.3,22 82.3,21.4 82.3,20.8 81.8,20.8 81.8,20.3 81.8,19.7 81.2,19.7 81.2,19.2 80.7,19.2 80.7,18.6 80.1,18.6 80.1,18.1 79.6,18.1 79.6,17.5 79,17.5 78.5,17.5 78.5,16.9 77.9,16.9 77.4,16.9 77.4,16.4 76.8,16.4 76.2,16.4 75.7,16.4 75.7,15.8 75.1,15.8 74.6,15.8 74,15.8 73.5,15.8 72.9,15.8 72.9,15.3 72.4,15.2 71.8,15.2 71.3,15.2 70.7,15.2 70.2,15.2 69.6,15.2 69,15.2 68.5,15.2 67.9,15.2 67.4,15.2 66.8,15.2 66.3,15.2 65.7,15.2 65.2,15.2 64.6,15.2 64.6,15.8 64,15.8 63.5,15.8 62.9,15.8 62.4,15.8 62.4,16.3 61.8,16.3 61.3,16.3 60.7,16.3 60.7,16.9 60.2,16.9 60.2,17.4 59.6,17.4 59.1,17.4 59,18 58.5,18 57.9,17.9 57.9,18.5 57.4,18.5 57.4,19.1 56.8,19.1 56.8,19.6 56.3,19.6 56.3,20.2 55.7,20.2 55.7,20.7 55.2,20.7 55.1,21.3 55.1,21.8 54.6,21.8 54.6,22.4 54.6,22.9 54,22.9 54,23.5 54,24 53.5,24 53.5,24.6 53.5,25.1 53.5,25.7 54,25.7 54,25.1 54,24.6 54.6,24.6 54.6,24 54.6,23.5 55.1,23.5 55.1,22.9 55.1,22.4 55.7,22.4 55.7,21.8 55.7,21.3 56.3,21.3 56.3,20.7 56.8,20.7 56.8,20.2 57.4,20.2 57.4,19.6 57.9,19.6 57.9,19.1 58.5,19.1 58.5,18.5 59,18.5 59.6,18.5 59.6,18 60.2,18 60.7,18 60.7,17.4 61.3,17.4 61.8,17.4 62.4,17.4 62.9,17.4 63.5,17.4 64,17.4 64.6,17.4 65.1,17.4 65.7,17.4 66.3,17.4 66.8,17.4 67.4,17.4 67.4,16.9 66.8,16.9 66.3,16.9 65.7,16.9 65.2,16.9 64.6,16.9 64,16.9 63.5,16.9 62.9,16.9 62.9,16.3 63.5,16.3 64,16.3 64.6,16.3 65.2,16.3 65.2,15.8 65.7,15.8 66.3,15.8 66.8,15.8 67.4,15.8 67.9,15.8 68.5,15.8 69,15.8 69.6,15.8 70.1,15.8 70.7,15.8 71.3,15.8 71.8,15.8 72.4,15.8 72.4,16.4 72.9,16.4 72.9,16.9 73.5,16.9 73.5,16.4 74,16.4 74,16.9 74.6,16.9 75.1,16.9 75.7,16.9 76.2,16.9 76.8,16.9 76.8,17.5 76.8,18 77.3,18 77.9,18.1 78.5,18.1 79,18.1 79,18.6 79.6,18.6 79.6,19.2 80.1,19.2 80.1,19.7 80.7,19.7 80.7,20.3 81.2,20.3 81.2,20.8 81.2,21.4 81.2,21.9 80.7,21.9 80.6,22.5 81.2,22.5 81.8,22.5 82.3,22.5 82.3,23.1 82.3,23.6 82.9,23.6 83.4,23.6 83.4,23.1 84,23.1 84.5,23.1 84.5,22.5 85.1,22.5 85.6,22.5 85.6,22 86.2,22 86.8,22 86.8,21.4 87.3,21.4 87.9,21.4 88.4,21.4 89,21.4 89.5,21.4 90.1,21.4 90.6,21.4 90.6,22 91.2,22 91.7,22 92.3,22 92.3,22.6 92.8,22.6 92.8,23.1 92.3,23.1 91.7,23.1 91.2,23.1 90.6,23.1 90.6,23.7 90.1,23.7 89.5,23.7 89,23.7 89,24.2 88.4,24.2 87.8,24.2 87.8,24.8 87.3,24.8 86.7,24.7 86.7,25.3 86.2,25.3 86.2,25.9 85.6,25.9 85.6,26.4 85.1,26.4 85.1,27 84.5,27 84.5,27.5 84,27.5 83.9,28.1 83.4,28.1 83.4,28.6 82.8,28.6 82.8,29.2 82.3,29.2 82.3,29.7 81.7,29.7 81.7,30.3 81.2,30.3 81.2,30.8 80.6,30.8 80.6,31.4 80,31.4 80,31.9 79.5,31.9 79.5,32.5 78.9,32.5 78.9,33 78.4,33 78.4,33.6 77.8,33.6 77.8,34.1 77.3,34.1 77.3,34.7 76.7,34.7 76.2,34.7 76.1,35.2 75.6,35.2 75,35.2 75,35.8 74.5,35.8 73.9,35.8 73.4,35.8 73.4,36.3 72.8,36.3 72.3,36.3 71.7,36.3 71.2,36.3 70.6,36.3 70.6,36.9 71.1,36.9 71.7,36.9 72.3,36.9 72.8,36.9 73.4,36.9 73.9,36.9 74.5,36.9 75,36.9 75.6,36.9 76.1,36.9 76.7,36.9 77.2,36.9 77.8,36.9 78.4,36.9 78.9,36.9 79.5,36.9 80,36.9 80.6,36.9 81.1,36.9 81.7,36.9 82.2,36.9 82.8,36.9 83.3,36.9 83.9,36.9 84.5,36.9 85,36.9 85,36.4 84.5,36.4 83.9,36.4 83.4,36.4 82.8,36.4 82.2,36.4 81.7,36.4 81.1,36.4 80.6,36.4 80,36.4 79.5,36.4 78.9,36.4 78.4,36.4 78.4,35.8 78.4,35.2 78.9,35.2 78.9,34.7 79.5,34.7 79.5,34.1 80,34.1 80,33.6 80.6,33.6 81.1,33.6 81.7,33.6 82.3,33.6 82.8,33.6 83.4,33.6 83.9,33.6 83.9,34.2 84.5,34.2 85,34.2 85.6,34.2 86.1,34.2 86.1,33.6 86.7,33.6 87.2,33.6 87.8,33.6 88.4,33.6 88.9,33.6 89.5,33.6 90,33.6 90.6,33.6 91.1,33.6 91.7,33.6 92.2,33.6 92.8,33.7 92.8,34.2 93.3,34.2 93.9,34.2 93.9,34.8 93.9,35.3 93.9,35.9 93.3,35.9 93.3,36.4 92.8,36.4 92.8,37 92.2,37 92.2,37.5 91.7,37.5 91.7,38.1 91.1,38.1 90.6,38.1 90.5,38.6 90,38.6 89.4,38.6 88.9,38.6 88.9,39.2 88.3,39.2 87.8,39.2 87.2,39.2 87.2,39.7 86.7,39.7 86.1,39.7 85.6,39.7 85.5,40.3 85,40.3 84.4,40.3 83.9,40.3 83.3,40.3 83.3,40.8 82.8,40.8 82.2,40.8 81.7,40.8 81.1,40.8 81.1,41.4 80.6,41.4 80,41.4 79.4,41.3 79.4,41.9 78.9,41.9 78.9,42.5 79.4,42.5 80,42.5 80.5,42.5 81.1,42.5 81.7,42.5 82.2,42.5 82.8,42.5 82.8,41.9 83.3,41.9 83.9,41.9 83.9,41.4 83.9,40.8 84.4,40.8 85,40.8 85.5,40.8 86.1,40.8 86.1,40.3 86.7,40.3 87.2,40.3 87.8,40.3 87.8,39.7 88.3,39.7 88.9,39.7 89.4,39.7 89.4,39.2 90,39.2 90.5,39.2 91.1,39.2 91.1,38.6 91.7,38.6 92.2,38.6 92.2,38.1 92.8,38.1 92.8,37.5 93.3,37.5 93.3,37 93.9,37 93.9,36.4 94.4,36.4 94.4,35.9 94.4,35.3 94.4,34.8 94.5,34.2 94.5,33.7 93.9,33.7 93.9,33.1 93.3,33.1 92.8,33.1 92.2,33.1 91.7,33.1 91.1,33.1 90.6,33.1 90,33.1 89.5,33.1 88.9,33.1 88.4,33.1 87.8,33.1 87.2,33.1 86.7,33.1 86.1,33.1 85.6,33.1 85,33.1 84.5,33.1 84.5,32.5 85,32.5 85,31.9 85.6,32 85.6,31.4 86.1,31.4 86.2,30.8 86.7,30.8 86.7,30.3 87.3,30.3 87.3,29.7 "
+ id="polygon1634"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1640"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="58.400002"
+ y="32.900002"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.1907,91.7212)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1638"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1644"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="57.299999"
+ y="32.900002"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.0931,90.6066)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1642"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1648"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="56.200001"
+ y="32.900002"
+ transform="matrix(0.00513464,-1,1,0.00513464,22.9955,89.492)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1646"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1652"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="55.099998"
+ y="32.900002"
+ transform="matrix(0.00513464,-1,1,0.00513464,21.8979,88.3773)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1650"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1656"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="57.900002"
+ y="32.400002"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.1992,90.6151)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1654"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1660"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="56.799999"
+ y="32.400002"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.1016,89.5005)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1658"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1664"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="55.599998"
+ y="32.400002"
+ transform="matrix(0.00513464,-1,1,0.00513464,23.004,88.3858)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1662"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1668"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="60.599998"
+ y="31.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,28.5004,92.8528)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1666"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1672"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="59.5,31.3 59,31.3 59,31.8 58.4,31.8 58.4,32.4 59,32.4 59,32.9 59.5,32.9 59.5,33.5 60.1,33.5 60.1,34 60.6,34 60.6,34.6 61.2,34.6 61.2,35.2 61.7,35.2 61.7,34.6 61.7,34 61.7,33.5 62.3,33.5 62.3,32.9 62.3,32.4 61.7,32.4 61.2,32.4 61.2,32.9 61.2,33.5 60.6,33.5 60.6,32.9 60.1,32.9 60.1,32.4 59.5,32.4 59.5,31.8 "
+ id="polygon1670"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1676"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="57.299999"
+ y="31.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.2077,89.509)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1674"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1680"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="56.200001"
+ y="31.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.1101,88.3944)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1678"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1684"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="55.099998"
+ y="31.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,23.0125,87.2798)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1682"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1688"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="57.900002"
+ y="31.299999"
+ transform="matrix(0.00513464,-1,1,0.00513464,26.3138,89.5175)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1686"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1692"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="56.799999"
+ y="31.299999"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.2162,88.4029)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1690"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1696"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="55.700001"
+ y="31.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,24.1186,87.2883)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1694"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1700"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="60.6,31.3 60.6,30.7 60.1,30.7 60.1,31.3 60.1,31.8 60.6,31.8 "
+ id="polygon1698"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1704"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="57.299999"
+ y="30.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,26.3223,88.4114)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1702"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1708"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="56.200001"
+ y="30.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,25.2247,87.2968)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1706"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1712"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <path
+ d="M 68.4,30.2 V 29.6 29 28.4 27.8 27.2 H 67.8 V 26.6 26 H 67.2 V 25.4 H 66.6 66 V 24.8 H 65.4 64.8 64.2 63.6 63 v 0.6 H 62.4 61.8 V 26 h -0.6 v 0.6 0.6 h -0.6 v 0.6 0.6 0.6 0.6 0.6 h 0.6 v 0.6 0.6 h 0.6 v 0.6 0.6 h 0.6 V 32 h 0.6 0.6 v -0.6 h 0.6 0.6 0.6 v -0.6 h 0.6 0.6 0.6 0.6 0.6 0.6 V 30.2 Z M 63.4,28 V 27.4 H 64 64.6 V 28 28.6 H 64 63.4 Z"
+ id="path1710"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1716"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="56.799999"
+ y="30.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,26.3308,87.3053)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1714"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1720"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="70.6,27.4 70.6,28 70.6,28.5 70.6,29.1 70.6,29.7 70.6,30.2 71.2,30.2 71.7,30.2 72.3,30.2 72.3,29.7 71.7,29.7 71.2,29.7 71.2,29.1 71.2,28.6 71.2,28 71.2,27.4 71.2,26.9 71.2,26.3 70.6,26.3 70.6,26.9 "
+ id="polygon1718"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1724"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="59.5,30.7 60.1,30.7 60.1,30.2 60.1,29.6 60.1,29 60.1,28.5 60.1,27.9 60.1,27.4 60.1,26.8 59.6,26.8 59.6,27.4 59.6,27.9 59.5,28.5 59.5,29 59.5,29.6 59.5,30.2 "
+ id="polygon1722"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1728"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="72.9,29.1 72.3,29.1 72.3,29.7 72.8,29.7 73.4,29.7 73.4,29.1 "
+ id="polygon1726"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1732"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="74,28.6 73.4,28.6 73.4,29.1 74,29.1 74.5,29.1 74.5,28.6 "
+ id="polygon1730"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1736"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="75.1,28 74.5,28 74.5,28.6 75.1,28.6 75.6,28.6 75.6,28 "
+ id="polygon1734"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1740"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="76.2,27.5 75.6,27.5 75.6,28 76.2,28 76.7,28 76.7,27.5 "
+ id="polygon1738"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1744"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="70.1,31.3 70.1,30.8 70.1,30.2 70.1,29.7 70.1,29.1 70.1,28.5 70.1,28 70.1,27.4 70.1,26.9 69.5,26.9 69.5,27.4 69.5,28 69.5,28.5 69.5,29.1 69.5,29.7 69.5,30.2 69.5,30.8 69,30.8 69,31.3 69.5,31.3 "
+ id="polygon1742"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1748"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="76.699997"
+ y="26.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,49.4311,104.0757)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1746"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1752"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="77.9,26.4 77.3,26.4 77.3,26.9 77.9,26.9 78.4,26.9 78.4,26.4 "
+ id="polygon1750"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1756"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="69,26.3 69,26.9 69.5,26.9 69.5,26.3 69.5,25.8 69,25.8 "
+ id="polygon1754"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1760"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="60.7,26.8 60.7,26.3 60.7,25.7 60.1,25.7 60.1,26.3 60.1,26.8 "
+ id="polygon1758"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1764"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="57.9,30.1 57.9,30.7 58.4,30.7 58.4,31.3 59,31.3 59,30.7 59,30.2 59,29.6 59,29 59,28.5 59,27.9 59,27.4 59,26.8 59,26.3 58.5,26.3 58.4,26.8 58.4,27.4 58.4,27.9 58.4,28.5 58.4,29 58.4,29.6 58.4,30.1 "
+ id="polygon1762"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1768"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="78.400002"
+ y="25.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,52.1921,104.65)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1766"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1772"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="79.5,25.3 79,25.3 79,25.8 79.5,25.8 80.1,25.8 80.1,25.3 "
+ id="polygon1770"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1776"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="75.6,25.2 75.1,25.2 74.5,25.2 74,25.2 74,25.8 74.5,25.8 75.1,25.8 75.6,25.8 76.2,25.8 76.2,25.3 "
+ id="polygon1774"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1780"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="70.7,25.8 70.7,25.2 70.1,25.2 70.1,25.8 70.1,26.3 70.6,26.3 "
+ id="polygon1778"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1784"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="68.400002"
+ y="25.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,42.8712,94.0697)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1782"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1788"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="60.700001"
+ y="25.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,35.1881,86.2674)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1786"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1792"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="59,25.2 59,25.7 59,26.3 59.6,26.3 59.6,25.7 59.6,25.2 "
+ id="polygon1790"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1796"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="80.099998"
+ y="24.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,54.9531,105.2244)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1794"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1800"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="76.8,24.7 76.2,24.7 76.2,25.3 76.8,25.3 77.3,25.3 77.3,24.7 "
+ id="polygon1798"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1804"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="73.400002"
+ y="24.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,48.3676,98.5367)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1802"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1808"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="69.5"
+ y="24.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,44.5261,94.6355)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1806"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1812"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="67.900002"
+ y="24.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,42.8797,92.9636)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1810"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1816"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="61.200001"
+ y="24.6"
+ transform="matrix(0.00513464,-1,1,0.00513464,36.2942,86.2759)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1814"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1820"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="59.599998"
+ y="24.6"
+ transform="matrix(0.00513464,-1,1,0.00513464,34.6479,84.604)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1818"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1824"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="81.2,24.2 80.6,24.2 80.6,24.7 81.2,24.7 81.7,24.7 81.8,24.2 "
+ id="polygon1822"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1828"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="77.9,24.1 77.3,24.1 77.3,24.7 77.9,24.7 78.4,24.7 78.4,24.2 "
+ id="polygon1826"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1832"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="72.900002"
+ y="24.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,48.3761,97.4306)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1830"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1836"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="69"
+ y="24.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,44.5346,93.5294)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1834"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1840"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="66.8,24.6 67.3,24.7 67.9,24.7 67.9,24.1 67.3,24.1 66.8,24.1 "
+ id="polygon1838"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1844"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="62.3,24.6 62.9,24.6 62.9,24.1 62.3,24.1 61.8,24.1 61.8,24.6 "
+ id="polygon1842"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1848"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="60.099998"
+ y="24.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,35.754,84.6125)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1846"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1852"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="81.800003"
+ y="23.6"
+ transform="matrix(0.00513464,-1,1,0.00513464,57.7141,105.7987)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1850"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1856"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="78.400002"
+ y="23.6"
+ transform="matrix(0.00513464,-1,1,0.00513464,54.4213,102.4549)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1854"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1860"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="62.9,23.5 62.9,24.1 63.5,24.1 64,24.1 64.6,24.1 65.1,24.1 65.7,24.1 66.2,24.1 66.8,24.1 66.8,23.5 66.2,23.5 65.7,23.5 65.1,23.5 64.6,23.5 64,23.5 63.5,23.5 "
+ id="polygon1858"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1864"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="60.700001"
+ y="23.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,36.8601,84.621)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1862"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1868"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="79.5,23 79,23 79,23.6 79.5,23.6 80.1,23.6 80.1,23.1 "
+ id="polygon1866"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1872"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="76.199997"
+ y="23"
+ transform="matrix(0.00513464,-1,1,0.00513464,52.7835,99.6768)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1870"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1876"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="72.3,24.1 72.9,24.1 72.9,23.6 72.9,23 72.9,22.5 72.3,22.5 72.3,23 72.3,23.6 "
+ id="polygon1874"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1880"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="67.3,23 67.3,23.5 67.9,23.5 68.4,23.5 68.4,24.1 69,24.1 69,23.5 69.6,23.6 69.6,23 69.6,22.4 69,22.4 69,23 68.4,23 68.5,22.4 67.9,22.4 67.9,21.9 67.3,21.9 67.3,22.4 66.8,22.4 66.2,22.4 65.7,22.4 65.1,22.4 64.6,22.4 64,22.4 63.5,22.4 62.9,22.4 62.4,22.4 62.3,23 62.9,23 63.5,23 64,23 64.6,23 65.1,23 65.7,23 66.2,23 66.8,23 "
+ id="polygon1878"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1884"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="61.8,23 61.2,23 61.2,23.5 61.8,23.5 62.3,23.5 62.3,23 "
+ id="polygon1882"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1888"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="80.099998"
+ y="22.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,57.1823,103.0292)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1886"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1892"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="75.699997"
+ y="22.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,52.792,98.5708)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1890"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1896"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="77.900002"
+ y="21.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.5445,100.2512)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1894"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1900"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="75.099998"
+ y="21.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,52.8005,97.4647)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1898"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1904"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="73.4,22.5 73.4,21.9 73.4,21.4 72.9,21.4 72.9,21.9 72.9,22.5 "
+ id="polygon1902"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1908"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="68.5"
+ y="21.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,46.215,90.777)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1906"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1912"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="77.300003"
+ y="21.4"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.553,99.1451)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1910"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1916"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="67.900002"
+ y="21.299999"
+ transform="matrix(0.00513464,-1,1,0.00513464,46.2236,89.6709)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1914"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1920"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="79.5"
+ y="20.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,58.3054,100.8255)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1918"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1924"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="76.800003"
+ y="20.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.5615,98.039)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1922"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1928"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="73.400002"
+ y="20.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,52.2688,94.6952)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1926"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1932"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="68.5"
+ y="20.799999"
+ transform="matrix(0.00513464,-1,1,0.00513464,47.3297,89.6794)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1930"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1936"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="79"
+ y="20.299999"
+ transform="matrix(0.00513464,-1,1,0.00513464,58.314,99.7194)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1934"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1940"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="76.199997"
+ y="20.299999"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.57,96.9329)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1938"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1944"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="74"
+ y="20.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,53.3749,94.7037)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1942"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1948"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="69.6,20.8 69.6,21.3 69,21.3 69,21.9 69.6,21.9 69.6,22.4 70.1,22.4 70.1,21.9 70.1,21.3 70.7,21.3 70.7,20.8 70.7,20.2 70.1,20.2 70.1,20.8 "
+ id="polygon1946"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1952"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="69"
+ y="20.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,48.4358,89.6879)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1950"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1956"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="67.900002"
+ y="20.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,47.3382,88.5733)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1954"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1960"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="78.400002"
+ y="19.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,58.3225,98.6133)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1958"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1964"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="74.599998"
+ y="19.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,54.481,94.7122)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1962"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1968"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="70.699997"
+ y="19.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,50.6394,90.811)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1966"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1972"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="69.599998"
+ y="19.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,49.5419,89.6964)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1970"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1976"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="68.5"
+ y="19.700001"
+ transform="matrix(0.00513464,-1,1,0.00513464,48.4443,88.5818)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1974"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1980"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="77.900002"
+ y="19.200001"
+ transform="matrix(0.00513464,-1,1,0.00513464,58.331,97.5073)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1978"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1984"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="75.099998"
+ y="19.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.5871,94.7207)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1982"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1988"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="70.099998"
+ y="19.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,50.6479,89.7049)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1986"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1992"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="69"
+ y="19.1"
+ transform="matrix(0.00513464,-1,1,0.00513464,49.5504,88.5903)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1990"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g1996"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="75.699997"
+ y="18.6"
+ transform="matrix(0.00513464,-1,1,0.00513464,56.6931,94.7292)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect1994"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g2000"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="70.1,19.1 70.1,18.6 70.7,18.6 70.7,18 70.1,18 69.6,18 69,18 69,18.6 69.6,18.6 69.6,19.1 "
+ id="polygon1998"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g2004"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="76.199997"
+ y="18"
+ transform="matrix(0.00513464,-1,1,0.00513464,57.7992,94.7377)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2002"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g2008"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="72.400002"
+ y="18"
+ transform="matrix(0.00513464,-1,1,0.00513464,53.9577,90.8366)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2006"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g2012"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="71.2,19.1 71.2,19.7 71.8,19.7 71.8,19.1 72.3,19.1 72.4,18.6 71.8,18.6 71.8,18 71.2,18 71.2,18.6 70.7,18.6 70.7,19.1 "
+ id="polygon2010"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g2016"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="72.900002"
+ y="17.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.0638,90.8451)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2014"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g2020"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="71.800003"
+ y="17.5"
+ transform="matrix(0.00513464,-1,1,0.00513464,53.9662,89.7305)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2018"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g2024"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <polygon
+ points="68.5,18 69,18 69,17.5 68.5,17.4 67.9,17.4 67.4,17.4 67.4,18 67.9,18 "
+ id="polygon2022"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g2028"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="73.5"
+ y="16.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,56.1699,90.8536)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2026"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+ <g
+ id="g2032"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176">
+ <rect
+ x="72.400002"
+ y="16.9"
+ transform="matrix(0.00513464,-1,1,0.00513464,55.0723,89.739)"
+ width="0.60000002"
+ height="0.60000002"
+ id="rect2030"
+ style="fill:#fcfeff;fill-opacity:1;stroke-width:2.41176" />
+
+ </g>
+
+</g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.028144173"
+ x="0.013794855"
+ id="image1291"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAANW0lE QVR4nO2dS4xc2VnH/98591VVXd3TbntC92ggwZ5R1BWRRZMoIQG1UIQIs0BCcpINESyyQIqySlgl 2I6yQ2xmtpEQilgQI4EUiAQaQQtCFAiNQjLVUWbsjII19iS2u931vI9zzseiqtxP21V9b7vrlr/f pp/33HPr/O53vvO4VYAgCIIgCIIgCIIgCILwLEBnUz6f8mmfZR7ZpKfyop+GQPvKHNX52mmLKjyS K8NGONAEhclUdMMOy2PsSdMcfr08+LK+JTKdNhurQ0GuD3/RGP58hfc1eSESeUUUMuSQPE0CLgPr ILRv0xp2EMd3CHf3DkiS+yJTQYTh0p4QjS1E0TJvYhGorzA2VjGQ6RoAHklEKECiIhuQDsiz3qC1 9jLF8R1KknNkzDYZs0vAi3CuJeKcEkrNM3ALnrfAnneOw3Cbo2iZN+t3GBtNHkSjh5FoagQ6Ik/j LlSSnKM4NirLbit3fo7Y9AhYgrP9vTjq+iJTTkhVHoqgdIWB+yCvyupeh31/xUWR58Jwm5sX4Abd 21ZhEhUo0NUD8nS7UGkKlS1C2QRq3kA5F5KrJ8Q2IaAOdqnIUxCkAgbaIB2yaoesVMItD06HcP4O XBDA1WpwA4lGkegqYwoEok++ygE/3/+CJv6MVrjIwEIB5QpFw9xyUG9ZuL+Jt+6+tnH1vUneKJRX IPrEN/nFiHrfJqBhHJBYwLqcpQqnglZAqAFPAc7xG0miX3n9s9GtPCOzkwpEAPDJVznQy73NxKBx p8PoZjJBWAbmAsJyjeArfqP747sf2rj6V+mhIf6IJzaomvDchIcJM4Of73+hm6Lx0wdO5CkRnZRx 84FDz9AHgpeWPj+Ycrk2zGOv0j5vnhhgJhHo0DzPNXJwn77VcnDiTulwDNxqOZDWn8LaosI6FLBK B2Vi4GHQOJ5xJxKPThKuN+h+D5esyFNaLAPbfXfp0m6owhgcre0MJx8xnDN68sTjBDPRI3lWCeug xl2oTsbVgq5FOCNaKeba7dCL43nX7d7hS17IYQxurjccNpo4JNERxunC6KA8Ww/neTJb4JUIZ0Jm gbSmvbj2rtefr+leb0l3u1CNu1BYb+zr0h52ZwcYMwcadVt78vT7HS29V/lhANVAedX+olfrWC+t 93SaDtr4oETH8ySB9mXkl7HWXqbRDLN97sKkIzhhGmGmNA79xE/8JHDeXFLVab2i03SwFLXWvj1Y FN+LQgcYX4L1LYrjO2TMLmWLUHNJXxd4GcIZ4uvMDzx4WdLxsijQc0lfZ4tQcfy2iuMVOrQF50A0 GiOJHnZf7UVKkpCcWyKbQJkalF/whQhng/XIQwYKgwBpljIZsE2gnFtwgy03Sxh2Y0dC0JgR6DLW sAZjlsier9G8gXLZxJOQwpTiZ/Csb7Q1mY5Mql0V6jkXkT3fIWOWht0YcNzO0oklcLZLzsVkLRSY ZTV9BnAM5RtPs4NyUUDOBeRcTM7ODdt37ZHHji3QKP9h0yPnIpKtGLMDe1BgQy4g5SwUc0rOxYfa 9/Kxx55gS+sSsNAHerUTVFWYRpg9YoDAIOaM2PEgSJgeGZNRHIOAxWOPHTMCjbLwF/edNJMINDNY YjYUPvx5/OBwghyoP9xRKMwWIdgZAioTBQcZSQkD2J4oKIhAQi5EICEXRT5Y+FR56ZzC5z7o46Mv eHi+RvBLditkDvhFl/Hddwy+/oMMb+2UcyN5KQX67Ad8fOXjIXSJU3lfAS/UCZff7+MPXvZx9TsJ /rqZnXW1JqZk9y3wykUPV36z3PIcRivgq78V4nd/tXz3c6kECjTw5Y+Fp/6eNGcBAfjKx8LSdcWl qu5HVjTeU5tFfQYszxE+vFKuXTKlEuilc6Wq7ol4uWTXWKra1vzZjT4j5kp2jaUSSJg+RCAhFyKQ kIvyTTw8ht2kHA8aLYSPznNePqfwexc97MSM/3nXIpnyZ+9mSqBf/8supv1RawJw80/mHvn3Vy55 eOXSoFluPnD4o3/o45329F6UdGFTzMXnFL78G+GT//EMEYGeMnrCV/xDy9M9sSgCPWUmXaqoTPm8 kAgk5EIEespM+mZcjqc3gQZmbBR2HH/+2xF+7fmzuU9++AuHL/1LfOB36YTD8p9sT/dGs5kX6MU6 4aXFsxFop58vemQO+Iv/TAuqzekw8wKVjTe3HW7sOPxfy+Hv3zR4UyKQMAn/eMPgtc3pjjr7kSRa yIUIJORCBBJyIQIJuRCBhFyIQEIuRCAhFyKQkAsRSMiFCCTkQgQSciECCbkQgYRciEBCLkQgIRci kJALEUjIhQgk5EIEEnIhAgm5EIGEXIhAQi5EICEXIpCQCxFIyIUIJORCBBJyIQIJuRCBhFyIQEIu RCAhFyKQkAsRSMjFzL9D2a02Y/GMPhH51hR/REFRzLxAh98lVSgW6cKEXIhAQi5EICEXIpCQCxFI yMVMjcL++49rZ12FZ46ZEuhxn0UqnA7ShQm5EIGEXIhAQi5KJVAnm/21pXZarmsslUA3pvyzs4rg rTNa+D0ppRLoe7ct3u2W6w6dhDsdxn/dnvAzMc+YUgmUWuBr/5FgFhViAF/9ToKsXAGoXAIBwLdv Glz99wS2ZC/047AO+LN/S/BPb5uzrsrElHIi8RtvZPjebYvPfdDHR1/w8Es1gi7ZrWAc8PMu47vv GHz9B1npcp8RpRQIAN7advjTf00AJGddlWeakt23wrQhAgm5EIGEXIhAwgDSJ5odmVggpStMOpzF qZhnnASkPAb6Ex01pkCrQ2FuTVgpoRxoJvJ4NJ4lGn9B7gRd2H1gd7KTCNMNkWGCZpBhIp9JBaxU zORV2fMWOIqWH9nWYwsURcvseQtMXnVQuApEoBmBDBzIskojRypjooCVig617/Vjjz1BDlRjpSLW Gg5EItEMoAgu87Ql1Xc6YadUykpFrHRn2L6bjz52vFNcxyY24XnnWN/rcsuDI5WIPDNC5sPozFql 4foKTvXgHqiY9b059rz7vFlfGbb1lSNtPoZAVxhoMOorHIbbrNQ86xDOj8Ny7TsQHok2bJSX2TRj 4/mB9Tw4HcIptcthuDSUpnFswBi/C9tY5UEe1GJ/B67loZyrf8IRUsMms37mh3MmSGE6YcX6O3BR 9D4XRbcZG6v75Tkg0pMEYmD0qMx1bNbvcK0GFwRwfrVjwdKLzQAchEHmJzBhqkwn7Fn94K4LArgw 3B52X6ME+uhjU2NGoGE3trHKzeg212pwldacBUsQKj3s0Df9LK5VsnYltkG7aiuVOVurwTUvwGGj OWj7Y/IfYDyBhlHoCgNbjM1PuOYFuFoNDnHaK/ZqhKcNx0k76FoTtPu21oWpVu/vk2d1nzwE4Ohm 0AmG8fsk2mhy8wKc6/duzOT+0mcFZnC//9N6/ZfN4iJMrQZ3YyHZF3m29stzLONuKGMANCiIGbgG bDTBH8+uc2t3jebnAZLHiksFM7jdBiW9v72x8IZDfWWYLDcBrDDQwCF5jg0Vk+xIPCKR+8ndV+n9 5/8QzjWoXge80m5wfLYwGbjdAZLkR+n/3nwNmztuIM0W9sQBniTPgf+YEBqV+ytf/OF7qVL5Fitq kO8D2gdkgno6YQJsBs4yENOP2HV//2df+7ufDf54RBrgMeKMyNvvEMB4z+98o1r5yIc/D8efBqmL rNR8znKFU4Act8C4odhe737/+6/9/J+/FQ+S5KsjUSa+8wsRCLhGAFSjAXXvXsdPa+e9auK81E/9 wKt4Jkt9Z632fa3ZQTFbAhQhAJidJE85IVKMFAAcE2kmBZdl1iqtrfbIZNZkfgoTV5FW491sYWHB DhJluMeNsMYhb9LCg+z5KoBVjqLXuVJZsejeQ78+xxUvQZakHEaBsybTzkJzpCmwRgEAsyfyFAE7 UGAYAFKtHSWW/TCz2oNN0tQGEZtQOWPb1nqV4faMjcVhznNyeYD8EWhYBgP4lMJ6gxp3oXZ353Wy 0NK1LrwsCrTJUh2ZVNuQlLNQQAVgQ8yZCFQQRP5AAspYaTidsIu9wHp+YP04td0aTLg7bxcWWnZv mP5Nl1egAodNDcbGKpprO3wpSZzbAXXqPTsXA2TAWTVw1kKx84k5I+aU5A3piqOLFEQBE1WZkLEL 4fwenGdT1wl7Ntipuqjqub3oU8wMXhEC7evGrgP1Bocx2Bg4tKvoLoJtAjWfQjkXk3MgnksIAAzq BZxeAIAQbQAJqANWKmZlI25Fg1X1YKe6b20LDLyOwXA9X/QBiunChuWMkukmjbqyJDlHcfy2cm6B 7PkaOdslNj1ytipd1ymhdG+wa1TXWN/rslK7HEXvc2G4zXvLEwdmmHMJVGAXdnCWurnecGvtkMJw gZPkPpkWyJjdoTjzcK4lEhWMUvMM7ALYheeBvep9DsMljqJwEHk2msOu6/HLE5NQlEDHLnVsYpWx fofQTmgNy4jjUa1bBZ1WOEgLwAKAwR72TdwG6hjmPK/j0MIoUEAeVHQUeDhDPZwbGtIk4PLw+y3C esFnFfbYAPYew7qOgzsJi5UHKF6gQ+WO6nhNuqszY/x1rZNw2g0r4kwPskApCIIgCIJQEP8P03/B 430mofMAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.670942"
+ width="28.670942" />
+ <image
+ y="0.0055872202"
+ x="-0.031073861"
+ id="image3082"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAAL6klE QVR4nO3dTYvcRhrA8adKJZW6Zc0LmTEmmYUF5zYOJjQLOSTQEMhlA7uHsNlPsCHg5DP4MzghHyC3 xZvTGh8CgYaQXMwEkngOZo1f2JAYz2ReLHePSqqqZy+t2Z5xj6c1pbZa7ecHxo09LdVEf0rVkjIG IIQQQgghhBBCCCEvA9bw7ZPJ4TQ2Oo0DPG6bFFJ9xoVTWUxVH1g25vXxfTAAgG63W/GuSa/XK14e DwTH/HklEVUZ0PF4ilBYkiQMACBN0yP7U0rRzFQRKeWRIMIwRACAOI6x1+uNBlRpRNMIiAEAK8JJ 05QppZjWmmmtGQCAtZbCmRLOOQIACCFQCIFSSgzDEEdCGo1oZgJ6Jp6trS2ulGJpmvI8z/nKysph QMaYw/1STO6KaAAAPM87DGh7ext937dhGFopJa6urtqqI6oyoCPx9Pt9nmUZX15e5koprrXm1lpm rWWIyOI4pngqxDnHJEmAMYacc+ScoxDCSint7u6uDYLARlFkx0RUe0Ds2rVrQRRFn3qe93fO+UVE XKxgu6R6TxDxP4j4z4cPH3529epVNfzzM0fkGhD78ssv/2CtvckYW7fWgtYaEKdyyYE4YoyBEAI4 54CIt5VSf/7oo4/+O/IlpQ/cWQNiAADXrl0LFhYWNowx60mSQJZlZ9wceZGCIIA4jsHzvNsPHjz4 09WrVzM44/UiXnLfhx/PAQCiKPo0z/P1nZ0diqdBsiyDnZ0dyPP80tra2hUYWcPC+Gt5JyoT0DPX eRhjH+7t7dEpq4EQEfb29oAx9rdOp8O73S6H8SEdj+oIMeH+xl0kZP1+/3WKp7kQEQ4ODl7f39/n aZpip9NBAICNjY1xH/MZjDmlTRpQ4chH9SzL2mcdPJkNSqlzSZKINE1tv99HIQSur68X14yKLztx lpjkFDb2ImG/3+fGGLfRk9oZYyCKIhFFkVhYWPAGg4HX7/f51tYW73a7405nR0y6BnomnoODA6+i 74HUTCklDg4OxNOnT0Ucx16WZXxMRGOddgo78sYkSVhxhXlpaansJzgygxCR+b7ve55nfd83aZqa OI4hSRIQQuDwRjjCCWugiSPodrssTVOmtWbD2xM0A80Jz/P8LMuEUkqEYegppbzl5WWepilP05QN Z6HCkUllkkU0AwCWJAlTSjFrLSvubVX6XZDaaK2F7/ssz3PgnKPWGpVS3FprRx65cZuBhjtiw7vq vN1uU0BzwvM8YYzxgiDwtNZeu93m1lpmjGFa68PnucBhEX3IGMOGG+eISHfT5wAiciGEZ609fGKi +HXaeycOqFj/aK0n3jhpBmstR0RmreVSyuI1O3/+/KnvpdMQgeGZhCEiK+Kx1h5OGMcfRR5V5jrQ uJ2SORAEAUNEFoZh6feeaQ1E8cynYllS5vjSKYwAwNnPKBQQcUIBESdlH+eYGa+++ip0Oh1YW1uD KIqAsWYty4wxkCQJ3L9/H27dugX7+/t1D+lMGhnQO++8A2+99Vbdw3DieR4sLS3Bm2++CW+88Qbc vHkT7ty5U/ewSmvcKazT6TQ+nuOEEPD+++/D2tpa3UMprVEBtVotePvtt+sexlRwzuHdd9+texil NSqgixcvQhAEdQ9jas6fPw8rKyt1D6OURgXUtP+4Z/HKK6/UPYRSGhWQ7/t1D2HqpJR1D6GURgVE Zg8FRJxQQMRJIy8kjoOIoJQ6/Qtrxhh77jrnwoULoJSCfr8Pv/76K1hrX+DoypubgAaDAXzxxRd1 D+NU586dg48//vjEv798+TJcvnwZAAAePXoE169fhzRNX9TwSqNT2Ay7cOHCzF84pYBesHa73I8T mPXbGxTQC1b2WtasX/uigIgTCugFK/upatZ//tLcfAo7yQcffAALCwu17PvevXuj//wAAAA8ffq0 1Da2trYqHFH15j6gpaUlWF5ermXf4w5+mRklyzL4/vvvqxxS5eY+oKZ59OgR7O3twc7ODty+fXvm H3WlgGbMjz/+CD/99FPdw5gYLaKJEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgT Cog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMK iDihgIgTCog4oYCIEwqIOKGAiJO5/wllu7u7YIypZd9JktSy3xdp7gP66quv6h7CXKNTGHFCAREn FBBxQgERJxQQcTI3n8La7TZ88skndQ/jVIyxuodQqbkJiDEGYRjWPYyXDp3CiBMKiDihgIiTRgWU ZVndQ5g6pVTdQyilUQH9/vvvdQ9h6pr2PTYqoLt37871LPT48WPY3t6uexilNCqgNE3h22+/rXsY U2GthW+++abuYZTWqIAAAH744YeZ/3dEy8rzHG7cuAG//PJL3UMprZEXEr/77ju4d+8edDodWFtb g3PnzjXuCq8xBp48eQL379+HW7duwZMnT+oe0pk0MiAAgN9++w1u3LhR9zBeeo07hZHZQgERJxQQ cUIBEQAAYIzhWd5XOiDP8/CsOyOzjXNe+rhOGhAFM8eyLEPGGKZpCgDlZqMzn8JoFpofw2OJjDFk jCHnHDnnKIRAIQSGYXjisZ44oDAMDzdY7KCS0ZPacc7tMBybpulhRI8fPz79vWV35nkecs7R8zxL s9B8YIxZrbXhnFvP82wxQUwySZQKSAiB29vbKISw/X6f4pkTxhjteZ4ZxmMHg4EdThIohMA4jotj /cwxnyQgBACM4xillMg5Ryml9X2/np9YQConhNBZlpk8z7Xv+0YIYaWUdnisT4wHoMQM1Ov1sFgH 7e7uWiGErWT0pHZaay2lzKWUOssyLaU0u7u7NgxDG4Yh9nq90XiOhHTazVQEgMPb3HEcY5qmFgAg SRKDiI27C06egVLKfDAYWEQ0Ukq7t7dnW62WlVKOnr7GKnMdCItZKIoi22q1DCItg5oOEUFrnUdR lLdaLZMkiWm1WiaKIru6umqHs0/x6xmTroGK33FjY8Ourq7aKIpslmWDir4PUhOlVNLv93WSJKbf 7+t2u/28eJ6JqOzzQAgA0Ov1oNvt2sFgcFdK2aHTWDMhIgwGg3txHOtisRyGIW5sbIyGU9kpbPQ1 9no91FpfT5IE6FTWPIgISZKAMeZfi4uLdnV11W5ubpqNjQ0LABaejWfsQfZK7vfIVPPaa69tRFH0 V2PMed/3gXO6ud8EWmtIkgSyLPv5zp07/+j1evrBgwfPi+XEGeKs557D9125cuWPUsp/c87XhRAg RGOfkn0paK1Baw3W2p+VUn/5/PPPHw7/alwkp55aXBcvDADgvffea1+6dOkKAHzIGLvIOV9w3C6Z AmvtE0S8i4jXNzc3P/v6669TOGWRfJpKAhr+ztfX1/n29rYfRZFQSgnf9/08z4UQwjfGeL7ve9Za joiseO/wNXEwck+yuBFq8zw3nueZPM91EAR5lmW63W5naZrmi4uLZnNzc3StA3CGeADc/6+M0QuN GIYhtlot0+/3IY5jPDg4gCAIUGttpZSeMcYDABYEAQegeKpURJTnuUVElFKaLMvM8HcdhqEeXuMp Hs84dYE8iaoWLAgArLhSrbVmUkqmtTZSSuCco9YajTFWSskRERCRUUDVKQISQqBSyiKiDYLA+L5v ENG0Wi2TZZmNosg+7+Zo6f26bgCOnsZYp9Ph+/v7fDAYeHEce0opT2vN2+02N8Zway2jeKZj9IEw z/PsYDCwwxujJkkS0263zeLioh1+VHda+xSqmIHG3S9DrbVNkgSWl5dRKcWzLOPWWlYENPzaCnZP AP7/zyoUARljMAxDK6W0u7u7NgiCk+5tOc1CVc0CR2ahbrfLtra2uFKKpWnKrbXMGMNWVlaY1poZ Y2j2mZLiGZ7t7W0sHv4bhoST3p4oo+qAites2+2yJElYmqZMKcW01kxrffh11lqKqGKjTxAWjx9L KTEMQ4zjGE+4MToTAR3f1uHH9CIkAIA0TSmaF6R4EH4kHICK4wGoNqDj25vkNZmOkyKpNB6A6R3M 49ulaOpT6aL5uGkfWApndtAjE4QQQgghFfkfLnfEbGu/P8gAAAAASUVORK5CYII= "
+ style="display:inline;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.697369"
+ width="28.697369" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_galaxy_buds.svg b/app/src/main/assets/ic_device_galaxy_buds.svg
new file mode 100644
index 0000000..efd5e73
--- /dev/null
+++ b/app/src/main/assets/ic_device_galaxy_buds.svg
@@ -0,0 +1,189 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_galaxy_buds.svg"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="22.627418"
+ inkscape:cx="8.1096305"
+ inkscape:cy="10.982252"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#1f7fdb;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#4dabf5;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ <g
+ id="g2507"
+ transform="matrix(0.99999822,0,0,1,0.29668164,-0.00953878)">
+ <g
+ id="g12091"
+ transform="matrix(0.43308665,0,0,0.43752425,-17.418784,-45.717514)"
+ style="stroke-width:1.14863;stroke-miterlimit:4;stroke-dasharray:none">
+ <g
+ id="g12078"
+ style="stroke-width:1.14863;stroke-miterlimit:4;stroke-dasharray:none">
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#fcffff;stroke-width:1.14863;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 89.229326,149.75243 c 1.422876,1.97773 0.400592,5.02874 -3.539949,5.03091"
+ id="path10585-6"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#fcffff;stroke-width:1.14863;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 85.608172,141.34954 c -2.666757,-1.07007 -4.567397,-1.03074 -7.019236,-0.32613 -1.148282,0.32999 -2.049576,1.68227 -2.142569,2.8734 -0.428995,5.49489 2.903496,8.17341 6.263367,7.93085 2.05934,-0.14867 4.130587,-2.74489 4.627575,-5.01288 0.41816,-1.90826 0.08389,-4.73774 -1.729137,-5.46524 z"
+ id="path5256-7"
+ sodipodi:nodetypes="ssssss" />
+ <path
+ style="fill:none;stroke:#fcffff;stroke-width:1.14863;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 83.287036,138.80034 c -3.534371,-0.50931 -7.153563,0.99406 -8.625608,4.36799 -1.972926,4.52216 0.871682,11.86623 6.124093,12.64208 9.667201,1.42779 12.807218,-15.52506 2.501515,-17.01007"
+ id="path3567-5" />
+ </g>
+ <path
+ style="fill:none;stroke:#fcffff;stroke-width:1.14863;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 81.17037,134.62224 c 1.528272,0.40147 2.479848,2.50023 3.65606,3.48042 1.57278,1.31064 3.329209,2.02976 4.365471,3.91113 0.788477,1.43151 0.572308,3.47576 1.409777,4.73562 0.43788,0.65873 1.309196,0.96445 1.803823,1.61735 1.540471,2.03338 1.370542,5.10243 -0.479425,6.88871 -1.219739,1.17764 -3.141402,1.6612 -4.790555,1.28077 -0.702868,-0.16221 -1.380432,-0.74583 -2.116667,-0.68541 -1.719445,0.14105 -2.786803,0.97944 -4.618181,0.50261 -6.467763,-1.6839 -8.890577,-9.24373 -7.458556,-15.10934 0.757382,-3.10205 4.540443,-7.59068 8.228253,-6.62186"
+ id="path3559-3" />
+ </g>
+ <g
+ id="g1406"
+ transform="matrix(0.64963,0,0,0.65628639,-41.657575,-73.118779)"
+ style="stroke-width:0.765756;stroke-miterlimit:4;stroke-dasharray:none">
+ <path
+ style="fill:none;stroke:#fcffff;stroke-width:0.765756;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 80.707855,121.04264 c -1.01885,0.26765 -1.65323,1.66683 -2.43737,2.32029 -1.04852,0.87375 -2.21948,1.35316 -2.91032,2.60741 -0.52565,0.95434 -0.38154,2.31718 -0.93985,3.15709 -0.29192,0.43915 -0.8728,0.64296 -1.20255,1.07824 -1.02698,1.35557 -0.91369,3.40161 0.31962,4.59246 0.81316,0.78509 2.09427,1.10747 3.1937,0.85385 0.46858,-0.10813 0.92029,-0.49721 1.41111,-0.45694 1.1463,0.094 1.85787,0.65296 3.07879,0.33508 4.31184,-1.1226 5.92705,-6.16249 4.97237,-10.0729 -0.50492,-2.06804 -3.02696,-5.06045 -5.4855,-4.41458"
+ id="path3559" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#fcffff;stroke-width:0.765756;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 75.335215,131.12944 c -0.94858,1.31848 -0.26706,3.35249 2.35997,3.35394"
+ id="path10585"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#fcffff;stroke-width:0.765756;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 77.749325,125.52752 c 1.77783,-0.71339 3.04493,-0.68717 4.67949,-0.21743 0.76552,0.22 1.36638,1.12151 1.42838,1.9156 0.28599,3.66326 -1.93567,5.44894 -4.17558,5.28724 -1.3729,-0.0991 -2.75373,-1.82993 -3.08505,-3.34193 -0.27878,-1.27217 -0.0559,-3.1585 1.15276,-3.64348 z"
+ id="path5256"
+ sodipodi:nodetypes="ssssss" />
+ <path
+ style="fill:none;stroke:#fcffff;stroke-width:0.765756;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 79.296745,123.82804 c 2.35625,-0.33954 4.76904,0.66271 5.7504,2.912 1.31529,3.01478 -0.58112,7.91081 -4.08272,8.42805 -6.4448,0.95186 -8.53815,-10.35004 -1.66768,-11.34005"
+ id="path3567" />
+ </g>
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none"
+ sodipodi:insensitive="true">
+ <image
+ y="0.0079936879"
+ x="0.010951031"
+ id="image4124"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAAAA3NCSVQICAjb4U/gAAAACXBIWXMA ADFOAAAxTgGacD5yAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAABF0RVh0 VGl0bGUAUGViYmxlIEljb26KvwD9AAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAAY3RFWHRDb3B5 cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0dHA6Ly9jcmVh dGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzMuMC/eBBrlAAABy1BMVEUAAAAAAAAA AP8AAIAAAFUAAKoAVaoAAIAAQIAAM2YAM5kzM2YzM5krK4AkJG0kJJIgIIAcHHEcHI4aGoAXF3QX F4sXLnQXLosVK4AUJ3YUJ4kSJIAkJIARInciInciIogQIIAgIIAeHngeHoccHIAcK4AbKHkbKIYa GoAaJoAYJHkXI4AXI4sWIXoWIYUVIIAfH3ofH4UdJ4ATHIQcJnsbJIAaI4QaIoAZIXsZIYQYIHgY IIAXH4MXJ3wXHoAXJoBLpfAWJHwdJHwdJIMcI4Acas0VIoMbIoAaIXwaIIAZH3wZH4MZJXwYHoAY HoYYJIAehtsYHoIefdtHoO0jetZGovNEn+4gg+EfgN5Ep/VKqfREpvQgfdkgfttKqvQggd4hjesw nPMwnPRJqfUhgd4hjutJqfUgfttNq/VNq/QhlPEhlPIhlvMkmPMglvIff9sgjeoglPAhk/AhlPEh lPIhlvMkl/MkmPMlmPMmmPMnmfMpmvMzn/Q0n/Q1n/Q2oPRIqfVNq/VasfZbsvZhtPZitfdtuveQ yvmXzvmj0/qk1Pqu2fu83/vR6f3T6v3Z7f3i8f3j8v3l8/7m8/7p9f7q9f7x+P7y+f7////ougeB AAAAb3RSTlMAAQECAwMDBAQFBQUFBgcHCAkJCgsLCwsMDQ0ODg8PDxAQERESEhMTFBQVFhYXFxgZ GRobGxwdHh8fICAhISIiIiMjIyQkJSYnKCkpKSoqKiorKyssLC2xs7O0tba2tu7u7u7u7+/w8fHy +/v7+/ywJhX9AAAC0klEQVRo3u2a+VfTQBCAm00iW0nSTVNKbKQYbIMUC6jU4q2IB2rF+74vPEAR LSjIIXgAAiqef65py6MNJe17y1RF8/26L/N1N9O8ZGZcLof/DiYFoiV1cZHwiGVZjh7zalRAYYbn sFsQ6RFwGccyBeJjwSMrPn8lHX6fQqRy3tbAsFiU1So9FA4bNIRD63XNTwTO5pSYc9fuvwbgwaW9 SxqYs/eGBgcAGBy+fRrlpRPDsJdfJoEYuMgvzibz/vK3kmDcFMuxNZvM/BG74QTdFV6PJZsYlhe9 XXCCrlCVStwcyhG45QCkoCmiV0o4uwXEib4aQMHjbc0RTXYvCMx/mGdNHaSgraWxRhEXzsgUEC0C KWjfszlUYRWsrYcUHGttNlSRzxUEo5CCjrZYrSqVVlD3Nwj6vqcZsovzKrPeTy3o/5lmxE4wnFl/ 4QhKLvjy2YY5IEExHMH0B1umQQSj9o+EUUewIBh/m8c4qOBbfu58dQRLCmYnJmZLKphKJicdwR8X vJsymSmhIM37FSv4OJlhxnlUOAJHAC8Ye5PHmPNe9PsEJX/5dT5AigvmPtkw53wnr3xB34809uWc zDp9OWflV7xgBaBlzY79MWORALhuui+vbhoArfwe3rUp5MtWfl2Il1QDUtAaj+rKakv1XdEhBfGm Wo1kq+/pM1Ih+wdRI6iIq3IFvEAewgke6QHFg1mU26PjhbtwghsKEbG1X8ew3PmnUPGfXRAwv7hr yqAD13t6nwPQ23PnBMeivG4gg85c7XwCQOeVU+avZ5bslcrrNm7dfehIInGSguOJo+0Hd27ZoOam j3ULvOjTI02x+PYddLTEY42GJpfnpo/L0g50k8pqI1IfbWikoSFaXxfSFKnMrqFsZpKbKKoWDFbT EdQCqlfCdhtIG7AoESLTQogk4EJd/VRTn8fLgecKTg3MjyUsA4SKDD7MT1bQ8++NmvwCRmydh2Fg UvEAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.710279"
+ width="28.710279" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_galaxy_buds_disabled.svg b/app/src/main/assets/ic_device_galaxy_buds_disabled.svg
new file mode 100644
index 0000000..9144019
--- /dev/null
+++ b/app/src/main/assets/ic_device_galaxy_buds_disabled.svg
@@ -0,0 +1,242 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_galaxy_buds_disabled.svg"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16"
+ inkscape:cx="7.34375"
+ inkscape:cy="7.65625"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer6"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#9f9f9f;fill-opacity:0.94117647;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#8a8a8a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer6"
+ inkscape:label="Foreground"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer7"
+ inkscape:label="Backplane"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer8"
+ inkscape:label="Frontplane"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer9"
+ inkscape:label="Base"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer10"
+ inkscape:label="Top"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer11"
+ inkscape:label="Toptop"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer12"
+ inkscape:label="Toptoptop"
+ style="display:inline" />
+ </g>
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#8a8a8a;fill-opacity:0.94117647"
+ d="m 12.48275,10.865376 h 3.907 v 6.978 h -3.907 z"
+ android:strokeWidth="1"
+ id="path2735" />
+ <g
+ id="g2507"
+ transform="matrix(0.99999822,0,0,1,0.29668164,-0.00953878)"
+ style="display:inline">
+ <g
+ id="g12091"
+ transform="matrix(0.43308665,0,0,0.43752425,-17.418784,-45.717514)"
+ style="stroke-width:1.14863;stroke-miterlimit:4;stroke-dasharray:none">
+ <g
+ id="g12078"
+ style="stroke-width:1.14863;stroke-miterlimit:4;stroke-dasharray:none">
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#fcffff;stroke-width:1.14863;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 89.229326,149.75243 c 1.422876,1.97773 0.400592,5.02874 -3.539949,5.03091"
+ id="path10585-6"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#fcffff;stroke-width:1.14863;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 85.608172,141.34954 c -2.666757,-1.07007 -4.567397,-1.03074 -7.019236,-0.32613 -1.148282,0.32999 -2.049576,1.68227 -2.142569,2.8734 -0.428995,5.49489 2.903496,8.17341 6.263367,7.93085 2.05934,-0.14867 4.130587,-2.74489 4.627575,-5.01288 0.41816,-1.90826 0.08389,-4.73774 -1.729137,-5.46524 z"
+ id="path5256-7"
+ sodipodi:nodetypes="ssssss" />
+ <path
+ style="fill:none;stroke:#fcffff;stroke-width:1.14863;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 83.287036,138.80034 c -3.534371,-0.50931 -7.153563,0.99406 -8.625608,4.36799 -1.972926,4.52216 0.871682,11.86623 6.124093,12.64208 9.667201,1.42779 12.807218,-15.52506 2.501515,-17.01007"
+ id="path3567-5" />
+ </g>
+ <path
+ style="fill:none;stroke:#fcffff;stroke-width:1.14863;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 81.17037,134.62224 c 1.528272,0.40147 2.479848,2.50023 3.65606,3.48042 1.57278,1.31064 3.329209,2.02976 4.365471,3.91113 0.788477,1.43151 0.572308,3.47576 1.409777,4.73562 0.43788,0.65873 1.309196,0.96445 1.803823,1.61735 1.540471,2.03338 1.370542,5.10243 -0.479425,6.88871 -1.219739,1.17764 -3.141402,1.6612 -4.790555,1.28077 -0.702868,-0.16221 -1.380432,-0.74583 -2.116667,-0.68541 -1.719445,0.14105 -2.786803,0.97944 -4.618181,0.50261 -6.467763,-1.6839 -8.890577,-9.24373 -7.458556,-15.10934 0.757382,-3.10205 4.540443,-7.59068 8.228253,-6.62186"
+ id="path3559-3" />
+ </g>
+ <g
+ id="g1406"
+ transform="matrix(0.64963,0,0,0.65628639,-41.657575,-73.118779)"
+ style="stroke-width:0.765756;stroke-miterlimit:4;stroke-dasharray:none">
+ <path
+ style="fill:none;stroke:#fcffff;stroke-width:0.765756;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 80.707855,121.04264 c -1.01885,0.26765 -1.65323,1.66683 -2.43737,2.32029 -1.04852,0.87375 -2.21948,1.35316 -2.91032,2.60741 -0.52565,0.95434 -0.38154,2.31718 -0.93985,3.15709 -0.29192,0.43915 -0.8728,0.64296 -1.20255,1.07824 -1.02698,1.35557 -0.91369,3.40161 0.31962,4.59246 0.81316,0.78509 2.09427,1.10747 3.1937,0.85385 0.46858,-0.10813 0.92029,-0.49721 1.41111,-0.45694 1.1463,0.094 1.85787,0.65296 3.07879,0.33508 4.31184,-1.1226 5.92705,-6.16249 4.97237,-10.0729 -0.50492,-2.06804 -3.02696,-5.06045 -5.4855,-4.41458"
+ id="path3559" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#fcffff;stroke-width:0.765756;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 75.335215,131.12944 c -0.94858,1.31848 -0.26706,3.35249 2.35997,3.35394"
+ id="path10585"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#fcffff;stroke-width:0.765756;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 77.749325,125.52752 c 1.77783,-0.71339 3.04493,-0.68717 4.67949,-0.21743 0.76552,0.22 1.36638,1.12151 1.42838,1.9156 0.28599,3.66326 -1.93567,5.44894 -4.17558,5.28724 -1.3729,-0.0991 -2.75373,-1.82993 -3.08505,-3.34193 -0.27878,-1.27217 -0.0559,-3.1585 1.15276,-3.64348 z"
+ id="path5256"
+ sodipodi:nodetypes="ssssss" />
+ <path
+ style="fill:none;stroke:#fcffff;stroke-width:0.765756;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 79.296745,123.82804 c 2.35625,-0.33954 4.76904,0.66271 5.7504,2.912 1.31529,3.01478 -0.58112,7.91081 -4.08272,8.42805 -6.4448,0.95186 -8.53815,-10.35004 -1.66768,-11.34005"
+ id="path3567" />
+ </g>
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.028144173"
+ x="0.013794855"
+ id="image1291"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAANW0lE QVR4nO2dS4xc2VnH/98591VVXd3TbntC92ggwZ5R1BWRRZMoIQG1UIQIs0BCcpINESyyQIqySlgl 2I6yQ2xmtpEQilgQI4EUiAQaQQtCFAiNQjLVUWbsjII19iS2u931vI9zzseiqtxP21V9b7vrlr/f pp/33HPr/O53vvO4VYAgCIIgCIIgCIIgCILwLEBnUz6f8mmfZR7ZpKfyop+GQPvKHNX52mmLKjyS K8NGONAEhclUdMMOy2PsSdMcfr08+LK+JTKdNhurQ0GuD3/RGP58hfc1eSESeUUUMuSQPE0CLgPr ILRv0xp2EMd3CHf3DkiS+yJTQYTh0p4QjS1E0TJvYhGorzA2VjGQ6RoAHklEKECiIhuQDsiz3qC1 9jLF8R1KknNkzDYZs0vAi3CuJeKcEkrNM3ALnrfAnneOw3Cbo2iZN+t3GBtNHkSjh5FoagQ6Ik/j LlSSnKM4NirLbit3fo7Y9AhYgrP9vTjq+iJTTkhVHoqgdIWB+yCvyupeh31/xUWR58Jwm5sX4Abd 21ZhEhUo0NUD8nS7UGkKlS1C2QRq3kA5F5KrJ8Q2IaAOdqnIUxCkAgbaIB2yaoesVMItD06HcP4O XBDA1WpwA4lGkegqYwoEok++ygE/3/+CJv6MVrjIwEIB5QpFw9xyUG9ZuL+Jt+6+tnH1vUneKJRX IPrEN/nFiHrfJqBhHJBYwLqcpQqnglZAqAFPAc7xG0miX3n9s9GtPCOzkwpEAPDJVznQy73NxKBx p8PoZjJBWAbmAsJyjeArfqP747sf2rj6V+mhIf6IJzaomvDchIcJM4Of73+hm6Lx0wdO5CkRnZRx 84FDz9AHgpeWPj+Ycrk2zGOv0j5vnhhgJhHo0DzPNXJwn77VcnDiTulwDNxqOZDWn8LaosI6FLBK B2Vi4GHQOJ5xJxKPThKuN+h+D5esyFNaLAPbfXfp0m6owhgcre0MJx8xnDN68sTjBDPRI3lWCeug xl2oTsbVgq5FOCNaKeba7dCL43nX7d7hS17IYQxurjccNpo4JNERxunC6KA8Ww/neTJb4JUIZ0Jm gbSmvbj2rtefr+leb0l3u1CNu1BYb+zr0h52ZwcYMwcadVt78vT7HS29V/lhANVAedX+olfrWC+t 93SaDtr4oETH8ySB9mXkl7HWXqbRDLN97sKkIzhhGmGmNA79xE/8JHDeXFLVab2i03SwFLXWvj1Y FN+LQgcYX4L1LYrjO2TMLmWLUHNJXxd4GcIZ4uvMDzx4WdLxsijQc0lfZ4tQcfy2iuMVOrQF50A0 GiOJHnZf7UVKkpCcWyKbQJkalF/whQhng/XIQwYKgwBpljIZsE2gnFtwgy03Sxh2Y0dC0JgR6DLW sAZjlsier9G8gXLZxJOQwpTiZ/Csb7Q1mY5Mql0V6jkXkT3fIWOWht0YcNzO0oklcLZLzsVkLRSY ZTV9BnAM5RtPs4NyUUDOBeRcTM7ODdt37ZHHji3QKP9h0yPnIpKtGLMDe1BgQy4g5SwUc0rOxYfa 9/Kxx55gS+sSsNAHerUTVFWYRpg9YoDAIOaM2PEgSJgeGZNRHIOAxWOPHTMCjbLwF/edNJMINDNY YjYUPvx5/OBwghyoP9xRKMwWIdgZAioTBQcZSQkD2J4oKIhAQi5EICEXRT5Y+FR56ZzC5z7o46Mv eHi+RvBLditkDvhFl/Hddwy+/oMMb+2UcyN5KQX67Ad8fOXjIXSJU3lfAS/UCZff7+MPXvZx9TsJ /rqZnXW1JqZk9y3wykUPV36z3PIcRivgq78V4nd/tXz3c6kECjTw5Y+Fp/6eNGcBAfjKx8LSdcWl qu5HVjTeU5tFfQYszxE+vFKuXTKlEuilc6Wq7ol4uWTXWKra1vzZjT4j5kp2jaUSSJg+RCAhFyKQ kIvyTTw8ht2kHA8aLYSPznNePqfwexc97MSM/3nXIpnyZ+9mSqBf/8supv1RawJw80/mHvn3Vy55 eOXSoFluPnD4o3/o45329F6UdGFTzMXnFL78G+GT//EMEYGeMnrCV/xDy9M9sSgCPWUmXaqoTPm8 kAgk5EIEespM+mZcjqc3gQZmbBR2HH/+2xF+7fmzuU9++AuHL/1LfOB36YTD8p9sT/dGs5kX6MU6 4aXFsxFop58vemQO+Iv/TAuqzekw8wKVjTe3HW7sOPxfy+Hv3zR4UyKQMAn/eMPgtc3pjjr7kSRa yIUIJORCBBJyIQIJuRCBhFyIQEIuRCAhFyKQkAsRSMiFCCTkQgQSciECCbkQgYRciEBCLkQgIRci kJALEUjIhQgk5EIEEnIhAgm5EIGEXIhAQi5EICEXIpCQCxFIyIUIJORCBBJyIQIJuRCBhFyIQEIu RCAhFyKQkAsRSMjFzL9D2a02Y/GMPhH51hR/REFRzLxAh98lVSgW6cKEXIhAQi5EICEXIpCQCxFI yMVMjcL++49rZ12FZ46ZEuhxn0UqnA7ShQm5EIGEXIhAQi5KJVAnm/21pXZarmsslUA3pvyzs4rg rTNa+D0ppRLoe7ct3u2W6w6dhDsdxn/dnvAzMc+YUgmUWuBr/5FgFhViAF/9ToKsXAGoXAIBwLdv Glz99wS2ZC/047AO+LN/S/BPb5uzrsrElHIi8RtvZPjebYvPfdDHR1/w8Es1gi7ZrWAc8PMu47vv GHz9B1npcp8RpRQIAN7advjTf00AJGddlWeakt23wrQhAgm5EIGEXIhAwgDSJ5odmVggpStMOpzF qZhnnASkPAb6Ex01pkCrQ2FuTVgpoRxoJvJ4NJ4lGn9B7gRd2H1gd7KTCNMNkWGCZpBhIp9JBaxU zORV2fMWOIqWH9nWYwsURcvseQtMXnVQuApEoBmBDBzIskojRypjooCVig617/Vjjz1BDlRjpSLW Gg5EItEMoAgu87Ql1Xc6YadUykpFrHRn2L6bjz52vFNcxyY24XnnWN/rcsuDI5WIPDNC5sPozFql 4foKTvXgHqiY9b059rz7vFlfGbb1lSNtPoZAVxhoMOorHIbbrNQ86xDOj8Ny7TsQHok2bJSX2TRj 4/mB9Tw4HcIptcthuDSUpnFswBi/C9tY5UEe1GJ/B67loZyrf8IRUsMms37mh3MmSGE6YcX6O3BR 9D4XRbcZG6v75Tkg0pMEYmD0qMx1bNbvcK0GFwRwfrVjwdKLzQAchEHmJzBhqkwn7Fn94K4LArgw 3B52X6ME+uhjU2NGoGE3trHKzeg212pwldacBUsQKj3s0Df9LK5VsnYltkG7aiuVOVurwTUvwGGj OWj7Y/IfYDyBhlHoCgNbjM1PuOYFuFoNDnHaK/ZqhKcNx0k76FoTtPu21oWpVu/vk2d1nzwE4Ohm 0AmG8fsk2mhy8wKc6/duzOT+0mcFZnC//9N6/ZfN4iJMrQZ3YyHZF3m29stzLONuKGMANCiIGbgG bDTBH8+uc2t3jebnAZLHiksFM7jdBiW9v72x8IZDfWWYLDcBrDDQwCF5jg0Vk+xIPCKR+8ndV+n9 5/8QzjWoXge80m5wfLYwGbjdAZLkR+n/3nwNmztuIM0W9sQBniTPgf+YEBqV+ytf/OF7qVL5Fitq kO8D2gdkgno6YQJsBs4yENOP2HV//2df+7ufDf54RBrgMeKMyNvvEMB4z+98o1r5yIc/D8efBqmL rNR8znKFU4Act8C4odhe737/+6/9/J+/FQ+S5KsjUSa+8wsRCLhGAFSjAXXvXsdPa+e9auK81E/9 wKt4Jkt9Z632fa3ZQTFbAhQhAJidJE85IVKMFAAcE2kmBZdl1iqtrfbIZNZkfgoTV5FW491sYWHB DhJluMeNsMYhb9LCg+z5KoBVjqLXuVJZsejeQ78+xxUvQZakHEaBsybTzkJzpCmwRgEAsyfyFAE7 UGAYAFKtHSWW/TCz2oNN0tQGEZtQOWPb1nqV4faMjcVhznNyeYD8EWhYBgP4lMJ6gxp3oXZ353Wy 0NK1LrwsCrTJUh2ZVNuQlLNQQAVgQ8yZCFQQRP5AAspYaTidsIu9wHp+YP04td0aTLg7bxcWWnZv mP5Nl1egAodNDcbGKpprO3wpSZzbAXXqPTsXA2TAWTVw1kKx84k5I+aU5A3piqOLFEQBE1WZkLEL 4fwenGdT1wl7Ntipuqjqub3oU8wMXhEC7evGrgP1Bocx2Bg4tKvoLoJtAjWfQjkXk3MgnksIAAzq BZxeAIAQbQAJqANWKmZlI25Fg1X1YKe6b20LDLyOwXA9X/QBiunChuWMkukmjbqyJDlHcfy2cm6B 7PkaOdslNj1ytipd1ymhdG+wa1TXWN/rslK7HEXvc2G4zXvLEwdmmHMJVGAXdnCWurnecGvtkMJw gZPkPpkWyJjdoTjzcK4lEhWMUvMM7ALYheeBvep9DsMljqJwEHk2msOu6/HLE5NQlEDHLnVsYpWx fofQTmgNy4jjUa1bBZ1WOEgLwAKAwR72TdwG6hjmPK/j0MIoUEAeVHQUeDhDPZwbGtIk4PLw+y3C esFnFfbYAPYew7qOgzsJi5UHKF6gQ+WO6nhNuqszY/x1rZNw2g0r4kwPskApCIIgCIJQEP8P03/B 430mofMAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.670942"
+ width="28.670942" />
+ <image
+ y="0.0055872202"
+ x="-0.031073861"
+ id="image3082"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAAL6klE QVR4nO3dTYvcRhrA8adKJZW6Zc0LmTEmmYUF5zYOJjQLOSTQEMhlA7uHsNlPsCHg5DP4MzghHyC3 xZvTGh8CgYaQXMwEkngOZo1f2JAYz2ReLHePSqqqZy+t2Z5xj6c1pbZa7ecHxo09LdVEf0rVkjIG IIQQQgghhBBCCCEvA9bw7ZPJ4TQ2Oo0DPG6bFFJ9xoVTWUxVH1g25vXxfTAAgG63W/GuSa/XK14e DwTH/HklEVUZ0PF4ilBYkiQMACBN0yP7U0rRzFQRKeWRIMIwRACAOI6x1+uNBlRpRNMIiAEAK8JJ 05QppZjWmmmtGQCAtZbCmRLOOQIACCFQCIFSSgzDEEdCGo1oZgJ6Jp6trS2ulGJpmvI8z/nKysph QMaYw/1STO6KaAAAPM87DGh7ext937dhGFopJa6urtqqI6oyoCPx9Pt9nmUZX15e5koprrXm1lpm rWWIyOI4pngqxDnHJEmAMYacc+ScoxDCSint7u6uDYLARlFkx0RUe0Ds2rVrQRRFn3qe93fO+UVE XKxgu6R6TxDxP4j4z4cPH3529epVNfzzM0fkGhD78ssv/2CtvckYW7fWgtYaEKdyyYE4YoyBEAI4 54CIt5VSf/7oo4/+O/IlpQ/cWQNiAADXrl0LFhYWNowx60mSQJZlZ9wceZGCIIA4jsHzvNsPHjz4 09WrVzM44/UiXnLfhx/PAQCiKPo0z/P1nZ0diqdBsiyDnZ0dyPP80tra2hUYWcPC+Gt5JyoT0DPX eRhjH+7t7dEpq4EQEfb29oAx9rdOp8O73S6H8SEdj+oIMeH+xl0kZP1+/3WKp7kQEQ4ODl7f39/n aZpip9NBAICNjY1xH/MZjDmlTRpQ4chH9SzL2mcdPJkNSqlzSZKINE1tv99HIQSur68X14yKLztx lpjkFDb2ImG/3+fGGLfRk9oZYyCKIhFFkVhYWPAGg4HX7/f51tYW73a7405nR0y6BnomnoODA6+i 74HUTCklDg4OxNOnT0Ucx16WZXxMRGOddgo78sYkSVhxhXlpaansJzgygxCR+b7ve55nfd83aZqa OI4hSRIQQuDwRjjCCWugiSPodrssTVOmtWbD2xM0A80Jz/P8LMuEUkqEYegppbzl5WWepilP05QN Z6HCkUllkkU0AwCWJAlTSjFrLSvubVX6XZDaaK2F7/ssz3PgnKPWGpVS3FprRx65cZuBhjtiw7vq vN1uU0BzwvM8YYzxgiDwtNZeu93m1lpmjGFa68PnucBhEX3IGMOGG+eISHfT5wAiciGEZ609fGKi +HXaeycOqFj/aK0n3jhpBmstR0RmreVSyuI1O3/+/KnvpdMQgeGZhCEiK+Kx1h5OGMcfRR5V5jrQ uJ2SORAEAUNEFoZh6feeaQ1E8cynYllS5vjSKYwAwNnPKBQQcUIBESdlH+eYGa+++ip0Oh1YW1uD KIqAsWYty4wxkCQJ3L9/H27dugX7+/t1D+lMGhnQO++8A2+99Vbdw3DieR4sLS3Bm2++CW+88Qbc vHkT7ty5U/ewSmvcKazT6TQ+nuOEEPD+++/D2tpa3UMprVEBtVotePvtt+sexlRwzuHdd9+texil NSqgixcvQhAEdQ9jas6fPw8rKyt1D6OURgXUtP+4Z/HKK6/UPYRSGhWQ7/t1D2HqpJR1D6GURgVE Zg8FRJxQQMRJIy8kjoOIoJQ6/Qtrxhh77jrnwoULoJSCfr8Pv/76K1hrX+DoypubgAaDAXzxxRd1 D+NU586dg48//vjEv798+TJcvnwZAAAePXoE169fhzRNX9TwSqNT2Ay7cOHCzF84pYBesHa73I8T mPXbGxTQC1b2WtasX/uigIgTCugFK/upatZ//tLcfAo7yQcffAALCwu17PvevXuj//wAAAA8ffq0 1Da2trYqHFH15j6gpaUlWF5ermXf4w5+mRklyzL4/vvvqxxS5eY+oKZ59OgR7O3twc7ODty+fXvm H3WlgGbMjz/+CD/99FPdw5gYLaKJEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgT Cog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMK iDihgIgTCog4oYCIEwqIOKGAiJO5/wllu7u7YIypZd9JktSy3xdp7gP66quv6h7CXKNTGHFCAREn FBBxQgERJxQQcTI3n8La7TZ88skndQ/jVIyxuodQqbkJiDEGYRjWPYyXDp3CiBMKiDihgIiTRgWU ZVndQ5g6pVTdQyilUQH9/vvvdQ9h6pr2PTYqoLt37871LPT48WPY3t6uexilNCqgNE3h22+/rXsY U2GthW+++abuYZTWqIAAAH744YeZ/3dEy8rzHG7cuAG//PJL3UMprZEXEr/77ju4d+8edDodWFtb g3PnzjXuCq8xBp48eQL379+HW7duwZMnT+oe0pk0MiAAgN9++w1u3LhR9zBeeo07hZHZQgERJxQQ cUIBEQAAYIzhWd5XOiDP8/CsOyOzjXNe+rhOGhAFM8eyLEPGGKZpCgDlZqMzn8JoFpofw2OJjDFk jCHnHDnnKIRAIQSGYXjisZ44oDAMDzdY7KCS0ZPacc7tMBybpulhRI8fPz79vWV35nkecs7R8zxL s9B8YIxZrbXhnFvP82wxQUwySZQKSAiB29vbKISw/X6f4pkTxhjteZ4ZxmMHg4EdThIohMA4jotj /cwxnyQgBACM4xillMg5Ryml9X2/np9YQConhNBZlpk8z7Xv+0YIYaWUdnisT4wHoMQM1Ov1sFgH 7e7uWiGErWT0pHZaay2lzKWUOssyLaU0u7u7NgxDG4Yh9nq90XiOhHTazVQEgMPb3HEcY5qmFgAg SRKDiI27C06egVLKfDAYWEQ0Ukq7t7dnW62WlVKOnr7GKnMdCItZKIoi22q1DCItg5oOEUFrnUdR lLdaLZMkiWm1WiaKIru6umqHs0/x6xmTroGK33FjY8Ourq7aKIpslmWDir4PUhOlVNLv93WSJKbf 7+t2u/28eJ6JqOzzQAgA0Ov1oNvt2sFgcFdK2aHTWDMhIgwGg3txHOtisRyGIW5sbIyGU9kpbPQ1 9no91FpfT5IE6FTWPIgISZKAMeZfi4uLdnV11W5ubpqNjQ0LABaejWfsQfZK7vfIVPPaa69tRFH0 V2PMed/3gXO6ud8EWmtIkgSyLPv5zp07/+j1evrBgwfPi+XEGeKs557D9125cuWPUsp/c87XhRAg RGOfkn0paK1Baw3W2p+VUn/5/PPPHw7/alwkp55aXBcvDADgvffea1+6dOkKAHzIGLvIOV9w3C6Z AmvtE0S8i4jXNzc3P/v6669TOGWRfJpKAhr+ztfX1/n29rYfRZFQSgnf9/08z4UQwjfGeL7ve9Za joiseO/wNXEwck+yuBFq8zw3nueZPM91EAR5lmW63W5naZrmi4uLZnNzc3StA3CGeADc/6+M0QuN GIYhtlot0+/3IY5jPDg4gCAIUGttpZSeMcYDABYEAQegeKpURJTnuUVElFKaLMvM8HcdhqEeXuMp Hs84dYE8iaoWLAgArLhSrbVmUkqmtTZSSuCco9YajTFWSskRERCRUUDVKQISQqBSyiKiDYLA+L5v ENG0Wi2TZZmNosg+7+Zo6f26bgCOnsZYp9Ph+/v7fDAYeHEce0opT2vN2+02N8Zway2jeKZj9IEw z/PsYDCwwxujJkkS0263zeLioh1+VHda+xSqmIHG3S9DrbVNkgSWl5dRKcWzLOPWWlYENPzaCnZP AP7/zyoUARljMAxDK6W0u7u7NgiCk+5tOc1CVc0CR2ahbrfLtra2uFKKpWnKrbXMGMNWVlaY1poZ Y2j2mZLiGZ7t7W0sHv4bhoST3p4oo+qAites2+2yJElYmqZMKcW01kxrffh11lqKqGKjTxAWjx9L KTEMQ4zjGE+4MToTAR3f1uHH9CIkAIA0TSmaF6R4EH4kHICK4wGoNqDj25vkNZmOkyKpNB6A6R3M 49ulaOpT6aL5uGkfWApndtAjE4QQQgghFfkfLnfEbGu/P8gAAAAASUVORK5CYII= "
+ style="display:inline;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.697369"
+ width="28.697369" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_galaxy_buds_live.svg b/app/src/main/assets/ic_device_galaxy_buds_live.svg
new file mode 100644
index 0000000..acb6313
--- /dev/null
+++ b/app/src/main/assets/ic_device_galaxy_buds_live.svg
@@ -0,0 +1,166 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_galaxy_buds_live.svg"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16.000001"
+ inkscape:cx="7.5312497"
+ inkscape:cy="18.906249"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#1f7fdb;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#4dabf5;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ <g
+ id="g3814-7"
+ transform="matrix(-0.87287371,0,0,0.8814596,23.235344,11.404728)"
+ style="fill:none;fill-opacity:1;stroke:#fffffd;stroke-width:0.861768;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
+ <g
+ id="g1577">
+ <g
+ id="g3814"
+ transform="matrix(-1,0,0,1,19.724468,-6.6444907)"
+ style="display:inline;fill:none;fill-opacity:1;stroke:#fffffd;stroke-width:0.861768;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
+ <path
+ style="fill:none;fill-opacity:1;stroke:#fffffd;stroke-width:0.861768;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 6.3534766,13.150028 C 5.2590543,14.102574 3.7589552,14.39697 2.6009409,13.886465 1.3699015,13.343766 0.52234461,12.049487 0.50706651,10.688971 0.49818111,9.8974897 0.68425351,9.3259096 1.2026925,8.5521587 1.6467421,7.8894314 1.7546703,7.5633672 1.7383195,6.9339544 1.7242951,6.3940166 1.5887612,6.0540278 1.1304302,5.409047 0.69461341,4.7957481 0.51189951,4.3487514 0.43456241,3.7066569 0.29489991,2.5470862 0.74072801,1.4253568 1.6710145,0.59567225 2.8470953,-0.45322535 4.5920415,-0.46138405 5.9962565,0.57544755 7.6247819,1.7779049 8.5957069,4.0874446 8.6379302,6.8591974 8.6637127,8.551633 8.3637849,10.047519 7.7501356,11.287081 7.5146695,11.762713 7.3453654,12.031594 7.0219979,12.443468 6.7260442,12.820427 6.6974699,12.850626 6.3534853,13.150033 Z"
+ id="path11168-8" />
+ <path
+ style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#fffffd;stroke-width:0.861768;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 6.5908811,5.6846666 c 0,0 0.5148355,0.502981 0.476276,1.6957252 -0.03255,1.0068473 -0.6176138,1.4771457 -0.6176138,1.4771457"
+ id="path2468"
+ sodipodi:nodetypes="csc" />
+ </g>
+ <path
+ style="fill:none;fill-opacity:1;stroke:#fffffd;stroke-width:0.861768;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 6.3534766,13.150028 C 5.2590543,14.102574 3.7589552,14.39697 2.6009409,13.886465 1.3699015,13.343766 0.52234461,12.049487 0.50706651,10.688971 0.49818111,9.8974897 0.68425351,9.3259096 1.2026925,8.5521587 1.6467421,7.8894314 1.7546703,7.5633672 1.7383195,6.9339544 1.7242951,6.3940166 1.5887612,6.0540278 1.1304302,5.409047 0.69461341,4.7957481 0.51189951,4.3487514 0.43456241,3.7066569 0.29489991,2.5470862 0.74072801,1.4253568 1.6710145,0.59567225 2.8470953,-0.45322535 4.5920415,-0.46138405 5.9962565,0.57544755 7.6247819,1.7779049 8.5957069,4.0874446 8.6379302,6.8591974 8.6637127,8.551633 8.3637849,10.047519 7.7501356,11.287081 7.5146695,11.762713 7.3453654,12.031594 7.0219979,12.443468 6.7260442,12.820427 6.6974699,12.850626 6.3534853,13.150033 Z"
+ id="path11168-8-1" />
+ </g>
+ <path
+ style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#fffffd;stroke-width:0.861768;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 6.5908811,5.6846666 c 0,0 0.5148355,0.502981 0.476276,1.6957252 -0.03255,1.0068473 -0.6176138,1.4771457 -0.6176138,1.4771457"
+ id="path2468-7"
+ sodipodi:nodetypes="csc" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none"
+ sodipodi:insensitive="true">
+ <image
+ y="0.0079936879"
+ x="0.010951031"
+ id="image4124"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAAAA3NCSVQICAjb4U/gAAAACXBIWXMA ADFOAAAxTgGacD5yAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAABF0RVh0 VGl0bGUAUGViYmxlIEljb26KvwD9AAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAAY3RFWHRDb3B5 cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0dHA6Ly9jcmVh dGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzMuMC/eBBrlAAABy1BMVEUAAAAAAAAA AP8AAIAAAFUAAKoAVaoAAIAAQIAAM2YAM5kzM2YzM5krK4AkJG0kJJIgIIAcHHEcHI4aGoAXF3QX F4sXLnQXLosVK4AUJ3YUJ4kSJIAkJIARInciInciIogQIIAgIIAeHngeHoccHIAcK4AbKHkbKIYa GoAaJoAYJHkXI4AXI4sWIXoWIYUVIIAfH3ofH4UdJ4ATHIQcJnsbJIAaI4QaIoAZIXsZIYQYIHgY IIAXH4MXJ3wXHoAXJoBLpfAWJHwdJHwdJIMcI4Acas0VIoMbIoAaIXwaIIAZH3wZH4MZJXwYHoAY HoYYJIAehtsYHoIefdtHoO0jetZGovNEn+4gg+EfgN5Ep/VKqfREpvQgfdkgfttKqvQggd4hjesw nPMwnPRJqfUhgd4hjutJqfUgfttNq/VNq/QhlPEhlPIhlvMkmPMglvIff9sgjeoglPAhk/AhlPEh lPIhlvMkl/MkmPMlmPMmmPMnmfMpmvMzn/Q0n/Q1n/Q2oPRIqfVNq/VasfZbsvZhtPZitfdtuveQ yvmXzvmj0/qk1Pqu2fu83/vR6f3T6v3Z7f3i8f3j8v3l8/7m8/7p9f7q9f7x+P7y+f7////ougeB AAAAb3RSTlMAAQECAwMDBAQFBQUFBgcHCAkJCgsLCwsMDQ0ODg8PDxAQERESEhMTFBQVFhYXFxgZ GRobGxwdHh8fICAhISIiIiMjIyQkJSYnKCkpKSoqKiorKyssLC2xs7O0tba2tu7u7u7u7+/w8fHy +/v7+/ywJhX9AAAC0klEQVRo3u2a+VfTQBCAm00iW0nSTVNKbKQYbIMUC6jU4q2IB2rF+74vPEAR LSjIIXgAAiqef65py6MNJe17y1RF8/26L/N1N9O8ZGZcLof/DiYFoiV1cZHwiGVZjh7zalRAYYbn sFsQ6RFwGccyBeJjwSMrPn8lHX6fQqRy3tbAsFiU1So9FA4bNIRD63XNTwTO5pSYc9fuvwbgwaW9 SxqYs/eGBgcAGBy+fRrlpRPDsJdfJoEYuMgvzibz/vK3kmDcFMuxNZvM/BG74QTdFV6PJZsYlhe9 XXCCrlCVStwcyhG45QCkoCmiV0o4uwXEib4aQMHjbc0RTXYvCMx/mGdNHaSgraWxRhEXzsgUEC0C KWjfszlUYRWsrYcUHGttNlSRzxUEo5CCjrZYrSqVVlD3Nwj6vqcZsovzKrPeTy3o/5lmxE4wnFl/ 4QhKLvjy2YY5IEExHMH0B1umQQSj9o+EUUewIBh/m8c4qOBbfu58dQRLCmYnJmZLKphKJicdwR8X vJsymSmhIM37FSv4OJlhxnlUOAJHAC8Ye5PHmPNe9PsEJX/5dT5AigvmPtkw53wnr3xB34809uWc zDp9OWflV7xgBaBlzY79MWORALhuui+vbhoArfwe3rUp5MtWfl2Il1QDUtAaj+rKakv1XdEhBfGm Wo1kq+/pM1Ih+wdRI6iIq3IFvEAewgke6QHFg1mU26PjhbtwghsKEbG1X8ew3PmnUPGfXRAwv7hr yqAD13t6nwPQ23PnBMeivG4gg85c7XwCQOeVU+avZ5bslcrrNm7dfehIInGSguOJo+0Hd27ZoOam j3ULvOjTI02x+PYddLTEY42GJpfnpo/L0g50k8pqI1IfbWikoSFaXxfSFKnMrqFsZpKbKKoWDFbT EdQCqlfCdhtIG7AoESLTQogk4EJd/VRTn8fLgecKTg3MjyUsA4SKDD7MT1bQ8++NmvwCRmydh2Fg UvEAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.710279"
+ width="28.710279" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_galaxy_buds_live_disabled.svg b/app/src/main/assets/ic_device_galaxy_buds_live_disabled.svg
new file mode 100644
index 0000000..daf6955
--- /dev/null
+++ b/app/src/main/assets/ic_device_galaxy_buds_live_disabled.svg
@@ -0,0 +1,218 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_galaxy_buds_live_disabled.svg"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16"
+ inkscape:cx="7.34375"
+ inkscape:cy="7.65625"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer6"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#9f9f9f;fill-opacity:0.94117647;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#8a8a8a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer6"
+ inkscape:label="Foreground"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer7"
+ inkscape:label="Backplane"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer8"
+ inkscape:label="Frontplane"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer9"
+ inkscape:label="Base"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer10"
+ inkscape:label="Top"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer11"
+ inkscape:label="Toptop"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer12"
+ inkscape:label="Toptoptop"
+ style="display:inline" />
+ </g>
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#8a8a8a;fill-opacity:0.94117647"
+ d="m 12.48275,10.865376 h 3.907 v 6.978 h -3.907 z"
+ android:strokeWidth="1"
+ id="path2735" />
+ <g
+ id="g3814-7"
+ transform="matrix(-0.87287371,0,0,0.8814596,23.235344,11.404728)"
+ style="display:inline;fill:none;fill-opacity:1;stroke:#fffffd;stroke-width:0.861768;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
+ <g
+ id="g1577">
+ <g
+ id="g3814"
+ transform="matrix(-1,0,0,1,19.724468,-6.6444907)"
+ style="display:inline;fill:none;fill-opacity:1;stroke:#fffffd;stroke-width:0.861768;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
+ <path
+ style="fill:none;fill-opacity:1;stroke:#fffffd;stroke-width:0.861768;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 6.3534766,13.150028 C 5.2590543,14.102574 3.7589552,14.39697 2.6009409,13.886465 1.3699015,13.343766 0.52234461,12.049487 0.50706651,10.688971 0.49818111,9.8974897 0.68425351,9.3259096 1.2026925,8.5521587 1.6467421,7.8894314 1.7546703,7.5633672 1.7383195,6.9339544 1.7242951,6.3940166 1.5887612,6.0540278 1.1304302,5.409047 0.69461341,4.7957481 0.51189951,4.3487514 0.43456241,3.7066569 0.29489991,2.5470862 0.74072801,1.4253568 1.6710145,0.59567225 2.8470953,-0.45322535 4.5920415,-0.46138405 5.9962565,0.57544755 7.6247819,1.7779049 8.5957069,4.0874446 8.6379302,6.8591974 8.6637127,8.551633 8.3637849,10.047519 7.7501356,11.287081 7.5146695,11.762713 7.3453654,12.031594 7.0219979,12.443468 6.7260442,12.820427 6.6974699,12.850626 6.3534853,13.150033 Z"
+ id="path11168-8" />
+ <path
+ style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#fffffd;stroke-width:0.861768;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 6.5908811,5.6846666 c 0,0 0.5148355,0.502981 0.476276,1.6957252 -0.03255,1.0068473 -0.6176138,1.4771457 -0.6176138,1.4771457"
+ id="path2468"
+ sodipodi:nodetypes="csc" />
+ </g>
+ <path
+ style="fill:none;fill-opacity:1;stroke:#fffffd;stroke-width:0.861768;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 6.3534766,13.150028 C 5.2590543,14.102574 3.7589552,14.39697 2.6009409,13.886465 1.3699015,13.343766 0.52234461,12.049487 0.50706651,10.688971 0.49818111,9.8974897 0.68425351,9.3259096 1.2026925,8.5521587 1.6467421,7.8894314 1.7546703,7.5633672 1.7383195,6.9339544 1.7242951,6.3940166 1.5887612,6.0540278 1.1304302,5.409047 0.69461341,4.7957481 0.51189951,4.3487514 0.43456241,3.7066569 0.29489991,2.5470862 0.74072801,1.4253568 1.6710145,0.59567225 2.8470953,-0.45322535 4.5920415,-0.46138405 5.9962565,0.57544755 7.6247819,1.7779049 8.5957069,4.0874446 8.6379302,6.8591974 8.6637127,8.551633 8.3637849,10.047519 7.7501356,11.287081 7.5146695,11.762713 7.3453654,12.031594 7.0219979,12.443468 6.7260442,12.820427 6.6974699,12.850626 6.3534853,13.150033 Z"
+ id="path11168-8-1" />
+ </g>
+ <path
+ style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#fffffd;stroke-width:0.861768;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 6.5908811,5.6846666 c 0,0 0.5148355,0.502981 0.476276,1.6957252 -0.03255,1.0068473 -0.6176138,1.4771457 -0.6176138,1.4771457"
+ id="path2468-7"
+ sodipodi:nodetypes="csc" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.028144173"
+ x="0.013794855"
+ id="image1291"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAANW0lE QVR4nO2dS4xc2VnH/98591VVXd3TbntC92ggwZ5R1BWRRZMoIQG1UIQIs0BCcpINESyyQIqySlgl 2I6yQ2xmtpEQilgQI4EUiAQaQQtCFAiNQjLVUWbsjII19iS2u931vI9zzseiqtxP21V9b7vrlr/f pp/33HPr/O53vvO4VYAgCIIgCIIgCIIgCILwLEBnUz6f8mmfZR7ZpKfyop+GQPvKHNX52mmLKjyS K8NGONAEhclUdMMOy2PsSdMcfr08+LK+JTKdNhurQ0GuD3/RGP58hfc1eSESeUUUMuSQPE0CLgPr ILRv0xp2EMd3CHf3DkiS+yJTQYTh0p4QjS1E0TJvYhGorzA2VjGQ6RoAHklEKECiIhuQDsiz3qC1 9jLF8R1KknNkzDYZs0vAi3CuJeKcEkrNM3ALnrfAnneOw3Cbo2iZN+t3GBtNHkSjh5FoagQ6Ik/j LlSSnKM4NirLbit3fo7Y9AhYgrP9vTjq+iJTTkhVHoqgdIWB+yCvyupeh31/xUWR58Jwm5sX4Abd 21ZhEhUo0NUD8nS7UGkKlS1C2QRq3kA5F5KrJ8Q2IaAOdqnIUxCkAgbaIB2yaoesVMItD06HcP4O XBDA1WpwA4lGkegqYwoEok++ygE/3/+CJv6MVrjIwEIB5QpFw9xyUG9ZuL+Jt+6+tnH1vUneKJRX IPrEN/nFiHrfJqBhHJBYwLqcpQqnglZAqAFPAc7xG0miX3n9s9GtPCOzkwpEAPDJVznQy73NxKBx p8PoZjJBWAbmAsJyjeArfqP747sf2rj6V+mhIf6IJzaomvDchIcJM4Of73+hm6Lx0wdO5CkRnZRx 84FDz9AHgpeWPj+Ycrk2zGOv0j5vnhhgJhHo0DzPNXJwn77VcnDiTulwDNxqOZDWn8LaosI6FLBK B2Vi4GHQOJ5xJxKPThKuN+h+D5esyFNaLAPbfXfp0m6owhgcre0MJx8xnDN68sTjBDPRI3lWCeug xl2oTsbVgq5FOCNaKeba7dCL43nX7d7hS17IYQxurjccNpo4JNERxunC6KA8Ww/neTJb4JUIZ0Jm gbSmvbj2rtefr+leb0l3u1CNu1BYb+zr0h52ZwcYMwcadVt78vT7HS29V/lhANVAedX+olfrWC+t 93SaDtr4oETH8ySB9mXkl7HWXqbRDLN97sKkIzhhGmGmNA79xE/8JHDeXFLVab2i03SwFLXWvj1Y FN+LQgcYX4L1LYrjO2TMLmWLUHNJXxd4GcIZ4uvMDzx4WdLxsijQc0lfZ4tQcfy2iuMVOrQF50A0 GiOJHnZf7UVKkpCcWyKbQJkalF/whQhng/XIQwYKgwBpljIZsE2gnFtwgy03Sxh2Y0dC0JgR6DLW sAZjlsier9G8gXLZxJOQwpTiZ/Csb7Q1mY5Mql0V6jkXkT3fIWOWht0YcNzO0oklcLZLzsVkLRSY ZTV9BnAM5RtPs4NyUUDOBeRcTM7ODdt37ZHHji3QKP9h0yPnIpKtGLMDe1BgQy4g5SwUc0rOxYfa 9/Kxx55gS+sSsNAHerUTVFWYRpg9YoDAIOaM2PEgSJgeGZNRHIOAxWOPHTMCjbLwF/edNJMINDNY YjYUPvx5/OBwghyoP9xRKMwWIdgZAioTBQcZSQkD2J4oKIhAQi5EICEXRT5Y+FR56ZzC5z7o46Mv eHi+RvBLditkDvhFl/Hddwy+/oMMb+2UcyN5KQX67Ad8fOXjIXSJU3lfAS/UCZff7+MPXvZx9TsJ /rqZnXW1JqZk9y3wykUPV36z3PIcRivgq78V4nd/tXz3c6kECjTw5Y+Fp/6eNGcBAfjKx8LSdcWl qu5HVjTeU5tFfQYszxE+vFKuXTKlEuilc6Wq7ol4uWTXWKra1vzZjT4j5kp2jaUSSJg+RCAhFyKQ kIvyTTw8ht2kHA8aLYSPznNePqfwexc97MSM/3nXIpnyZ+9mSqBf/8supv1RawJw80/mHvn3Vy55 eOXSoFluPnD4o3/o45329F6UdGFTzMXnFL78G+GT//EMEYGeMnrCV/xDy9M9sSgCPWUmXaqoTPm8 kAgk5EIEespM+mZcjqc3gQZmbBR2HH/+2xF+7fmzuU9++AuHL/1LfOB36YTD8p9sT/dGs5kX6MU6 4aXFsxFop58vemQO+Iv/TAuqzekw8wKVjTe3HW7sOPxfy+Hv3zR4UyKQMAn/eMPgtc3pjjr7kSRa yIUIJORCBBJyIQIJuRCBhFyIQEIuRCAhFyKQkAsRSMiFCCTkQgQSciECCbkQgYRciEBCLkQgIRci kJALEUjIhQgk5EIEEnIhAgm5EIGEXIhAQi5EICEXIpCQCxFIyIUIJORCBBJyIQIJuRCBhFyIQEIu RCAhFyKQkAsRSMjFzL9D2a02Y/GMPhH51hR/REFRzLxAh98lVSgW6cKEXIhAQi5EICEXIpCQCxFI yMVMjcL++49rZ12FZ46ZEuhxn0UqnA7ShQm5EIGEXIhAQi5KJVAnm/21pXZarmsslUA3pvyzs4rg rTNa+D0ppRLoe7ct3u2W6w6dhDsdxn/dnvAzMc+YUgmUWuBr/5FgFhViAF/9ToKsXAGoXAIBwLdv Glz99wS2ZC/047AO+LN/S/BPb5uzrsrElHIi8RtvZPjebYvPfdDHR1/w8Es1gi7ZrWAc8PMu47vv GHz9B1npcp8RpRQIAN7advjTf00AJGddlWeakt23wrQhAgm5EIGEXIhAwgDSJ5odmVggpStMOpzF qZhnnASkPAb6Ex01pkCrQ2FuTVgpoRxoJvJ4NJ4lGn9B7gRd2H1gd7KTCNMNkWGCZpBhIp9JBaxU zORV2fMWOIqWH9nWYwsURcvseQtMXnVQuApEoBmBDBzIskojRypjooCVig617/Vjjz1BDlRjpSLW Gg5EItEMoAgu87Ql1Xc6YadUykpFrHRn2L6bjz52vFNcxyY24XnnWN/rcsuDI5WIPDNC5sPozFql 4foKTvXgHqiY9b059rz7vFlfGbb1lSNtPoZAVxhoMOorHIbbrNQ86xDOj8Ny7TsQHok2bJSX2TRj 4/mB9Tw4HcIptcthuDSUpnFswBi/C9tY5UEe1GJ/B67loZyrf8IRUsMms37mh3MmSGE6YcX6O3BR 9D4XRbcZG6v75Tkg0pMEYmD0qMx1bNbvcK0GFwRwfrVjwdKLzQAchEHmJzBhqkwn7Fn94K4LArgw 3B52X6ME+uhjU2NGoGE3trHKzeg212pwldacBUsQKj3s0Df9LK5VsnYltkG7aiuVOVurwTUvwGGj OWj7Y/IfYDyBhlHoCgNbjM1PuOYFuFoNDnHaK/ZqhKcNx0k76FoTtPu21oWpVu/vk2d1nzwE4Ohm 0AmG8fsk2mhy8wKc6/duzOT+0mcFZnC//9N6/ZfN4iJMrQZ3YyHZF3m29stzLONuKGMANCiIGbgG bDTBH8+uc2t3jebnAZLHiksFM7jdBiW9v72x8IZDfWWYLDcBrDDQwCF5jg0Vk+xIPCKR+8ndV+n9 5/8QzjWoXge80m5wfLYwGbjdAZLkR+n/3nwNmztuIM0W9sQBniTPgf+YEBqV+ytf/OF7qVL5Fitq kO8D2gdkgno6YQJsBs4yENOP2HV//2df+7ufDf54RBrgMeKMyNvvEMB4z+98o1r5yIc/D8efBqmL rNR8znKFU4Act8C4odhe737/+6/9/J+/FQ+S5KsjUSa+8wsRCLhGAFSjAXXvXsdPa+e9auK81E/9 wKt4Jkt9Z632fa3ZQTFbAhQhAJidJE85IVKMFAAcE2kmBZdl1iqtrfbIZNZkfgoTV5FW491sYWHB DhJluMeNsMYhb9LCg+z5KoBVjqLXuVJZsejeQ78+xxUvQZakHEaBsybTzkJzpCmwRgEAsyfyFAE7 UGAYAFKtHSWW/TCz2oNN0tQGEZtQOWPb1nqV4faMjcVhznNyeYD8EWhYBgP4lMJ6gxp3oXZ353Wy 0NK1LrwsCrTJUh2ZVNuQlLNQQAVgQ8yZCFQQRP5AAspYaTidsIu9wHp+YP04td0aTLg7bxcWWnZv mP5Nl1egAodNDcbGKpprO3wpSZzbAXXqPTsXA2TAWTVw1kKx84k5I+aU5A3piqOLFEQBE1WZkLEL 4fwenGdT1wl7Ntipuqjqub3oU8wMXhEC7evGrgP1Bocx2Bg4tKvoLoJtAjWfQjkXk3MgnksIAAzq BZxeAIAQbQAJqANWKmZlI25Fg1X1YKe6b20LDLyOwXA9X/QBiunChuWMkukmjbqyJDlHcfy2cm6B 7PkaOdslNj1ytipd1ymhdG+wa1TXWN/rslK7HEXvc2G4zXvLEwdmmHMJVGAXdnCWurnecGvtkMJw gZPkPpkWyJjdoTjzcK4lEhWMUvMM7ALYheeBvep9DsMljqJwEHk2msOu6/HLE5NQlEDHLnVsYpWx fofQTmgNy4jjUa1bBZ1WOEgLwAKAwR72TdwG6hjmPK/j0MIoUEAeVHQUeDhDPZwbGtIk4PLw+y3C esFnFfbYAPYew7qOgzsJi5UHKF6gQ+WO6nhNuqszY/x1rZNw2g0r4kwPskApCIIgCIJQEP8P03/B 430mofMAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.670942"
+ width="28.670942" />
+ <image
+ y="0.0055872202"
+ x="-0.031073861"
+ id="image3082"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAAL6klE QVR4nO3dTYvcRhrA8adKJZW6Zc0LmTEmmYUF5zYOJjQLOSTQEMhlA7uHsNlPsCHg5DP4MzghHyC3 xZvTGh8CgYaQXMwEkngOZo1f2JAYz2ReLHePSqqqZy+t2Z5xj6c1pbZa7ecHxo09LdVEf0rVkjIG IIQQQgghhBBCCCEvA9bw7ZPJ4TQ2Oo0DPG6bFFJ9xoVTWUxVH1g25vXxfTAAgG63W/GuSa/XK14e DwTH/HklEVUZ0PF4ilBYkiQMACBN0yP7U0rRzFQRKeWRIMIwRACAOI6x1+uNBlRpRNMIiAEAK8JJ 05QppZjWmmmtGQCAtZbCmRLOOQIACCFQCIFSSgzDEEdCGo1oZgJ6Jp6trS2ulGJpmvI8z/nKysph QMaYw/1STO6KaAAAPM87DGh7ext937dhGFopJa6urtqqI6oyoCPx9Pt9nmUZX15e5koprrXm1lpm rWWIyOI4pngqxDnHJEmAMYacc+ScoxDCSint7u6uDYLARlFkx0RUe0Ds2rVrQRRFn3qe93fO+UVE XKxgu6R6TxDxP4j4z4cPH3529epVNfzzM0fkGhD78ssv/2CtvckYW7fWgtYaEKdyyYE4YoyBEAI4 54CIt5VSf/7oo4/+O/IlpQ/cWQNiAADXrl0LFhYWNowx60mSQJZlZ9wceZGCIIA4jsHzvNsPHjz4 09WrVzM44/UiXnLfhx/PAQCiKPo0z/P1nZ0diqdBsiyDnZ0dyPP80tra2hUYWcPC+Gt5JyoT0DPX eRhjH+7t7dEpq4EQEfb29oAx9rdOp8O73S6H8SEdj+oIMeH+xl0kZP1+/3WKp7kQEQ4ODl7f39/n aZpip9NBAICNjY1xH/MZjDmlTRpQ4chH9SzL2mcdPJkNSqlzSZKINE1tv99HIQSur68X14yKLztx lpjkFDb2ImG/3+fGGLfRk9oZYyCKIhFFkVhYWPAGg4HX7/f51tYW73a7405nR0y6BnomnoODA6+i 74HUTCklDg4OxNOnT0Ucx16WZXxMRGOddgo78sYkSVhxhXlpaansJzgygxCR+b7ve55nfd83aZqa OI4hSRIQQuDwRjjCCWugiSPodrssTVOmtWbD2xM0A80Jz/P8LMuEUkqEYegppbzl5WWepilP05QN Z6HCkUllkkU0AwCWJAlTSjFrLSvubVX6XZDaaK2F7/ssz3PgnKPWGpVS3FprRx65cZuBhjtiw7vq vN1uU0BzwvM8YYzxgiDwtNZeu93m1lpmjGFa68PnucBhEX3IGMOGG+eISHfT5wAiciGEZ609fGKi +HXaeycOqFj/aK0n3jhpBmstR0RmreVSyuI1O3/+/KnvpdMQgeGZhCEiK+Kx1h5OGMcfRR5V5jrQ uJ2SORAEAUNEFoZh6feeaQ1E8cynYllS5vjSKYwAwNnPKBQQcUIBESdlH+eYGa+++ip0Oh1YW1uD KIqAsWYty4wxkCQJ3L9/H27dugX7+/t1D+lMGhnQO++8A2+99Vbdw3DieR4sLS3Bm2++CW+88Qbc vHkT7ty5U/ewSmvcKazT6TQ+nuOEEPD+++/D2tpa3UMprVEBtVotePvtt+sexlRwzuHdd9+texil NSqgixcvQhAEdQ9jas6fPw8rKyt1D6OURgXUtP+4Z/HKK6/UPYRSGhWQ7/t1D2HqpJR1D6GURgVE Zg8FRJxQQMRJIy8kjoOIoJQ6/Qtrxhh77jrnwoULoJSCfr8Pv/76K1hrX+DoypubgAaDAXzxxRd1 D+NU586dg48//vjEv798+TJcvnwZAAAePXoE169fhzRNX9TwSqNT2Ay7cOHCzF84pYBesHa73I8T mPXbGxTQC1b2WtasX/uigIgTCugFK/upatZ//tLcfAo7yQcffAALCwu17PvevXuj//wAAAA8ffq0 1Da2trYqHFH15j6gpaUlWF5ermXf4w5+mRklyzL4/vvvqxxS5eY+oKZ59OgR7O3twc7ODty+fXvm H3WlgGbMjz/+CD/99FPdw5gYLaKJEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgT Cog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMK iDihgIgTCog4oYCIEwqIOKGAiJO5/wllu7u7YIypZd9JktSy3xdp7gP66quv6h7CXKNTGHFCAREn FBBxQgERJxQQcTI3n8La7TZ88skndQ/jVIyxuodQqbkJiDEGYRjWPYyXDp3CiBMKiDihgIiTRgWU ZVndQ5g6pVTdQyilUQH9/vvvdQ9h6pr2PTYqoLt37871LPT48WPY3t6uexilNCqgNE3h22+/rXsY U2GthW+++abuYZTWqIAAAH744YeZ/3dEy8rzHG7cuAG//PJL3UMprZEXEr/77ju4d+8edDodWFtb g3PnzjXuCq8xBp48eQL379+HW7duwZMnT+oe0pk0MiAAgN9++w1u3LhR9zBeeo07hZHZQgERJxQQ cUIBEQAAYIzhWd5XOiDP8/CsOyOzjXNe+rhOGhAFM8eyLEPGGKZpCgDlZqMzn8JoFpofw2OJjDFk jCHnHDnnKIRAIQSGYXjisZ44oDAMDzdY7KCS0ZPacc7tMBybpulhRI8fPz79vWV35nkecs7R8zxL s9B8YIxZrbXhnFvP82wxQUwySZQKSAiB29vbKISw/X6f4pkTxhjteZ4ZxmMHg4EdThIohMA4jotj /cwxnyQgBACM4xillMg5Ryml9X2/np9YQConhNBZlpk8z7Xv+0YIYaWUdnisT4wHoMQM1Ov1sFgH 7e7uWiGErWT0pHZaay2lzKWUOssyLaU0u7u7NgxDG4Yh9nq90XiOhHTazVQEgMPb3HEcY5qmFgAg SRKDiI27C06egVLKfDAYWEQ0Ukq7t7dnW62WlVKOnr7GKnMdCItZKIoi22q1DCItg5oOEUFrnUdR lLdaLZMkiWm1WiaKIru6umqHs0/x6xmTroGK33FjY8Ourq7aKIpslmWDir4PUhOlVNLv93WSJKbf 7+t2u/28eJ6JqOzzQAgA0Ov1oNvt2sFgcFdK2aHTWDMhIgwGg3txHOtisRyGIW5sbIyGU9kpbPQ1 9no91FpfT5IE6FTWPIgISZKAMeZfi4uLdnV11W5ubpqNjQ0LABaejWfsQfZK7vfIVPPaa69tRFH0 V2PMed/3gXO6ud8EWmtIkgSyLPv5zp07/+j1evrBgwfPi+XEGeKs557D9125cuWPUsp/c87XhRAg RGOfkn0paK1Baw3W2p+VUn/5/PPPHw7/alwkp55aXBcvDADgvffea1+6dOkKAHzIGLvIOV9w3C6Z AmvtE0S8i4jXNzc3P/v6669TOGWRfJpKAhr+ztfX1/n29rYfRZFQSgnf9/08z4UQwjfGeL7ve9Za joiseO/wNXEwck+yuBFq8zw3nueZPM91EAR5lmW63W5naZrmi4uLZnNzc3StA3CGeADc/6+M0QuN GIYhtlot0+/3IY5jPDg4gCAIUGttpZSeMcYDABYEAQegeKpURJTnuUVElFKaLMvM8HcdhqEeXuMp Hs84dYE8iaoWLAgArLhSrbVmUkqmtTZSSuCco9YajTFWSskRERCRUUDVKQISQqBSyiKiDYLA+L5v ENG0Wi2TZZmNosg+7+Zo6f26bgCOnsZYp9Ph+/v7fDAYeHEce0opT2vN2+02N8Zway2jeKZj9IEw z/PsYDCwwxujJkkS0263zeLioh1+VHda+xSqmIHG3S9DrbVNkgSWl5dRKcWzLOPWWlYENPzaCnZP AP7/zyoUARljMAxDK6W0u7u7NgiCk+5tOc1CVc0CR2ahbrfLtra2uFKKpWnKrbXMGMNWVlaY1poZ Y2j2mZLiGZ7t7W0sHv4bhoST3p4oo+qAites2+2yJElYmqZMKcW01kxrffh11lqKqGKjTxAWjx9L KTEMQ4zjGE+4MToTAR3f1uHH9CIkAIA0TSmaF6R4EH4kHICK4wGoNqDj25vkNZmOkyKpNB6A6R3M 49ulaOpT6aL5uGkfWApndtAjE4QQQgghFfkfLnfEbGu/P8gAAAAASUVORK5CYII= "
+ style="display:inline;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.697369"
+ width="28.697369" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_galaxy_buds_pro.svg b/app/src/main/assets/ic_device_galaxy_buds_pro.svg
new file mode 100644
index 0000000..17cdc49
--- /dev/null
+++ b/app/src/main/assets/ic_device_galaxy_buds_pro.svg
@@ -0,0 +1,147 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_galaxy_buds_pro.svg"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16"
+ inkscape:cx="9.59375"
+ inkscape:cy="19.78125"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#1f7fdb;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#4dabf5;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ <g
+ id="g63739"
+ transform="translate(0.05413571,0.02254964)">
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0.499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 21.237726,23.178927 c 0.634206,-0.154792 1.014861,-0.392033 1.434432,-0.894023 0.709355,-0.848648 0.851246,-2.128284 0.35017,-3.158004 -0.158194,-0.325102 -0.376956,-0.640059 -0.486135,-0.699886 -0.184713,-0.101225 -0.186831,-0.142449 -0.0305,-0.593252 0.4121,-1.188704 0.245104,-3.013982 -0.384023,-4.197709 -0.384785,-0.723994 -1.220769,-1.489466 -1.876589,-1.718287 -1.305362,-0.455487 -3.136032,0.05098 -4.050919,1.120434 -1.417995,1.657746 -1.788681,3.854161 -0.956476,5.667197 0.279331,0.608544 1.422559,1.793423 2.254089,2.336206 0.402046,0.262429 0.660398,0.531288 0.79333,0.825545 0.434706,0.962313 1.83523,1.584515 2.952669,1.311779 z m -1.557124,-0.393681 c -0.417663,-0.178671 -0.962525,-0.642359 -1.13348,-0.964615 -0.08464,-0.159567 -0.02985,-0.160978 0.556108,-0.01438 1.152123,0.288237 1.967488,0.0109 2.699481,-0.921499 0.305122,-0.388252 0.34598,-0.533223 0.397766,-1.411541 l 0.05768,-0.978857 0.300204,0.366331 c 0.962332,1.174379 0.537663,3.170699 -0.820959,3.859379 -0.572392,0.290149 -1.464875,0.318438 -2.056846,0.0651 z M 18.664017,21.38604 c -1.145368,-0.445668 -2.891616,-2.002277 -3.271619,-2.916302 -0.916517,-2.204544 0.139572,-5.156228 2.189977,-6.12081 0.943576,-0.443884 2.09371,-0.462014 2.890806,-0.04544 1.698228,0.887254 2.464953,3.418948 1.713805,5.658899 -0.113052,0.337133 -0.206657,0.923837 -0.207992,1.30381 -0.0056,1.475953 -0.786045,2.332595 -2.127581,2.334067 -0.357171,4.12e-4 -0.877106,-0.09332 -1.187396,-0.214086 z m 0.05143,-2.660283 c 0.09413,-0.1471 0.141331,-0.346993 0.104904,-0.444199 -0.03648,-0.09727 -0.46741,-0.342356 -0.95774,-0.544765 -0.99398,-0.410316 -1.250633,-0.37172 -1.250633,0.188105 0,0.29544 0.0735,0.356398 0.830335,0.688221 1.011691,0.443601 1.058716,0.44776 1.273129,0.112618 z"
+ id="path4913" />
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke:#fffffd;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 7.8095555,17.66233 c -0.643309,-0.154792 -1.029417,-0.392034 -1.455018,-0.894023 -0.719533,-0.848647 -0.863456,-2.128283 -0.35519,-3.158003 0.16047,-0.325108 0.382369,-0.640058 0.493111,-0.699893 0.187359,-0.101222 0.189511,-0.142441 0.03099,-0.593244 -0.418003,-1.188704 -0.24862,-3.0139926 0.389538,-4.1977137 0.390307,-0.7239821 1.238294,-1.4894593 1.903507,-1.7182926 1.3240955,-0.4554866 3.1810385,0.05091 4.1090525,1.1204365 1.438337,1.6577511 1.814337,3.8541708 0.970193,5.6672038 -0.283344,0.608542 -1.442967,1.793425 -2.286421,2.336214 -0.407815,0.262422 -0.669885,0.531277 -0.804719,0.825539 -0.440944,0.962315 -1.8615685,1.584512 -2.9950365,1.311776 z m 1.579457,-0.393675 c 0.423658,-0.178678 0.9763395,-0.642346 1.1497485,-0.964615 0.08584,-0.159571 0.03028,-0.160985 -0.5640875,-0.01437 -1.168658,0.288237 -1.995714,0.01078 -2.738213,-0.921502 -0.309496,-0.388249 -0.350936,-0.533218 -0.403474,-1.411549 l -0.05856,-0.978857 -0.30451,0.366339 c -0.976134,1.174378 -0.545387,3.170701 0.832735,3.859381 0.580601,0.290142 1.48589,0.318436 2.086356,0.06516 z m 1.0311705,-1.39921 c 1.1618,-0.445664 2.933103,-2.002277 3.318564,-2.916305 C 14.668411,10.748599 13.59717,7.7969088 11.517344,6.832342 10.560231,6.3884511 9.3935935,6.370315 8.5850695,6.786822 c -1.722603,0.8872548 -2.500328,3.418952 -1.73839,5.658903 0.114677,0.337147 0.209614,0.923838 0.210971,1.303822 0.0052,1.475947 0.79732,2.332583 2.158099,2.334069 0.362301,3.8e-4 0.8896925,-0.09332 1.2044335,-0.214099 z m -0.05212,-2.660279 c -0.09548,-0.147102 -0.14336,-0.346994 -0.106402,-0.444203 0.03699,-0.09727 0.47412,-0.342357 0.971491,-0.544765 1.008241,-0.410314 1.268566,-0.371706 1.268566,0.188105 0,0.295436 -0.07455,0.356397 -0.842244,0.688225 -1.026201,0.443591 -1.073911,0.44776 -1.291402,0.112602 z"
+ id="path4913-6" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none"
+ sodipodi:insensitive="true">
+ <image
+ y="0.0079936879"
+ x="0.010951031"
+ id="image4124"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAAAA3NCSVQICAjb4U/gAAAACXBIWXMA ADFOAAAxTgGacD5yAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAABF0RVh0 VGl0bGUAUGViYmxlIEljb26KvwD9AAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAAY3RFWHRDb3B5 cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0dHA6Ly9jcmVh dGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzMuMC/eBBrlAAABy1BMVEUAAAAAAAAA AP8AAIAAAFUAAKoAVaoAAIAAQIAAM2YAM5kzM2YzM5krK4AkJG0kJJIgIIAcHHEcHI4aGoAXF3QX F4sXLnQXLosVK4AUJ3YUJ4kSJIAkJIARInciInciIogQIIAgIIAeHngeHoccHIAcK4AbKHkbKIYa GoAaJoAYJHkXI4AXI4sWIXoWIYUVIIAfH3ofH4UdJ4ATHIQcJnsbJIAaI4QaIoAZIXsZIYQYIHgY IIAXH4MXJ3wXHoAXJoBLpfAWJHwdJHwdJIMcI4Acas0VIoMbIoAaIXwaIIAZH3wZH4MZJXwYHoAY HoYYJIAehtsYHoIefdtHoO0jetZGovNEn+4gg+EfgN5Ep/VKqfREpvQgfdkgfttKqvQggd4hjesw nPMwnPRJqfUhgd4hjutJqfUgfttNq/VNq/QhlPEhlPIhlvMkmPMglvIff9sgjeoglPAhk/AhlPEh lPIhlvMkl/MkmPMlmPMmmPMnmfMpmvMzn/Q0n/Q1n/Q2oPRIqfVNq/VasfZbsvZhtPZitfdtuveQ yvmXzvmj0/qk1Pqu2fu83/vR6f3T6v3Z7f3i8f3j8v3l8/7m8/7p9f7q9f7x+P7y+f7////ougeB AAAAb3RSTlMAAQECAwMDBAQFBQUFBgcHCAkJCgsLCwsMDQ0ODg8PDxAQERESEhMTFBQVFhYXFxgZ GRobGxwdHh8fICAhISIiIiMjIyQkJSYnKCkpKSoqKiorKyssLC2xs7O0tba2tu7u7u7u7+/w8fHy +/v7+/ywJhX9AAAC0klEQVRo3u2a+VfTQBCAm00iW0nSTVNKbKQYbIMUC6jU4q2IB2rF+74vPEAR LSjIIXgAAiqef65py6MNJe17y1RF8/26L/N1N9O8ZGZcLof/DiYFoiV1cZHwiGVZjh7zalRAYYbn sFsQ6RFwGccyBeJjwSMrPn8lHX6fQqRy3tbAsFiU1So9FA4bNIRD63XNTwTO5pSYc9fuvwbgwaW9 SxqYs/eGBgcAGBy+fRrlpRPDsJdfJoEYuMgvzibz/vK3kmDcFMuxNZvM/BG74QTdFV6PJZsYlhe9 XXCCrlCVStwcyhG45QCkoCmiV0o4uwXEib4aQMHjbc0RTXYvCMx/mGdNHaSgraWxRhEXzsgUEC0C KWjfszlUYRWsrYcUHGttNlSRzxUEo5CCjrZYrSqVVlD3Nwj6vqcZsovzKrPeTy3o/5lmxE4wnFl/ 4QhKLvjy2YY5IEExHMH0B1umQQSj9o+EUUewIBh/m8c4qOBbfu58dQRLCmYnJmZLKphKJicdwR8X vJsymSmhIM37FSv4OJlhxnlUOAJHAC8Ye5PHmPNe9PsEJX/5dT5AigvmPtkw53wnr3xB34809uWc zDp9OWflV7xgBaBlzY79MWORALhuui+vbhoArfwe3rUp5MtWfl2Il1QDUtAaj+rKakv1XdEhBfGm Wo1kq+/pM1Ih+wdRI6iIq3IFvEAewgke6QHFg1mU26PjhbtwghsKEbG1X8ew3PmnUPGfXRAwv7hr yqAD13t6nwPQ23PnBMeivG4gg85c7XwCQOeVU+avZ5bslcrrNm7dfehIInGSguOJo+0Hd27ZoOam j3ULvOjTI02x+PYddLTEY42GJpfnpo/L0g50k8pqI1IfbWikoSFaXxfSFKnMrqFsZpKbKKoWDFbT EdQCqlfCdhtIG7AoESLTQogk4EJd/VRTn8fLgecKTg3MjyUsA4SKDD7MT1bQ8++NmvwCRmydh2Fg UvEAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.710279"
+ width="28.710279" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_galaxy_buds_pro_disabled.svg b/app/src/main/assets/ic_device_galaxy_buds_pro_disabled.svg
new file mode 100644
index 0000000..4f40a69
--- /dev/null
+++ b/app/src/main/assets/ic_device_galaxy_buds_pro_disabled.svg
@@ -0,0 +1,200 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_galaxy_buds_pro_disabled.svg"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16"
+ inkscape:cx="7.34375"
+ inkscape:cy="7.65625"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer6"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#9f9f9f;fill-opacity:0.94117647;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#8a8a8a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer6"
+ inkscape:label="Foreground"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer7"
+ inkscape:label="Backplane"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer8"
+ inkscape:label="Frontplane"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer9"
+ inkscape:label="Base"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer10"
+ inkscape:label="Top"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer11"
+ inkscape:label="Toptop"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer12"
+ inkscape:label="Toptoptop"
+ style="display:inline" />
+ </g>
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#8a8a8a;fill-opacity:0.94117647"
+ d="m 12.48275,10.865376 h 3.907 v 6.978 h -3.907 z"
+ android:strokeWidth="1"
+ id="path2735" />
+ <g
+ id="g63739"
+ transform="translate(0.05413571,0.02254964)"
+ style="display:inline">
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0.499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 21.237726,23.178927 c 0.634206,-0.154792 1.014861,-0.392033 1.434432,-0.894023 0.709355,-0.848648 0.851246,-2.128284 0.35017,-3.158004 -0.158194,-0.325102 -0.376956,-0.640059 -0.486135,-0.699886 -0.184713,-0.101225 -0.186831,-0.142449 -0.0305,-0.593252 0.4121,-1.188704 0.245104,-3.013982 -0.384023,-4.197709 -0.384785,-0.723994 -1.220769,-1.489466 -1.876589,-1.718287 -1.305362,-0.455487 -3.136032,0.05098 -4.050919,1.120434 -1.417995,1.657746 -1.788681,3.854161 -0.956476,5.667197 0.279331,0.608544 1.422559,1.793423 2.254089,2.336206 0.402046,0.262429 0.660398,0.531288 0.79333,0.825545 0.434706,0.962313 1.83523,1.584515 2.952669,1.311779 z m -1.557124,-0.393681 c -0.417663,-0.178671 -0.962525,-0.642359 -1.13348,-0.964615 -0.08464,-0.159567 -0.02985,-0.160978 0.556108,-0.01438 1.152123,0.288237 1.967488,0.0109 2.699481,-0.921499 0.305122,-0.388252 0.34598,-0.533223 0.397766,-1.411541 l 0.05768,-0.978857 0.300204,0.366331 c 0.962332,1.174379 0.537663,3.170699 -0.820959,3.859379 -0.572392,0.290149 -1.464875,0.318438 -2.056846,0.0651 z M 18.664017,21.38604 c -1.145368,-0.445668 -2.891616,-2.002277 -3.271619,-2.916302 -0.916517,-2.204544 0.139572,-5.156228 2.189977,-6.12081 0.943576,-0.443884 2.09371,-0.462014 2.890806,-0.04544 1.698228,0.887254 2.464953,3.418948 1.713805,5.658899 -0.113052,0.337133 -0.206657,0.923837 -0.207992,1.30381 -0.0056,1.475953 -0.786045,2.332595 -2.127581,2.334067 -0.357171,4.12e-4 -0.877106,-0.09332 -1.187396,-0.214086 z m 0.05143,-2.660283 c 0.09413,-0.1471 0.141331,-0.346993 0.104904,-0.444199 -0.03648,-0.09727 -0.46741,-0.342356 -0.95774,-0.544765 -0.99398,-0.410316 -1.250633,-0.37172 -1.250633,0.188105 0,0.29544 0.0735,0.356398 0.830335,0.688221 1.011691,0.443601 1.058716,0.44776 1.273129,0.112618 z"
+ id="path4913" />
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke:#fffffd;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 7.8095555,17.66233 c -0.643309,-0.154792 -1.029417,-0.392034 -1.455018,-0.894023 -0.719533,-0.848647 -0.863456,-2.128283 -0.35519,-3.158003 0.16047,-0.325108 0.382369,-0.640058 0.493111,-0.699893 0.187359,-0.101222 0.189511,-0.142441 0.03099,-0.593244 -0.418003,-1.188704 -0.24862,-3.0139926 0.389538,-4.1977137 0.390307,-0.7239821 1.238294,-1.4894593 1.903507,-1.7182926 1.3240955,-0.4554866 3.1810385,0.05091 4.1090525,1.1204365 1.438337,1.6577511 1.814337,3.8541708 0.970193,5.6672038 -0.283344,0.608542 -1.442967,1.793425 -2.286421,2.336214 -0.407815,0.262422 -0.669885,0.531277 -0.804719,0.825539 -0.440944,0.962315 -1.8615685,1.584512 -2.9950365,1.311776 z m 1.579457,-0.393675 c 0.423658,-0.178678 0.9763395,-0.642346 1.1497485,-0.964615 0.08584,-0.159571 0.03028,-0.160985 -0.5640875,-0.01437 -1.168658,0.288237 -1.995714,0.01078 -2.738213,-0.921502 -0.309496,-0.388249 -0.350936,-0.533218 -0.403474,-1.411549 l -0.05856,-0.978857 -0.30451,0.366339 c -0.976134,1.174378 -0.545387,3.170701 0.832735,3.859381 0.580601,0.290142 1.48589,0.318436 2.086356,0.06516 z m 1.0311705,-1.39921 c 1.1618,-0.445664 2.933103,-2.002277 3.318564,-2.916305 C 14.668411,10.748599 13.59717,7.7969088 11.517344,6.832342 10.560231,6.3884511 9.3935935,6.370315 8.5850695,6.786822 c -1.722603,0.8872548 -2.500328,3.418952 -1.73839,5.658903 0.114677,0.337147 0.209614,0.923838 0.210971,1.303822 0.0052,1.475947 0.79732,2.332583 2.158099,2.334069 0.362301,3.8e-4 0.8896925,-0.09332 1.2044335,-0.214099 z m -0.05212,-2.660279 c -0.09548,-0.147102 -0.14336,-0.346994 -0.106402,-0.444203 0.03699,-0.09727 0.47412,-0.342357 0.971491,-0.544765 1.008241,-0.410314 1.268566,-0.371706 1.268566,0.188105 0,0.295436 -0.07455,0.356397 -0.842244,0.688225 -1.026201,0.443591 -1.073911,0.44776 -1.291402,0.112602 z"
+ id="path4913-6" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.028144173"
+ x="0.013794855"
+ id="image1291"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAANW0lE QVR4nO2dS4xc2VnH/98591VVXd3TbntC92ggwZ5R1BWRRZMoIQG1UIQIs0BCcpINESyyQIqySlgl 2I6yQ2xmtpEQilgQI4EUiAQaQQtCFAiNQjLVUWbsjII19iS2u931vI9zzseiqtxP21V9b7vrlr/f pp/33HPr/O53vvO4VYAgCIIgCIIgCIIgCILwLEBnUz6f8mmfZR7ZpKfyop+GQPvKHNX52mmLKjyS K8NGONAEhclUdMMOy2PsSdMcfr08+LK+JTKdNhurQ0GuD3/RGP58hfc1eSESeUUUMuSQPE0CLgPr ILRv0xp2EMd3CHf3DkiS+yJTQYTh0p4QjS1E0TJvYhGorzA2VjGQ6RoAHklEKECiIhuQDsiz3qC1 9jLF8R1KknNkzDYZs0vAi3CuJeKcEkrNM3ALnrfAnneOw3Cbo2iZN+t3GBtNHkSjh5FoagQ6Ik/j LlSSnKM4NirLbit3fo7Y9AhYgrP9vTjq+iJTTkhVHoqgdIWB+yCvyupeh31/xUWR58Jwm5sX4Abd 21ZhEhUo0NUD8nS7UGkKlS1C2QRq3kA5F5KrJ8Q2IaAOdqnIUxCkAgbaIB2yaoesVMItD06HcP4O XBDA1WpwA4lGkegqYwoEok++ygE/3/+CJv6MVrjIwEIB5QpFw9xyUG9ZuL+Jt+6+tnH1vUneKJRX IPrEN/nFiHrfJqBhHJBYwLqcpQqnglZAqAFPAc7xG0miX3n9s9GtPCOzkwpEAPDJVznQy73NxKBx p8PoZjJBWAbmAsJyjeArfqP747sf2rj6V+mhIf6IJzaomvDchIcJM4Of73+hm6Lx0wdO5CkRnZRx 84FDz9AHgpeWPj+Ycrk2zGOv0j5vnhhgJhHo0DzPNXJwn77VcnDiTulwDNxqOZDWn8LaosI6FLBK B2Vi4GHQOJ5xJxKPThKuN+h+D5esyFNaLAPbfXfp0m6owhgcre0MJx8xnDN68sTjBDPRI3lWCeug xl2oTsbVgq5FOCNaKeba7dCL43nX7d7hS17IYQxurjccNpo4JNERxunC6KA8Ww/neTJb4JUIZ0Jm gbSmvbj2rtefr+leb0l3u1CNu1BYb+zr0h52ZwcYMwcadVt78vT7HS29V/lhANVAedX+olfrWC+t 93SaDtr4oETH8ySB9mXkl7HWXqbRDLN97sKkIzhhGmGmNA79xE/8JHDeXFLVab2i03SwFLXWvj1Y FN+LQgcYX4L1LYrjO2TMLmWLUHNJXxd4GcIZ4uvMDzx4WdLxsijQc0lfZ4tQcfy2iuMVOrQF50A0 GiOJHnZf7UVKkpCcWyKbQJkalF/whQhng/XIQwYKgwBpljIZsE2gnFtwgy03Sxh2Y0dC0JgR6DLW sAZjlsier9G8gXLZxJOQwpTiZ/Csb7Q1mY5Mql0V6jkXkT3fIWOWht0YcNzO0oklcLZLzsVkLRSY ZTV9BnAM5RtPs4NyUUDOBeRcTM7ODdt37ZHHji3QKP9h0yPnIpKtGLMDe1BgQy4g5SwUc0rOxYfa 9/Kxx55gS+sSsNAHerUTVFWYRpg9YoDAIOaM2PEgSJgeGZNRHIOAxWOPHTMCjbLwF/edNJMINDNY YjYUPvx5/OBwghyoP9xRKMwWIdgZAioTBQcZSQkD2J4oKIhAQi5EICEXRT5Y+FR56ZzC5z7o46Mv eHi+RvBLditkDvhFl/Hddwy+/oMMb+2UcyN5KQX67Ad8fOXjIXSJU3lfAS/UCZff7+MPXvZx9TsJ /rqZnXW1JqZk9y3wykUPV36z3PIcRivgq78V4nd/tXz3c6kECjTw5Y+Fp/6eNGcBAfjKx8LSdcWl qu5HVjTeU5tFfQYszxE+vFKuXTKlEuilc6Wq7ol4uWTXWKra1vzZjT4j5kp2jaUSSJg+RCAhFyKQ kIvyTTw8ht2kHA8aLYSPznNePqfwexc97MSM/3nXIpnyZ+9mSqBf/8supv1RawJw80/mHvn3Vy55 eOXSoFluPnD4o3/o45329F6UdGFTzMXnFL78G+GT//EMEYGeMnrCV/xDy9M9sSgCPWUmXaqoTPm8 kAgk5EIEespM+mZcjqc3gQZmbBR2HH/+2xF+7fmzuU9++AuHL/1LfOB36YTD8p9sT/dGs5kX6MU6 4aXFsxFop58vemQO+Iv/TAuqzekw8wKVjTe3HW7sOPxfy+Hv3zR4UyKQMAn/eMPgtc3pjjr7kSRa yIUIJORCBBJyIQIJuRCBhFyIQEIuRCAhFyKQkAsRSMiFCCTkQgQSciECCbkQgYRciEBCLkQgIRci kJALEUjIhQgk5EIEEnIhAgm5EIGEXIhAQi5EICEXIpCQCxFIyIUIJORCBBJyIQIJuRCBhFyIQEIu RCAhFyKQkAsRSMjFzL9D2a02Y/GMPhH51hR/REFRzLxAh98lVSgW6cKEXIhAQi5EICEXIpCQCxFI yMVMjcL++49rZ12FZ46ZEuhxn0UqnA7ShQm5EIGEXIhAQi5KJVAnm/21pXZarmsslUA3pvyzs4rg rTNa+D0ppRLoe7ct3u2W6w6dhDsdxn/dnvAzMc+YUgmUWuBr/5FgFhViAF/9ToKsXAGoXAIBwLdv Glz99wS2ZC/047AO+LN/S/BPb5uzrsrElHIi8RtvZPjebYvPfdDHR1/w8Es1gi7ZrWAc8PMu47vv GHz9B1npcp8RpRQIAN7advjTf00AJGddlWeakt23wrQhAgm5EIGEXIhAwgDSJ5odmVggpStMOpzF qZhnnASkPAb6Ex01pkCrQ2FuTVgpoRxoJvJ4NJ4lGn9B7gRd2H1gd7KTCNMNkWGCZpBhIp9JBaxU zORV2fMWOIqWH9nWYwsURcvseQtMXnVQuApEoBmBDBzIskojRypjooCVig617/Vjjz1BDlRjpSLW Gg5EItEMoAgu87Ql1Xc6YadUykpFrHRn2L6bjz52vFNcxyY24XnnWN/rcsuDI5WIPDNC5sPozFql 4foKTvXgHqiY9b059rz7vFlfGbb1lSNtPoZAVxhoMOorHIbbrNQ86xDOj8Ny7TsQHok2bJSX2TRj 4/mB9Tw4HcIptcthuDSUpnFswBi/C9tY5UEe1GJ/B67loZyrf8IRUsMms37mh3MmSGE6YcX6O3BR 9D4XRbcZG6v75Tkg0pMEYmD0qMx1bNbvcK0GFwRwfrVjwdKLzQAchEHmJzBhqkwn7Fn94K4LArgw 3B52X6ME+uhjU2NGoGE3trHKzeg212pwldacBUsQKj3s0Df9LK5VsnYltkG7aiuVOVurwTUvwGGj OWj7Y/IfYDyBhlHoCgNbjM1PuOYFuFoNDnHaK/ZqhKcNx0k76FoTtPu21oWpVu/vk2d1nzwE4Ohm 0AmG8fsk2mhy8wKc6/duzOT+0mcFZnC//9N6/ZfN4iJMrQZ3YyHZF3m29stzLONuKGMANCiIGbgG bDTBH8+uc2t3jebnAZLHiksFM7jdBiW9v72x8IZDfWWYLDcBrDDQwCF5jg0Vk+xIPCKR+8ndV+n9 5/8QzjWoXge80m5wfLYwGbjdAZLkR+n/3nwNmztuIM0W9sQBniTPgf+YEBqV+ytf/OF7qVL5Fitq kO8D2gdkgno6YQJsBs4yENOP2HV//2df+7ufDf54RBrgMeKMyNvvEMB4z+98o1r5yIc/D8efBqmL rNR8znKFU4Act8C4odhe737/+6/9/J+/FQ+S5KsjUSa+8wsRCLhGAFSjAXXvXsdPa+e9auK81E/9 wKt4Jkt9Z632fa3ZQTFbAhQhAJidJE85IVKMFAAcE2kmBZdl1iqtrfbIZNZkfgoTV5FW491sYWHB DhJluMeNsMYhb9LCg+z5KoBVjqLXuVJZsejeQ78+xxUvQZakHEaBsybTzkJzpCmwRgEAsyfyFAE7 UGAYAFKtHSWW/TCz2oNN0tQGEZtQOWPb1nqV4faMjcVhznNyeYD8EWhYBgP4lMJ6gxp3oXZ353Wy 0NK1LrwsCrTJUh2ZVNuQlLNQQAVgQ8yZCFQQRP5AAspYaTidsIu9wHp+YP04td0aTLg7bxcWWnZv mP5Nl1egAodNDcbGKpprO3wpSZzbAXXqPTsXA2TAWTVw1kKx84k5I+aU5A3piqOLFEQBE1WZkLEL 4fwenGdT1wl7Ntipuqjqub3oU8wMXhEC7evGrgP1Bocx2Bg4tKvoLoJtAjWfQjkXk3MgnksIAAzq BZxeAIAQbQAJqANWKmZlI25Fg1X1YKe6b20LDLyOwXA9X/QBiunChuWMkukmjbqyJDlHcfy2cm6B 7PkaOdslNj1ytipd1ymhdG+wa1TXWN/rslK7HEXvc2G4zXvLEwdmmHMJVGAXdnCWurnecGvtkMJw gZPkPpkWyJjdoTjzcK4lEhWMUvMM7ALYheeBvep9DsMljqJwEHk2msOu6/HLE5NQlEDHLnVsYpWx fofQTmgNy4jjUa1bBZ1WOEgLwAKAwR72TdwG6hjmPK/j0MIoUEAeVHQUeDhDPZwbGtIk4PLw+y3C esFnFfbYAPYew7qOgzsJi5UHKF6gQ+WO6nhNuqszY/x1rZNw2g0r4kwPskApCIIgCIJQEP8P03/B 430mofMAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.670942"
+ width="28.670942" />
+ <image
+ y="0.0055872202"
+ x="-0.031073861"
+ id="image3082"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAAL6klE QVR4nO3dTYvcRhrA8adKJZW6Zc0LmTEmmYUF5zYOJjQLOSTQEMhlA7uHsNlPsCHg5DP4MzghHyC3 xZvTGh8CgYaQXMwEkngOZo1f2JAYz2ReLHePSqqqZy+t2Z5xj6c1pbZa7ecHxo09LdVEf0rVkjIG IIQQQgghhBBCCCEvA9bw7ZPJ4TQ2Oo0DPG6bFFJ9xoVTWUxVH1g25vXxfTAAgG63W/GuSa/XK14e DwTH/HklEVUZ0PF4ilBYkiQMACBN0yP7U0rRzFQRKeWRIMIwRACAOI6x1+uNBlRpRNMIiAEAK8JJ 05QppZjWmmmtGQCAtZbCmRLOOQIACCFQCIFSSgzDEEdCGo1oZgJ6Jp6trS2ulGJpmvI8z/nKysph QMaYw/1STO6KaAAAPM87DGh7ext937dhGFopJa6urtqqI6oyoCPx9Pt9nmUZX15e5koprrXm1lpm rWWIyOI4pngqxDnHJEmAMYacc+ScoxDCSint7u6uDYLARlFkx0RUe0Ds2rVrQRRFn3qe93fO+UVE XKxgu6R6TxDxP4j4z4cPH3529epVNfzzM0fkGhD78ssv/2CtvckYW7fWgtYaEKdyyYE4YoyBEAI4 54CIt5VSf/7oo4/+O/IlpQ/cWQNiAADXrl0LFhYWNowx60mSQJZlZ9wceZGCIIA4jsHzvNsPHjz4 09WrVzM44/UiXnLfhx/PAQCiKPo0z/P1nZ0diqdBsiyDnZ0dyPP80tra2hUYWcPC+Gt5JyoT0DPX eRhjH+7t7dEpq4EQEfb29oAx9rdOp8O73S6H8SEdj+oIMeH+xl0kZP1+/3WKp7kQEQ4ODl7f39/n aZpip9NBAICNjY1xH/MZjDmlTRpQ4chH9SzL2mcdPJkNSqlzSZKINE1tv99HIQSur68X14yKLztx lpjkFDb2ImG/3+fGGLfRk9oZYyCKIhFFkVhYWPAGg4HX7/f51tYW73a7405nR0y6BnomnoODA6+i 74HUTCklDg4OxNOnT0Ucx16WZXxMRGOddgo78sYkSVhxhXlpaansJzgygxCR+b7ve55nfd83aZqa OI4hSRIQQuDwRjjCCWugiSPodrssTVOmtWbD2xM0A80Jz/P8LMuEUkqEYegppbzl5WWepilP05QN Z6HCkUllkkU0AwCWJAlTSjFrLSvubVX6XZDaaK2F7/ssz3PgnKPWGpVS3FprRx65cZuBhjtiw7vq vN1uU0BzwvM8YYzxgiDwtNZeu93m1lpmjGFa68PnucBhEX3IGMOGG+eISHfT5wAiciGEZ609fGKi +HXaeycOqFj/aK0n3jhpBmstR0RmreVSyuI1O3/+/KnvpdMQgeGZhCEiK+Kx1h5OGMcfRR5V5jrQ uJ2SORAEAUNEFoZh6feeaQ1E8cynYllS5vjSKYwAwNnPKBQQcUIBESdlH+eYGa+++ip0Oh1YW1uD KIqAsWYty4wxkCQJ3L9/H27dugX7+/t1D+lMGhnQO++8A2+99Vbdw3DieR4sLS3Bm2++CW+88Qbc vHkT7ty5U/ewSmvcKazT6TQ+nuOEEPD+++/D2tpa3UMprVEBtVotePvtt+sexlRwzuHdd9+texil NSqgixcvQhAEdQ9jas6fPw8rKyt1D6OURgXUtP+4Z/HKK6/UPYRSGhWQ7/t1D2HqpJR1D6GURgVE Zg8FRJxQQMRJIy8kjoOIoJQ6/Qtrxhh77jrnwoULoJSCfr8Pv/76K1hrX+DoypubgAaDAXzxxRd1 D+NU586dg48//vjEv798+TJcvnwZAAAePXoE169fhzRNX9TwSqNT2Ay7cOHCzF84pYBesHa73I8T mPXbGxTQC1b2WtasX/uigIgTCugFK/upatZ//tLcfAo7yQcffAALCwu17PvevXuj//wAAAA8ffq0 1Da2trYqHFH15j6gpaUlWF5ermXf4w5+mRklyzL4/vvvqxxS5eY+oKZ59OgR7O3twc7ODty+fXvm H3WlgGbMjz/+CD/99FPdw5gYLaKJEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgT Cog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMK iDihgIgTCog4oYCIEwqIOKGAiJO5/wllu7u7YIypZd9JktSy3xdp7gP66quv6h7CXKNTGHFCAREn FBBxQgERJxQQcTI3n8La7TZ88skndQ/jVIyxuodQqbkJiDEGYRjWPYyXDp3CiBMKiDihgIiTRgWU ZVndQ5g6pVTdQyilUQH9/vvvdQ9h6pr2PTYqoLt37871LPT48WPY3t6uexilNCqgNE3h22+/rXsY U2GthW+++abuYZTWqIAAAH744YeZ/3dEy8rzHG7cuAG//PJL3UMprZEXEr/77ju4d+8edDodWFtb g3PnzjXuCq8xBp48eQL379+HW7duwZMnT+oe0pk0MiAAgN9++w1u3LhR9zBeeo07hZHZQgERJxQQ cUIBEQAAYIzhWd5XOiDP8/CsOyOzjXNe+rhOGhAFM8eyLEPGGKZpCgDlZqMzn8JoFpofw2OJjDFk jCHnHDnnKIRAIQSGYXjisZ44oDAMDzdY7KCS0ZPacc7tMBybpulhRI8fPz79vWV35nkecs7R8zxL s9B8YIxZrbXhnFvP82wxQUwySZQKSAiB29vbKISw/X6f4pkTxhjteZ4ZxmMHg4EdThIohMA4jotj /cwxnyQgBACM4xillMg5Ryml9X2/np9YQConhNBZlpk8z7Xv+0YIYaWUdnisT4wHoMQM1Ov1sFgH 7e7uWiGErWT0pHZaay2lzKWUOssyLaU0u7u7NgxDG4Yh9nq90XiOhHTazVQEgMPb3HEcY5qmFgAg SRKDiI27C06egVLKfDAYWEQ0Ukq7t7dnW62WlVKOnr7GKnMdCItZKIoi22q1DCItg5oOEUFrnUdR lLdaLZMkiWm1WiaKIru6umqHs0/x6xmTroGK33FjY8Ourq7aKIpslmWDir4PUhOlVNLv93WSJKbf 7+t2u/28eJ6JqOzzQAgA0Ov1oNvt2sFgcFdK2aHTWDMhIgwGg3txHOtisRyGIW5sbIyGU9kpbPQ1 9no91FpfT5IE6FTWPIgISZKAMeZfi4uLdnV11W5ubpqNjQ0LABaejWfsQfZK7vfIVPPaa69tRFH0 V2PMed/3gXO6ud8EWmtIkgSyLPv5zp07/+j1evrBgwfPi+XEGeKs557D9125cuWPUsp/c87XhRAg RGOfkn0paK1Baw3W2p+VUn/5/PPPHw7/alwkp55aXBcvDADgvffea1+6dOkKAHzIGLvIOV9w3C6Z AmvtE0S8i4jXNzc3P/v6669TOGWRfJpKAhr+ztfX1/n29rYfRZFQSgnf9/08z4UQwjfGeL7ve9Za joiseO/wNXEwck+yuBFq8zw3nueZPM91EAR5lmW63W5naZrmi4uLZnNzc3StA3CGeADc/6+M0QuN GIYhtlot0+/3IY5jPDg4gCAIUGttpZSeMcYDABYEAQegeKpURJTnuUVElFKaLMvM8HcdhqEeXuMp Hs84dYE8iaoWLAgArLhSrbVmUkqmtTZSSuCco9YajTFWSskRERCRUUDVKQISQqBSyiKiDYLA+L5v ENG0Wi2TZZmNosg+7+Zo6f26bgCOnsZYp9Ph+/v7fDAYeHEce0opT2vN2+02N8Zway2jeKZj9IEw z/PsYDCwwxujJkkS0263zeLioh1+VHda+xSqmIHG3S9DrbVNkgSWl5dRKcWzLOPWWlYENPzaCnZP AP7/zyoUARljMAxDK6W0u7u7NgiCk+5tOc1CVc0CR2ahbrfLtra2uFKKpWnKrbXMGMNWVlaY1poZ Y2j2mZLiGZ7t7W0sHv4bhoST3p4oo+qAites2+2yJElYmqZMKcW01kxrffh11lqKqGKjTxAWjx9L KTEMQ4zjGE+4MToTAR3f1uHH9CIkAIA0TSmaF6R4EH4kHICK4wGoNqDj25vkNZmOkyKpNB6A6R3M 49ulaOpT6aL5uGkfWApndtAjE4QQQgghFfkfLnfEbGu/P8gAAAAASUVORK5CYII= "
+ style="display:inline;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.697369"
+ width="28.697369" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_miband6.svg b/app/src/main/assets/ic_device_miband6.svg
new file mode 100644
index 0000000..c5622af
--- /dev/null
+++ b/app/src/main/assets/ic_device_miband6.svg
@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_miband6.svg"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16.000001"
+ inkscape:cx="7.5312495"
+ inkscape:cy="18.906249"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#1f7fdb;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#4dabf5;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ <g
+ id="g3290"
+ transform="translate(0.06106299,-0.04193669)">
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke-width:3.77953"
+ d="m 5.7005371,14.062732 c 0.00345,-0.03232 0.013618,-0.134324 0.023098,-0.226674 0.052057,-0.502923 0.2664409,-1.351244 0.3915769,-1.549467 0.024477,-0.03878 0.076621,-0.09101 0.1157675,-0.116009 0.1381934,-0.0882 0.4683357,-0.160533 1.0160246,-0.222623 0.1544916,-0.0175 0.3033982,-0.04042 0.3308835,-0.05094 0.069898,-0.02672 0.2694919,-0.0674 0.330556,-0.0674 0.029045,0 0.1528109,-0.05016 0.2885308,-0.116957 0.4663707,-0.229536 0.9722843,-0.357145 1.597637,-0.40298 0.3197226,-0.02344 8.7265726,-0.02361 9.0500356,-1.89e-4 0.652071,0.04723 1.177696,0.187449 1.687436,0.450227 l 0.243463,0.125507 0.310621,0.0306 c 0.596954,0.05887 1.04326,0.137305 1.216256,0.213823 0.0981,0.04344 0.189561,0.12854 0.235198,0.21896 0.08601,0.170462 0.226243,0.687839 0.303984,1.121803 0.153044,0.854198 0.104262,1.629242 -0.16305,2.590391 -0.08412,0.302605 -0.139822,0.434653 -0.218589,0.518609 -0.138246,0.147338 -0.522565,0.238473 -1.348615,0.319808 l -0.335805,0.0331 -0.277042,0.14009 c -0.496631,0.251134 -0.965794,0.377985 -1.611884,0.435808 -0.35012,0.03129 -8.733467,0.03189 -9.1175547,5.69e-4 -0.6601552,-0.05378 -1.2013714,-0.200007 -1.6801359,-0.453951 -0.098125,-0.05206 -0.1498116,-0.06826 -0.2442131,-0.07662 -0.065847,-0.006 -0.1678593,-0.02499 -0.2266738,-0.04275 -0.05878,-0.01767 -0.2655963,-0.0518 -0.4595188,-0.07585 C 6.3912287,16.764537 6.1913765,16.692028 6.0776776,16.4675 6.0255336,16.364497 5.8912448,15.904677 5.8314304,15.624247 5.8166924,15.554947 5.7978174,15.468092 5.7896294,15.431152 5.7145594,15.094537 5.6685873,14.38872 5.7005374,14.06274 Z"
+ id="path1062" />
+ <path
+ id="path_3"
+ d="m 9.388,13.204 h 9.841 c 0.29,0 0.568,0.115 0.774,0.32 0.205,0.206 0.32,0.484 0.32,0.774 v 0.228 c 0,0.29 -0.115,0.568 -0.32,0.774 -0.206,0.205 -0.484,0.32 -0.774,0.32 H 9.39 C 9.198,15.62 9.009,15.57 8.843,15.474 8.676,15.378 8.538,15.24 8.442,15.073 8.346,14.907 8.295,14.718 8.295,14.526 v -0.228 c 0,-0.29 0.115,-0.568 0.32,-0.774 0.206,-0.205 0.484,-0.32 0.774,-0.32 z"
+ fill="#ffffff"
+ stroke="#ffffff"
+ stroke-width="2.22514"
+ style="fill:#4dabf5;fill-opacity:1;stroke:#4dabf5;stroke-opacity:1" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none"
+ sodipodi:insensitive="true">
+ <image
+ y="0.0079936879"
+ x="0.010951031"
+ id="image4124"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAAAA3NCSVQICAjb4U/gAAAACXBIWXMA ADFOAAAxTgGacD5yAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAABF0RVh0 VGl0bGUAUGViYmxlIEljb26KvwD9AAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAAY3RFWHRDb3B5 cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0dHA6Ly9jcmVh dGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzMuMC/eBBrlAAABy1BMVEUAAAAAAAAA AP8AAIAAAFUAAKoAVaoAAIAAQIAAM2YAM5kzM2YzM5krK4AkJG0kJJIgIIAcHHEcHI4aGoAXF3QX F4sXLnQXLosVK4AUJ3YUJ4kSJIAkJIARInciInciIogQIIAgIIAeHngeHoccHIAcK4AbKHkbKIYa GoAaJoAYJHkXI4AXI4sWIXoWIYUVIIAfH3ofH4UdJ4ATHIQcJnsbJIAaI4QaIoAZIXsZIYQYIHgY IIAXH4MXJ3wXHoAXJoBLpfAWJHwdJHwdJIMcI4Acas0VIoMbIoAaIXwaIIAZH3wZH4MZJXwYHoAY HoYYJIAehtsYHoIefdtHoO0jetZGovNEn+4gg+EfgN5Ep/VKqfREpvQgfdkgfttKqvQggd4hjesw nPMwnPRJqfUhgd4hjutJqfUgfttNq/VNq/QhlPEhlPIhlvMkmPMglvIff9sgjeoglPAhk/AhlPEh lPIhlvMkl/MkmPMlmPMmmPMnmfMpmvMzn/Q0n/Q1n/Q2oPRIqfVNq/VasfZbsvZhtPZitfdtuveQ yvmXzvmj0/qk1Pqu2fu83/vR6f3T6v3Z7f3i8f3j8v3l8/7m8/7p9f7q9f7x+P7y+f7////ougeB AAAAb3RSTlMAAQECAwMDBAQFBQUFBgcHCAkJCgsLCwsMDQ0ODg8PDxAQERESEhMTFBQVFhYXFxgZ GRobGxwdHh8fICAhISIiIiMjIyQkJSYnKCkpKSoqKiorKyssLC2xs7O0tba2tu7u7u7u7+/w8fHy +/v7+/ywJhX9AAAC0klEQVRo3u2a+VfTQBCAm00iW0nSTVNKbKQYbIMUC6jU4q2IB2rF+74vPEAR LSjIIXgAAiqef65py6MNJe17y1RF8/26L/N1N9O8ZGZcLof/DiYFoiV1cZHwiGVZjh7zalRAYYbn sFsQ6RFwGccyBeJjwSMrPn8lHX6fQqRy3tbAsFiU1So9FA4bNIRD63XNTwTO5pSYc9fuvwbgwaW9 SxqYs/eGBgcAGBy+fRrlpRPDsJdfJoEYuMgvzibz/vK3kmDcFMuxNZvM/BG74QTdFV6PJZsYlhe9 XXCCrlCVStwcyhG45QCkoCmiV0o4uwXEib4aQMHjbc0RTXYvCMx/mGdNHaSgraWxRhEXzsgUEC0C KWjfszlUYRWsrYcUHGttNlSRzxUEo5CCjrZYrSqVVlD3Nwj6vqcZsovzKrPeTy3o/5lmxE4wnFl/ 4QhKLvjy2YY5IEExHMH0B1umQQSj9o+EUUewIBh/m8c4qOBbfu58dQRLCmYnJmZLKphKJicdwR8X vJsymSmhIM37FSv4OJlhxnlUOAJHAC8Ye5PHmPNe9PsEJX/5dT5AigvmPtkw53wnr3xB34809uWc zDp9OWflV7xgBaBlzY79MWORALhuui+vbhoArfwe3rUp5MtWfl2Il1QDUtAaj+rKakv1XdEhBfGm Wo1kq+/pM1Ih+wdRI6iIq3IFvEAewgke6QHFg1mU26PjhbtwghsKEbG1X8ew3PmnUPGfXRAwv7hr yqAD13t6nwPQ23PnBMeivG4gg85c7XwCQOeVU+avZ5bslcrrNm7dfehIInGSguOJo+0Hd27ZoOam j3ULvOjTI02x+PYddLTEY42GJpfnpo/L0g50k8pqI1IfbWikoSFaXxfSFKnMrqFsZpKbKKoWDFbT EdQCqlfCdhtIG7AoESLTQogk4EJd/VRTn8fLgecKTg3MjyUsA4SKDD7MT1bQ8++NmvwCRmydh2Fg UvEAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.710279"
+ width="28.710279" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_miband6_disabled.svg b/app/src/main/assets/ic_device_miband6_disabled.svg
new file mode 100644
index 0000000..3796c8d
--- /dev/null
+++ b/app/src/main/assets/ic_device_miband6_disabled.svg
@@ -0,0 +1,198 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_miband6_disabled.svg"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16"
+ inkscape:cx="7.34375"
+ inkscape:cy="7.65625"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer6"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#9f9f9f;fill-opacity:0.94117647;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#8a8a8a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer6"
+ inkscape:label="Foreground"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer7"
+ inkscape:label="Backplane"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer8"
+ inkscape:label="Frontplane"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer9"
+ inkscape:label="Base"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer10"
+ inkscape:label="Top"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer11"
+ inkscape:label="Toptop"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer12"
+ inkscape:label="Toptoptop"
+ style="display:inline" />
+ </g>
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#8a8a8a;fill-opacity:0.94117647"
+ d="m 12.48275,10.865376 h 3.907 v 6.978 h -3.907 z"
+ android:strokeWidth="1"
+ id="path2735" />
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke-width:3.77953"
+ d="m 5.7616001,14.020795 c 0.00345,-0.03232 0.013618,-0.134324 0.023098,-0.226674 0.052057,-0.502923 0.2664409,-1.351244 0.3915769,-1.549467 0.024477,-0.03878 0.076621,-0.09101 0.1157675,-0.116009 0.1381934,-0.0882 0.4683357,-0.160533 1.0160246,-0.222623 0.1544916,-0.0175 0.3033982,-0.04042 0.3308835,-0.05094 0.069898,-0.02672 0.2694919,-0.0674 0.330556,-0.0674 0.029045,0 0.1528109,-0.05016 0.2885308,-0.116957 0.4663707,-0.229536 0.9722843,-0.357145 1.597637,-0.40298 0.3197226,-0.02344 8.7265726,-0.02361 9.0500356,-1.89e-4 0.652071,0.04723 1.177696,0.187449 1.687436,0.450227 l 0.243463,0.125507 0.310621,0.0306 c 0.596954,0.05887 1.04326,0.137305 1.216256,0.213823 0.0981,0.04344 0.189561,0.12854 0.235198,0.21896 0.08601,0.170462 0.226243,0.687839 0.303984,1.121803 0.153044,0.854198 0.104262,1.629242 -0.16305,2.590391 -0.08412,0.302605 -0.139822,0.434653 -0.218589,0.518609 -0.138246,0.147338 -0.522565,0.238473 -1.348615,0.319808 l -0.335805,0.0331 -0.277042,0.14009 c -0.496631,0.251134 -0.965794,0.377985 -1.611884,0.435808 -0.35012,0.03129 -8.733467,0.03189 -9.1175547,5.69e-4 C 9.1699731,17.413071 8.6287569,17.266844 8.1499924,17.0129 8.0518674,16.96084 8.0001808,16.94464 7.9057793,16.93628 7.8399323,16.93028 7.73792,16.91129 7.6791055,16.89353 7.6203255,16.87586 7.4135092,16.84173 7.2195867,16.81768 6.4522917,16.7226 6.2524395,16.650091 6.1387406,16.425563 6.0865966,16.32256 5.9523078,15.86274 5.8924934,15.58231 5.8777554,15.51301 5.8588804,15.426155 5.8506924,15.389215 5.7756224,15.0526 5.7296503,14.346783 5.7616004,14.020803 Z"
+ id="path1062" />
+ <path
+ id="path_3"
+ d="m 9.449063,13.162063 h 9.841 c 0.29,0 0.568,0.115 0.774,0.32 0.205,0.206 0.32,0.484 0.32,0.774 v 0.228 c 0,0.29 -0.115,0.568 -0.32,0.774 -0.206,0.205 -0.484,0.32 -0.774,0.32 h -9.839 c -0.192,0 -0.381,-0.05 -0.547,-0.146 -0.167,-0.096 -0.305,-0.234 -0.401,-0.401 -0.096,-0.166 -0.147,-0.355 -0.147,-0.547 v -0.228 c 0,-0.29 0.115,-0.568 0.32,-0.774 0.206,-0.205 0.484,-0.32 0.774,-0.32 z"
+ fill="#ffffff"
+ stroke="#ffffff"
+ stroke-width="2.22514"
+ style="fill:#9f9f9f;fill-opacity:1;stroke:#9f9f9f;stroke-opacity:1" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.028144173"
+ x="0.013794855"
+ id="image1291"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAANW0lE QVR4nO2dS4xc2VnH/98591VVXd3TbntC92ggwZ5R1BWRRZMoIQG1UIQIs0BCcpINESyyQIqySlgl 2I6yQ2xmtpEQilgQI4EUiAQaQQtCFAiNQjLVUWbsjII19iS2u931vI9zzseiqtxP21V9b7vrlr/f pp/33HPr/O53vvO4VYAgCIIgCIIgCIIgCILwLEBnUz6f8mmfZR7ZpKfyop+GQPvKHNX52mmLKjyS K8NGONAEhclUdMMOy2PsSdMcfr08+LK+JTKdNhurQ0GuD3/RGP58hfc1eSESeUUUMuSQPE0CLgPr ILRv0xp2EMd3CHf3DkiS+yJTQYTh0p4QjS1E0TJvYhGorzA2VjGQ6RoAHklEKECiIhuQDsiz3qC1 9jLF8R1KknNkzDYZs0vAi3CuJeKcEkrNM3ALnrfAnneOw3Cbo2iZN+t3GBtNHkSjh5FoagQ6Ik/j LlSSnKM4NirLbit3fo7Y9AhYgrP9vTjq+iJTTkhVHoqgdIWB+yCvyupeh31/xUWR58Jwm5sX4Abd 21ZhEhUo0NUD8nS7UGkKlS1C2QRq3kA5F5KrJ8Q2IaAOdqnIUxCkAgbaIB2yaoesVMItD06HcP4O XBDA1WpwA4lGkegqYwoEok++ygE/3/+CJv6MVrjIwEIB5QpFw9xyUG9ZuL+Jt+6+tnH1vUneKJRX IPrEN/nFiHrfJqBhHJBYwLqcpQqnglZAqAFPAc7xG0miX3n9s9GtPCOzkwpEAPDJVznQy73NxKBx p8PoZjJBWAbmAsJyjeArfqP747sf2rj6V+mhIf6IJzaomvDchIcJM4Of73+hm6Lx0wdO5CkRnZRx 84FDz9AHgpeWPj+Ycrk2zGOv0j5vnhhgJhHo0DzPNXJwn77VcnDiTulwDNxqOZDWn8LaosI6FLBK B2Vi4GHQOJ5xJxKPThKuN+h+D5esyFNaLAPbfXfp0m6owhgcre0MJx8xnDN68sTjBDPRI3lWCeug xl2oTsbVgq5FOCNaKeba7dCL43nX7d7hS17IYQxurjccNpo4JNERxunC6KA8Ww/neTJb4JUIZ0Jm gbSmvbj2rtefr+leb0l3u1CNu1BYb+zr0h52ZwcYMwcadVt78vT7HS29V/lhANVAedX+olfrWC+t 93SaDtr4oETH8ySB9mXkl7HWXqbRDLN97sKkIzhhGmGmNA79xE/8JHDeXFLVab2i03SwFLXWvj1Y FN+LQgcYX4L1LYrjO2TMLmWLUHNJXxd4GcIZ4uvMDzx4WdLxsijQc0lfZ4tQcfy2iuMVOrQF50A0 GiOJHnZf7UVKkpCcWyKbQJkalF/whQhng/XIQwYKgwBpljIZsE2gnFtwgy03Sxh2Y0dC0JgR6DLW sAZjlsier9G8gXLZxJOQwpTiZ/Csb7Q1mY5Mql0V6jkXkT3fIWOWht0YcNzO0oklcLZLzsVkLRSY ZTV9BnAM5RtPs4NyUUDOBeRcTM7ODdt37ZHHji3QKP9h0yPnIpKtGLMDe1BgQy4g5SwUc0rOxYfa 9/Kxx55gS+sSsNAHerUTVFWYRpg9YoDAIOaM2PEgSJgeGZNRHIOAxWOPHTMCjbLwF/edNJMINDNY YjYUPvx5/OBwghyoP9xRKMwWIdgZAioTBQcZSQkD2J4oKIhAQi5EICEXRT5Y+FR56ZzC5z7o46Mv eHi+RvBLditkDvhFl/Hddwy+/oMMb+2UcyN5KQX67Ad8fOXjIXSJU3lfAS/UCZff7+MPXvZx9TsJ /rqZnXW1JqZk9y3wykUPV36z3PIcRivgq78V4nd/tXz3c6kECjTw5Y+Fp/6eNGcBAfjKx8LSdcWl qu5HVjTeU5tFfQYszxE+vFKuXTKlEuilc6Wq7ol4uWTXWKra1vzZjT4j5kp2jaUSSJg+RCAhFyKQ kIvyTTw8ht2kHA8aLYSPznNePqfwexc97MSM/3nXIpnyZ+9mSqBf/8supv1RawJw80/mHvn3Vy55 eOXSoFluPnD4o3/o45329F6UdGFTzMXnFL78G+GT//EMEYGeMnrCV/xDy9M9sSgCPWUmXaqoTPm8 kAgk5EIEespM+mZcjqc3gQZmbBR2HH/+2xF+7fmzuU9++AuHL/1LfOB36YTD8p9sT/dGs5kX6MU6 4aXFsxFop58vemQO+Iv/TAuqzekw8wKVjTe3HW7sOPxfy+Hv3zR4UyKQMAn/eMPgtc3pjjr7kSRa yIUIJORCBBJyIQIJuRCBhFyIQEIuRCAhFyKQkAsRSMiFCCTkQgQSciECCbkQgYRciEBCLkQgIRci kJALEUjIhQgk5EIEEnIhAgm5EIGEXIhAQi5EICEXIpCQCxFIyIUIJORCBBJyIQIJuRCBhFyIQEIu RCAhFyKQkAsRSMjFzL9D2a02Y/GMPhH51hR/REFRzLxAh98lVSgW6cKEXIhAQi5EICEXIpCQCxFI yMVMjcL++49rZ12FZ46ZEuhxn0UqnA7ShQm5EIGEXIhAQi5KJVAnm/21pXZarmsslUA3pvyzs4rg rTNa+D0ppRLoe7ct3u2W6w6dhDsdxn/dnvAzMc+YUgmUWuBr/5FgFhViAF/9ToKsXAGoXAIBwLdv Glz99wS2ZC/047AO+LN/S/BPb5uzrsrElHIi8RtvZPjebYvPfdDHR1/w8Es1gi7ZrWAc8PMu47vv GHz9B1npcp8RpRQIAN7advjTf00AJGddlWeakt23wrQhAgm5EIGEXIhAwgDSJ5odmVggpStMOpzF qZhnnASkPAb6Ex01pkCrQ2FuTVgpoRxoJvJ4NJ4lGn9B7gRd2H1gd7KTCNMNkWGCZpBhIp9JBaxU zORV2fMWOIqWH9nWYwsURcvseQtMXnVQuApEoBmBDBzIskojRypjooCVig617/Vjjz1BDlRjpSLW Gg5EItEMoAgu87Ql1Xc6YadUykpFrHRn2L6bjz52vFNcxyY24XnnWN/rcsuDI5WIPDNC5sPozFql 4foKTvXgHqiY9b059rz7vFlfGbb1lSNtPoZAVxhoMOorHIbbrNQ86xDOj8Ny7TsQHok2bJSX2TRj 4/mB9Tw4HcIptcthuDSUpnFswBi/C9tY5UEe1GJ/B67loZyrf8IRUsMms37mh3MmSGE6YcX6O3BR 9D4XRbcZG6v75Tkg0pMEYmD0qMx1bNbvcK0GFwRwfrVjwdKLzQAchEHmJzBhqkwn7Fn94K4LArgw 3B52X6ME+uhjU2NGoGE3trHKzeg212pwldacBUsQKj3s0Df9LK5VsnYltkG7aiuVOVurwTUvwGGj OWj7Y/IfYDyBhlHoCgNbjM1PuOYFuFoNDnHaK/ZqhKcNx0k76FoTtPu21oWpVu/vk2d1nzwE4Ohm 0AmG8fsk2mhy8wKc6/duzOT+0mcFZnC//9N6/ZfN4iJMrQZ3YyHZF3m29stzLONuKGMANCiIGbgG bDTBH8+uc2t3jebnAZLHiksFM7jdBiW9v72x8IZDfWWYLDcBrDDQwCF5jg0Vk+xIPCKR+8ndV+n9 5/8QzjWoXge80m5wfLYwGbjdAZLkR+n/3nwNmztuIM0W9sQBniTPgf+YEBqV+ytf/OF7qVL5Fitq kO8D2gdkgno6YQJsBs4yENOP2HV//2df+7ufDf54RBrgMeKMyNvvEMB4z+98o1r5yIc/D8efBqmL rNR8znKFU4Act8C4odhe737/+6/9/J+/FQ+S5KsjUSa+8wsRCLhGAFSjAXXvXsdPa+e9auK81E/9 wKt4Jkt9Z632fa3ZQTFbAhQhAJidJE85IVKMFAAcE2kmBZdl1iqtrfbIZNZkfgoTV5FW491sYWHB DhJluMeNsMYhb9LCg+z5KoBVjqLXuVJZsejeQ78+xxUvQZakHEaBsybTzkJzpCmwRgEAsyfyFAE7 UGAYAFKtHSWW/TCz2oNN0tQGEZtQOWPb1nqV4faMjcVhznNyeYD8EWhYBgP4lMJ6gxp3oXZ353Wy 0NK1LrwsCrTJUh2ZVNuQlLNQQAVgQ8yZCFQQRP5AAspYaTidsIu9wHp+YP04td0aTLg7bxcWWnZv mP5Nl1egAodNDcbGKpprO3wpSZzbAXXqPTsXA2TAWTVw1kKx84k5I+aU5A3piqOLFEQBE1WZkLEL 4fwenGdT1wl7Ntipuqjqub3oU8wMXhEC7evGrgP1Bocx2Bg4tKvoLoJtAjWfQjkXk3MgnksIAAzq BZxeAIAQbQAJqANWKmZlI25Fg1X1YKe6b20LDLyOwXA9X/QBiunChuWMkukmjbqyJDlHcfy2cm6B 7PkaOdslNj1ytipd1ymhdG+wa1TXWN/rslK7HEXvc2G4zXvLEwdmmHMJVGAXdnCWurnecGvtkMJw gZPkPpkWyJjdoTjzcK4lEhWMUvMM7ALYheeBvep9DsMljqJwEHk2msOu6/HLE5NQlEDHLnVsYpWx fofQTmgNy4jjUa1bBZ1WOEgLwAKAwR72TdwG6hjmPK/j0MIoUEAeVHQUeDhDPZwbGtIk4PLw+y3C esFnFfbYAPYew7qOgzsJi5UHKF6gQ+WO6nhNuqszY/x1rZNw2g0r4kwPskApCIIgCIJQEP8P03/B 430mofMAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.670942"
+ width="28.670942" />
+ <image
+ y="0.0055872202"
+ x="-0.031073861"
+ id="image3082"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAAL6klE QVR4nO3dTYvcRhrA8adKJZW6Zc0LmTEmmYUF5zYOJjQLOSTQEMhlA7uHsNlPsCHg5DP4MzghHyC3 xZvTGh8CgYaQXMwEkngOZo1f2JAYz2ReLHePSqqqZy+t2Z5xj6c1pbZa7ecHxo09LdVEf0rVkjIG IIQQQgghhBBCCCEvA9bw7ZPJ4TQ2Oo0DPG6bFFJ9xoVTWUxVH1g25vXxfTAAgG63W/GuSa/XK14e DwTH/HklEVUZ0PF4ilBYkiQMACBN0yP7U0rRzFQRKeWRIMIwRACAOI6x1+uNBlRpRNMIiAEAK8JJ 05QppZjWmmmtGQCAtZbCmRLOOQIACCFQCIFSSgzDEEdCGo1oZgJ6Jp6trS2ulGJpmvI8z/nKysph QMaYw/1STO6KaAAAPM87DGh7ext937dhGFopJa6urtqqI6oyoCPx9Pt9nmUZX15e5koprrXm1lpm rWWIyOI4pngqxDnHJEmAMYacc+ScoxDCSint7u6uDYLARlFkx0RUe0Ds2rVrQRRFn3qe93fO+UVE XKxgu6R6TxDxP4j4z4cPH3529epVNfzzM0fkGhD78ssv/2CtvckYW7fWgtYaEKdyyYE4YoyBEAI4 54CIt5VSf/7oo4/+O/IlpQ/cWQNiAADXrl0LFhYWNowx60mSQJZlZ9wceZGCIIA4jsHzvNsPHjz4 09WrVzM44/UiXnLfhx/PAQCiKPo0z/P1nZ0diqdBsiyDnZ0dyPP80tra2hUYWcPC+Gt5JyoT0DPX eRhjH+7t7dEpq4EQEfb29oAx9rdOp8O73S6H8SEdj+oIMeH+xl0kZP1+/3WKp7kQEQ4ODl7f39/n aZpip9NBAICNjY1xH/MZjDmlTRpQ4chH9SzL2mcdPJkNSqlzSZKINE1tv99HIQSur68X14yKLztx lpjkFDb2ImG/3+fGGLfRk9oZYyCKIhFFkVhYWPAGg4HX7/f51tYW73a7405nR0y6BnomnoODA6+i 74HUTCklDg4OxNOnT0Ucx16WZXxMRGOddgo78sYkSVhxhXlpaansJzgygxCR+b7ve55nfd83aZqa OI4hSRIQQuDwRjjCCWugiSPodrssTVOmtWbD2xM0A80Jz/P8LMuEUkqEYegppbzl5WWepilP05QN Z6HCkUllkkU0AwCWJAlTSjFrLSvubVX6XZDaaK2F7/ssz3PgnKPWGpVS3FprRx65cZuBhjtiw7vq vN1uU0BzwvM8YYzxgiDwtNZeu93m1lpmjGFa68PnucBhEX3IGMOGG+eISHfT5wAiciGEZ609fGKi +HXaeycOqFj/aK0n3jhpBmstR0RmreVSyuI1O3/+/KnvpdMQgeGZhCEiK+Kx1h5OGMcfRR5V5jrQ uJ2SORAEAUNEFoZh6feeaQ1E8cynYllS5vjSKYwAwNnPKBQQcUIBESdlH+eYGa+++ip0Oh1YW1uD KIqAsWYty4wxkCQJ3L9/H27dugX7+/t1D+lMGhnQO++8A2+99Vbdw3DieR4sLS3Bm2++CW+88Qbc vHkT7ty5U/ewSmvcKazT6TQ+nuOEEPD+++/D2tpa3UMprVEBtVotePvtt+sexlRwzuHdd9+texil NSqgixcvQhAEdQ9jas6fPw8rKyt1D6OURgXUtP+4Z/HKK6/UPYRSGhWQ7/t1D2HqpJR1D6GURgVE Zg8FRJxQQMRJIy8kjoOIoJQ6/Qtrxhh77jrnwoULoJSCfr8Pv/76K1hrX+DoypubgAaDAXzxxRd1 D+NU586dg48//vjEv798+TJcvnwZAAAePXoE169fhzRNX9TwSqNT2Ay7cOHCzF84pYBesHa73I8T mPXbGxTQC1b2WtasX/uigIgTCugFK/upatZ//tLcfAo7yQcffAALCwu17PvevXuj//wAAAA8ffq0 1Da2trYqHFH15j6gpaUlWF5ermXf4w5+mRklyzL4/vvvqxxS5eY+oKZ59OgR7O3twc7ODty+fXvm H3WlgGbMjz/+CD/99FPdw5gYLaKJEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgT Cog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMK iDihgIgTCog4oYCIEwqIOKGAiJO5/wllu7u7YIypZd9JktSy3xdp7gP66quv6h7CXKNTGHFCAREn FBBxQgERJxQQcTI3n8La7TZ88skndQ/jVIyxuodQqbkJiDEGYRjWPYyXDp3CiBMKiDihgIiTRgWU ZVndQ5g6pVTdQyilUQH9/vvvdQ9h6pr2PTYqoLt37871LPT48WPY3t6uexilNCqgNE3h22+/rXsY U2GthW+++abuYZTWqIAAAH744YeZ/3dEy8rzHG7cuAG//PJL3UMprZEXEr/77ju4d+8edDodWFtb g3PnzjXuCq8xBp48eQL379+HW7duwZMnT+oe0pk0MiAAgN9++w1u3LhR9zBeeo07hZHZQgERJxQQ cUIBEQAAYIzhWd5XOiDP8/CsOyOzjXNe+rhOGhAFM8eyLEPGGKZpCgDlZqMzn8JoFpofw2OJjDFk jCHnHDnnKIRAIQSGYXjisZ44oDAMDzdY7KCS0ZPacc7tMBybpulhRI8fPz79vWV35nkecs7R8zxL s9B8YIxZrbXhnFvP82wxQUwySZQKSAiB29vbKISw/X6f4pkTxhjteZ4ZxmMHg4EdThIohMA4jotj /cwxnyQgBACM4xillMg5Ryml9X2/np9YQConhNBZlpk8z7Xv+0YIYaWUdnisT4wHoMQM1Ov1sFgH 7e7uWiGErWT0pHZaay2lzKWUOssyLaU0u7u7NgxDG4Yh9nq90XiOhHTazVQEgMPb3HEcY5qmFgAg SRKDiI27C06egVLKfDAYWEQ0Ukq7t7dnW62WlVKOnr7GKnMdCItZKIoi22q1DCItg5oOEUFrnUdR lLdaLZMkiWm1WiaKIru6umqHs0/x6xmTroGK33FjY8Ourq7aKIpslmWDir4PUhOlVNLv93WSJKbf 7+t2u/28eJ6JqOzzQAgA0Ov1oNvt2sFgcFdK2aHTWDMhIgwGg3txHOtisRyGIW5sbIyGU9kpbPQ1 9no91FpfT5IE6FTWPIgISZKAMeZfi4uLdnV11W5ubpqNjQ0LABaejWfsQfZK7vfIVPPaa69tRFH0 V2PMed/3gXO6ud8EWmtIkgSyLPv5zp07/+j1evrBgwfPi+XEGeKs557D9125cuWPUsp/c87XhRAg RGOfkn0paK1Baw3W2p+VUn/5/PPPHw7/alwkp55aXBcvDADgvffea1+6dOkKAHzIGLvIOV9w3C6Z AmvtE0S8i4jXNzc3P/v6669TOGWRfJpKAhr+ztfX1/n29rYfRZFQSgnf9/08z4UQwjfGeL7ve9Za joiseO/wNXEwck+yuBFq8zw3nueZPM91EAR5lmW63W5naZrmi4uLZnNzc3StA3CGeADc/6+M0QuN GIYhtlot0+/3IY5jPDg4gCAIUGttpZSeMcYDABYEAQegeKpURJTnuUVElFKaLMvM8HcdhqEeXuMp Hs84dYE8iaoWLAgArLhSrbVmUkqmtTZSSuCco9YajTFWSskRERCRUUDVKQISQqBSyiKiDYLA+L5v ENG0Wi2TZZmNosg+7+Zo6f26bgCOnsZYp9Ph+/v7fDAYeHEce0opT2vN2+02N8Zway2jeKZj9IEw z/PsYDCwwxujJkkS0263zeLioh1+VHda+xSqmIHG3S9DrbVNkgSWl5dRKcWzLOPWWlYENPzaCnZP AP7/zyoUARljMAxDK6W0u7u7NgiCk+5tOc1CVc0CR2ahbrfLtra2uFKKpWnKrbXMGMNWVlaY1poZ Y2j2mZLiGZ7t7W0sHv4bhoST3p4oo+qAites2+2yJElYmqZMKcW01kxrffh11lqKqGKjTxAWjx9L KTEMQ4zjGE+4MToTAR3f1uHH9CIkAIA0TSmaF6R4EH4kHICK4wGoNqDj25vkNZmOkyKpNB6A6R3M 49ulaOpT6aL5uGkfWApndtAjE4QQQgghFfkfLnfEbGu/P8gAAAAASUVORK5CYII= "
+ style="display:inline;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.697369"
+ width="28.697369" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_sony_800n.svg b/app/src/main/assets/ic_device_sony_800n.svg
new file mode 100644
index 0000000..1c80954
--- /dev/null
+++ b/app/src/main/assets/ic_device_sony_800n.svg
@@ -0,0 +1,215 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_sony_800n.svg"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="22.627418"
+ inkscape:cx="8.8167373"
+ inkscape:cy="16.373941"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#1f7fdb;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#4dabf5;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ <g
+ id="g2458"
+ transform="translate(0.01135305,-0.15009041)">
+ <g
+ id="g2347">
+ <path
+ id="path13615-2"
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 7.279849,6.2422412 c -0.03346,0.01692 -0.06557,0.03428 -0.09731,0.05262 -0.03173,0.01835 -0.06309,0.03768 -0.09514,0.05843 -0.03205,0.02076 -0.06477,0.04295 -0.09916,0.0671 -0.03438,0.02415 -0.07046,0.05023 -0.109229,0.07876 -0.02534,0.01848 -0.05048,0.03753 -0.07551,0.05707 -0.02503,0.01954 -0.0499,0.0396 -0.07456,0.06013 -0.04934,0.04106 -0.09793,0.084 -0.145486,0.128648 -0.04756,0.04465 -0.09414,0.091 -0.139497,0.138751 -0.04536,0.04774 -0.08948,0.09688 -0.132231,0.147218 -0.04275,0.05034 -0.08411,0.101873 -0.123825,0.15432 -0.0397,0.05244 -0.07777,0.105815 -0.114003,0.159856 -0.03625,0.05404 -0.07062,0.108751 -0.102975,0.163896 -0.01618,0.02756 -0.0319,0.05524 -0.04703,0.08299 -0.01513,0.02775 -0.02969,0.05557 -0.04371,0.08346 -0.03618,0.07192 -0.06927,0.14029 -0.09954,0.205974 -0.03028,0.0657 -0.05776,0.1287 -0.08266,0.190082 -0.02488,0.06138 -0.04721,0.121101 -0.06723,0.180116 -0.01,0.02952 -0.01941,0.05886 -0.0283,0.08813 -0.009,0.02927 -0.01728,0.05852 -0.0251,0.08781 -0.0078,0.02929 -0.01514,0.05865 -0.02198,0.0882 -0.0069,0.02954 -0.01321,0.05927 -0.01911,0.0893 -0.01181,0.06007 -0.02184,0.12132 -0.03033,0.184805 -0.0086,0.06349 -0.01548,0.129192 -0.02116,0.198028 -0.0057,0.06884 -0.01012,0.140872 -0.01351,0.216987 -0.0025,0.05629 -0.0043,0.110992 -0.0052,0.16448 -8.64e-4,0.05349 -9.69e-4,0.105698 -6.4e-5,0.156926 8.64e-4,0.05122 0.0027,0.101473 0.0056,0.150996 0.0029,0.04952 0.0067,0.09832 0.01168,0.146697 0.0049,0.04837 0.01103,0.09631 0.01829,0.144095 0.0073,0.04777 0.01565,0.09538 0.02537,0.1431138 0.0097,0.04773 0.02073,0.09559 0.03307,0.143831 0.01233,0.04823 0.02603,0.09688 0.04117,0.146177 0.01514,0.04931 0.03172,0.0993 0.04983,0.150215 0.01811,0.05092 0.03775,0.102736 0.05902,0.155817 0.02125,0.05309 0.04414,0.107386 0.0687,0.163179 0.02456,0.0558 0.05079,0.113155 0.07883,0.172233 0.02805,0.05908 0.0579,0.119891 0.0896,0.182787 0.03169,0.0629 0.06523,0.127824 0.100756,0.195098 0.03552,0.06727 0.07298,0.136893 0.112485,0.209101 0.03951,0.07221 0.08105,0.147045 0.124707,0.224738 0.06395,0.110373 0.12747,0.218614 0.190476,0.324728 0.063,0.10611 0.12555,0.210119 0.187612,0.312027 0.06207,0.101911 0.123617,0.201698 0.184741,0.299454 0.06112,0.09775 0.121762,0.19342 0.181938,0.287076 0.06017,0.09366 0.119894,0.185283 0.179133,0.274895 0.05924,0.08961 0.118038,0.177222 0.176331,0.262845 0.05829,0.08562 0.116105,0.1693 0.173462,0.250989 0.05736,0.08169 0.114249,0.161388 0.170659,0.239198 0.05641,0.0778 0.112317,0.153684 0.167791,0.227668 0.05546,0.07399 0.110513,0.14612 0.165051,0.216333 0.05453,0.07022 0.108593,0.138504 0.162182,0.204999 0.0027,0.0033 0.0053,0.0064 0.008,0.0098 C 8.00872,14.732698 7.120877,13.942779 6.658257,13.361473 5.598455,12.029821 5.055189,10.452858 5.217346,9.1790022 5.365603,8.0143522 5.882,7.0633652 6.841532,6.1881132 7.079748,5.9708212 7.106058,5.9999142 7.175394,6.0213522 7.413036,6.1657852 7.440231,6.1534602 7.2799,6.2422692 Z"
+ sodipodi:nodetypes="cssscssssssssssssssssssssssssssssssssssscsssssssssssccsscc" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 11.577603,11.492866 c 0.322335,-0.01399 0.357232,0.03938 0.593611,-0.181212 0.221983,-0.240393 0.274315,-0.351823 0.276651,-0.568905 0.0025,-0.225226 -0.0071,-0.251559 -0.197844,-0.709979 C 11.842608,9.0532972 11.354715,8.0707792 11.126601,7.7704282 10.78476,7.3203352 10.29549,6.9914762 9.74677,6.8222662 9.333842,6.6949312 8.625467,6.7136062 8.182495,6.8635162 6.779112,7.3384312 6.027958,8.8521302 6.491449,10.27125 c 0.271605,0.831606 0.705445,1.281983 1.214295,1.281983 z"
+ id="path3272-6"
+ sodipodi:nodetypes="ccsssssscc" />
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ id="path16756-1"
+ sodipodi:type="arc"
+ sodipodi:cx="-12.009243"
+ sodipodi:cy="12.377947"
+ sodipodi:rx="0.19409445"
+ sodipodi:ry="0.19840427"
+ sodipodi:start="0.57990659"
+ sodipodi:end="0.54828965"
+ sodipodi:arc-type="arc"
+ sodipodi:open="true"
+ d="m -11.84688,12.486662 a 0.19409445,0.19840427 0 0 1 -0.26743,0.05811 0.19409445,0.19840427 0 0 1 -0.05896,-0.2729 0.19409445,0.19840427 0 0 1 0.266498,-0.06242 0.19409445,0.19840427 0 0 1 0.06317,0.271913"
+ transform="scale(-1,1)" />
+ <path
+ id="rect20090-0"
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ d="m 11.857757,10.952689 -3.825845,0.06844 c -0.144962,0.0026 -0.261704,0.119311 -0.261704,0.267512 0,0.148204 0.11674,0.270106 0.261704,0.267514 l 3.825845,-0.06844 c 0.144962,-0.0026 0.261704,-0.119312 0.261704,-0.267515 0,-0.148201 -0.116741,-0.270105 -0.261704,-0.267511 z"
+ sodipodi:nodetypes="sssssss" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 12.18194,17.216199 c 0.624312,-0.131607 1.333297,-0.60414 1.680554,-1.120063 0.529917,-0.787312 0.660533,-1.614976 0.412007,-2.610703 -0.445153,-1.783509 -1.737094,-5.0026358 -2.421826,-6.0344618 -0.48947,-0.737586 -1.146722,-1.225567 -1.985164,-1.473899 -0.284388,-0.08423 -0.549789,-0.118307 -0.970098,-0.126588 -0.24378,-0.0048 -0.652706,0.03674 -1.040841,0.17381 -0.485591,0.168758 -0.640212,0.230083 -0.950323,0.458278 -0.405441,0.295742 -0.788291,0.729017 -1.012688,1.175025 -0.289449,0.575304 -0.391516,0.937594 -0.418687,1.546511 -0.04019,0.9006898 0.117467,1.4116638 0.816102,2.6547258 1.00275,1.681382 1.225081,2.020621 2.371836,3.434141 0.232245,0.299614 0.629643,0.75695 0.883104,1.016312 0.372595,0.381249 0.523321,0.502467 0.787118,0.633023 0.598301,0.296089 1.270931,0.395727 1.848906,0.273889 z"
+ id="path5762-6"
+ sodipodi:nodetypes="ssssssccssccsss" />
+ </g>
+ <g
+ id="g2347-1"
+ style="display:inline"
+ transform="matrix(-1,0,0,1,28.780726,6.499701)">
+ <path
+ id="path13615-2-5"
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 7.279849,6.2422412 c -0.03346,0.01692 -0.06557,0.03428 -0.09731,0.05262 -0.03173,0.01835 -0.06309,0.03768 -0.09514,0.05843 -0.03205,0.02076 -0.06477,0.04295 -0.09916,0.0671 -0.03438,0.02415 -0.07046,0.05023 -0.109229,0.07876 -0.02534,0.01848 -0.05048,0.03753 -0.07551,0.05707 -0.02503,0.01954 -0.0499,0.0396 -0.07456,0.06013 -0.04934,0.04106 -0.09793,0.084 -0.145486,0.128648 -0.04756,0.04465 -0.09414,0.091 -0.139497,0.138751 -0.04536,0.04774 -0.08948,0.09688 -0.132231,0.147218 -0.04275,0.05034 -0.08411,0.101873 -0.123825,0.15432 -0.0397,0.05244 -0.07777,0.105815 -0.114003,0.159856 -0.03625,0.05404 -0.07062,0.108751 -0.102975,0.163896 -0.01618,0.02756 -0.0319,0.05524 -0.04703,0.08299 -0.01513,0.02775 -0.02969,0.05557 -0.04371,0.08346 -0.03618,0.07192 -0.06927,0.14029 -0.09954,0.205974 -0.03028,0.0657 -0.05776,0.1287 -0.08266,0.190082 -0.02488,0.06138 -0.04721,0.121101 -0.06723,0.180116 -0.01,0.02952 -0.01941,0.05886 -0.0283,0.08813 -0.009,0.02927 -0.01728,0.05852 -0.0251,0.08781 -0.0078,0.02929 -0.01514,0.05865 -0.02198,0.0882 -0.0069,0.02954 -0.01321,0.05927 -0.01911,0.0893 -0.01181,0.06007 -0.02184,0.12132 -0.03033,0.184805 -0.0086,0.06349 -0.01548,0.129192 -0.02116,0.198028 -0.0057,0.06884 -0.01012,0.140872 -0.01351,0.216987 -0.0025,0.05629 -0.0043,0.110992 -0.0052,0.16448 -8.64e-4,0.05349 -9.69e-4,0.105698 -6.4e-5,0.156926 8.64e-4,0.05122 0.0027,0.101473 0.0056,0.150996 0.0029,0.04952 0.0067,0.09832 0.01168,0.146697 0.0049,0.04837 0.01103,0.09631 0.01829,0.144095 0.0073,0.04777 0.01565,0.09538 0.02537,0.1431138 0.0097,0.04773 0.02073,0.09559 0.03307,0.143831 0.01233,0.04823 0.02603,0.09688 0.04117,0.146177 0.01514,0.04931 0.03172,0.0993 0.04983,0.150215 0.01811,0.05092 0.03775,0.102736 0.05902,0.155817 0.02125,0.05309 0.04414,0.107386 0.0687,0.163179 0.02456,0.0558 0.05079,0.113155 0.07883,0.172233 0.02805,0.05908 0.0579,0.119891 0.0896,0.182787 0.03169,0.0629 0.06523,0.127824 0.100756,0.195098 0.03552,0.06727 0.07298,0.136893 0.112485,0.209101 0.03951,0.07221 0.08105,0.147045 0.124707,0.224738 0.06395,0.110373 0.12747,0.218614 0.190476,0.324728 0.063,0.10611 0.12555,0.210119 0.187612,0.312027 0.06207,0.101911 0.123617,0.201698 0.184741,0.299454 0.06112,0.09775 0.121762,0.19342 0.181938,0.287076 0.06017,0.09366 0.119894,0.185283 0.179133,0.274895 0.05924,0.08961 0.118038,0.177222 0.176331,0.262845 0.05829,0.08562 0.116105,0.1693 0.173462,0.250989 0.05736,0.08169 0.114249,0.161388 0.170659,0.239198 0.05641,0.0778 0.112317,0.153684 0.167791,0.227668 0.05546,0.07399 0.110513,0.14612 0.165051,0.216333 0.05453,0.07022 0.108593,0.138504 0.162182,0.204999 0.0027,0.0033 0.0053,0.0064 0.008,0.0098 C 8.00872,14.732698 7.120877,13.942779 6.658257,13.361473 5.598455,12.029821 5.055189,10.452858 5.217346,9.1790022 5.365603,8.0143522 5.882,7.0633652 6.841532,6.1881132 7.079748,5.9708212 7.106058,5.9999142 7.175394,6.0213522 7.413036,6.1657852 7.440231,6.1534602 7.2799,6.2422692 Z"
+ sodipodi:nodetypes="cssscssssssssssssssssssssssssssssssssssscsssssssssssccsscc" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 11.577603,11.492866 c 0.322335,-0.01399 0.357232,0.03938 0.593611,-0.181212 0.221983,-0.240393 0.274315,-0.351823 0.276651,-0.568905 0.0025,-0.225226 -0.0071,-0.251559 -0.197844,-0.709979 C 11.842608,9.0532972 11.354715,8.0707792 11.126601,7.7704282 10.78476,7.3203352 10.29549,6.9914762 9.74677,6.8222662 9.333842,6.6949312 8.625467,6.7136062 8.182495,6.8635162 6.779112,7.3384312 6.027958,8.8521302 6.491449,10.27125 c 0.271605,0.831606 0.705445,1.281983 1.214295,1.281983 z"
+ id="path3272-6-9"
+ sodipodi:nodetypes="ccsssssscc" />
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ id="path16756-1-4"
+ sodipodi:type="arc"
+ sodipodi:cx="-12.009243"
+ sodipodi:cy="12.377947"
+ sodipodi:rx="0.19409445"
+ sodipodi:ry="0.19840427"
+ sodipodi:start="0.57990659"
+ sodipodi:end="0.54828965"
+ sodipodi:arc-type="arc"
+ sodipodi:open="true"
+ transform="scale(-1,1)"
+ d="m -11.84688,12.486662 a 0.19409445,0.19840427 0 0 1 -0.26743,0.05811 0.19409445,0.19840427 0 0 1 -0.05896,-0.2729 0.19409445,0.19840427 0 0 1 0.266498,-0.06242 0.19409445,0.19840427 0 0 1 0.06317,0.271913" />
+ <path
+ id="rect20090-0-9"
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ d="m 11.857757,10.952689 -3.825845,0.06844 c -0.144962,0.0026 -0.261704,0.119311 -0.261704,0.267512 0,0.148204 0.11674,0.270106 0.261704,0.267514 l 3.825845,-0.06844 c 0.144962,-0.0026 0.261704,-0.119312 0.261704,-0.267515 0,-0.148201 -0.116741,-0.270105 -0.261704,-0.267511 z"
+ sodipodi:nodetypes="sssssss" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 12.18194,17.216199 c 0.624312,-0.131607 1.333297,-0.60414 1.680554,-1.120063 0.529917,-0.787312 0.660533,-1.614976 0.412007,-2.610703 -0.445153,-1.783509 -1.737094,-5.0026358 -2.421826,-6.0344618 -0.48947,-0.737586 -1.146722,-1.225567 -1.985164,-1.473899 -0.284388,-0.08423 -0.549789,-0.118307 -0.970098,-0.126588 -0.24378,-0.0048 -0.652706,0.03674 -1.040841,0.17381 -0.485591,0.168758 -0.640212,0.230083 -0.950323,0.458278 -0.405441,0.295742 -0.788291,0.729017 -1.012688,1.175025 -0.289449,0.575304 -0.391516,0.937594 -0.418687,1.546511 -0.04019,0.9006898 0.117467,1.4116638 0.816102,2.6547258 1.00275,1.681382 1.225081,2.020621 2.371836,3.434141 0.232245,0.299614 0.629643,0.75695 0.883104,1.016312 0.372595,0.381249 0.523321,0.502467 0.787118,0.633023 0.598301,0.296089 1.270931,0.395727 1.848906,0.273889 z"
+ id="path5762-6-0"
+ sodipodi:nodetypes="ssssssccssccsss" />
+ </g>
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none"
+ sodipodi:insensitive="true">
+ <image
+ y="0.0079936879"
+ x="0.010951031"
+ id="image4124"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAAAA3NCSVQICAjb4U/gAAAACXBIWXMA ADFOAAAxTgGacD5yAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAABF0RVh0 VGl0bGUAUGViYmxlIEljb26KvwD9AAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAAY3RFWHRDb3B5 cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0dHA6Ly9jcmVh dGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzMuMC/eBBrlAAABy1BMVEUAAAAAAAAA AP8AAIAAAFUAAKoAVaoAAIAAQIAAM2YAM5kzM2YzM5krK4AkJG0kJJIgIIAcHHEcHI4aGoAXF3QX F4sXLnQXLosVK4AUJ3YUJ4kSJIAkJIARInciInciIogQIIAgIIAeHngeHoccHIAcK4AbKHkbKIYa GoAaJoAYJHkXI4AXI4sWIXoWIYUVIIAfH3ofH4UdJ4ATHIQcJnsbJIAaI4QaIoAZIXsZIYQYIHgY IIAXH4MXJ3wXHoAXJoBLpfAWJHwdJHwdJIMcI4Acas0VIoMbIoAaIXwaIIAZH3wZH4MZJXwYHoAY HoYYJIAehtsYHoIefdtHoO0jetZGovNEn+4gg+EfgN5Ep/VKqfREpvQgfdkgfttKqvQggd4hjesw nPMwnPRJqfUhgd4hjutJqfUgfttNq/VNq/QhlPEhlPIhlvMkmPMglvIff9sgjeoglPAhk/AhlPEh lPIhlvMkl/MkmPMlmPMmmPMnmfMpmvMzn/Q0n/Q1n/Q2oPRIqfVNq/VasfZbsvZhtPZitfdtuveQ yvmXzvmj0/qk1Pqu2fu83/vR6f3T6v3Z7f3i8f3j8v3l8/7m8/7p9f7q9f7x+P7y+f7////ougeB AAAAb3RSTlMAAQECAwMDBAQFBQUFBgcHCAkJCgsLCwsMDQ0ODg8PDxAQERESEhMTFBQVFhYXFxgZ GRobGxwdHh8fICAhISIiIiMjIyQkJSYnKCkpKSoqKiorKyssLC2xs7O0tba2tu7u7u7u7+/w8fHy +/v7+/ywJhX9AAAC0klEQVRo3u2a+VfTQBCAm00iW0nSTVNKbKQYbIMUC6jU4q2IB2rF+74vPEAR LSjIIXgAAiqef65py6MNJe17y1RF8/26L/N1N9O8ZGZcLof/DiYFoiV1cZHwiGVZjh7zalRAYYbn sFsQ6RFwGccyBeJjwSMrPn8lHX6fQqRy3tbAsFiU1So9FA4bNIRD63XNTwTO5pSYc9fuvwbgwaW9 SxqYs/eGBgcAGBy+fRrlpRPDsJdfJoEYuMgvzibz/vK3kmDcFMuxNZvM/BG74QTdFV6PJZsYlhe9 XXCCrlCVStwcyhG45QCkoCmiV0o4uwXEib4aQMHjbc0RTXYvCMx/mGdNHaSgraWxRhEXzsgUEC0C KWjfszlUYRWsrYcUHGttNlSRzxUEo5CCjrZYrSqVVlD3Nwj6vqcZsovzKrPeTy3o/5lmxE4wnFl/ 4QhKLvjy2YY5IEExHMH0B1umQQSj9o+EUUewIBh/m8c4qOBbfu58dQRLCmYnJmZLKphKJicdwR8X vJsymSmhIM37FSv4OJlhxnlUOAJHAC8Ye5PHmPNe9PsEJX/5dT5AigvmPtkw53wnr3xB34809uWc zDp9OWflV7xgBaBlzY79MWORALhuui+vbhoArfwe3rUp5MtWfl2Il1QDUtAaj+rKakv1XdEhBfGm Wo1kq+/pM1Ih+wdRI6iIq3IFvEAewgke6QHFg1mU26PjhbtwghsKEbG1X8ew3PmnUPGfXRAwv7hr yqAD13t6nwPQ23PnBMeivG4gg85c7XwCQOeVU+avZ5bslcrrNm7dfehIInGSguOJo+0Hd27ZoOam j3ULvOjTI02x+PYddLTEY42GJpfnpo/L0g50k8pqI1IfbWikoSFaXxfSFKnMrqFsZpKbKKoWDFbT EdQCqlfCdhtIG7AoESLTQogk4EJd/VRTn8fLgecKTg3MjyUsA4SKDD7MT1bQ8++NmvwCRmydh2Fg UvEAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.710279"
+ width="28.710279" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_sony_800n_disabled.svg b/app/src/main/assets/ic_device_sony_800n_disabled.svg
new file mode 100644
index 0000000..e43bffb
--- /dev/null
+++ b/app/src/main/assets/ic_device_sony_800n_disabled.svg
@@ -0,0 +1,268 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_sony_800n_disabled.svg"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16"
+ inkscape:cx="7.34375"
+ inkscape:cy="7.65625"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer6"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#9f9f9f;fill-opacity:0.94117647;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#8a8a8a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer6"
+ inkscape:label="Foreground"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer7"
+ inkscape:label="Backplane"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer8"
+ inkscape:label="Frontplane"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer9"
+ inkscape:label="Base"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer10"
+ inkscape:label="Top"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer11"
+ inkscape:label="Toptop"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer12"
+ inkscape:label="Toptoptop"
+ style="display:inline" />
+ </g>
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#8a8a8a;fill-opacity:0.94117647"
+ d="m 12.48275,10.865376 h 3.907 v 6.978 h -3.907 z"
+ android:strokeWidth="1"
+ id="path2735" />
+ <g
+ id="g2458"
+ transform="translate(0.01135305,-0.15009041)"
+ style="display:inline">
+ <g
+ id="g2347">
+ <path
+ id="path13615-2"
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 7.279849,6.2422412 c -0.03346,0.01692 -0.06557,0.03428 -0.09731,0.05262 -0.03173,0.01835 -0.06309,0.03768 -0.09514,0.05843 -0.03205,0.02076 -0.06477,0.04295 -0.09916,0.0671 -0.03438,0.02415 -0.07046,0.05023 -0.109229,0.07876 -0.02534,0.01848 -0.05048,0.03753 -0.07551,0.05707 -0.02503,0.01954 -0.0499,0.0396 -0.07456,0.06013 -0.04934,0.04106 -0.09793,0.084 -0.145486,0.128648 -0.04756,0.04465 -0.09414,0.091 -0.139497,0.138751 -0.04536,0.04774 -0.08948,0.09688 -0.132231,0.147218 -0.04275,0.05034 -0.08411,0.101873 -0.123825,0.15432 -0.0397,0.05244 -0.07777,0.105815 -0.114003,0.159856 -0.03625,0.05404 -0.07062,0.108751 -0.102975,0.163896 -0.01618,0.02756 -0.0319,0.05524 -0.04703,0.08299 -0.01513,0.02775 -0.02969,0.05557 -0.04371,0.08346 -0.03618,0.07192 -0.06927,0.14029 -0.09954,0.205974 -0.03028,0.0657 -0.05776,0.1287 -0.08266,0.190082 -0.02488,0.06138 -0.04721,0.121101 -0.06723,0.180116 -0.01,0.02952 -0.01941,0.05886 -0.0283,0.08813 -0.009,0.02927 -0.01728,0.05852 -0.0251,0.08781 -0.0078,0.02929 -0.01514,0.05865 -0.02198,0.0882 -0.0069,0.02954 -0.01321,0.05927 -0.01911,0.0893 -0.01181,0.06007 -0.02184,0.12132 -0.03033,0.184805 -0.0086,0.06349 -0.01548,0.129192 -0.02116,0.198028 -0.0057,0.06884 -0.01012,0.140872 -0.01351,0.216987 -0.0025,0.05629 -0.0043,0.110992 -0.0052,0.16448 -8.64e-4,0.05349 -9.69e-4,0.105698 -6.4e-5,0.156926 8.64e-4,0.05122 0.0027,0.101473 0.0056,0.150996 0.0029,0.04952 0.0067,0.09832 0.01168,0.146697 0.0049,0.04837 0.01103,0.09631 0.01829,0.144095 0.0073,0.04777 0.01565,0.09538 0.02537,0.1431138 0.0097,0.04773 0.02073,0.09559 0.03307,0.143831 0.01233,0.04823 0.02603,0.09688 0.04117,0.146177 0.01514,0.04931 0.03172,0.0993 0.04983,0.150215 0.01811,0.05092 0.03775,0.102736 0.05902,0.155817 0.02125,0.05309 0.04414,0.107386 0.0687,0.163179 0.02456,0.0558 0.05079,0.113155 0.07883,0.172233 0.02805,0.05908 0.0579,0.119891 0.0896,0.182787 0.03169,0.0629 0.06523,0.127824 0.100756,0.195098 0.03552,0.06727 0.07298,0.136893 0.112485,0.209101 0.03951,0.07221 0.08105,0.147045 0.124707,0.224738 0.06395,0.110373 0.12747,0.218614 0.190476,0.324728 0.063,0.10611 0.12555,0.210119 0.187612,0.312027 0.06207,0.101911 0.123617,0.201698 0.184741,0.299454 0.06112,0.09775 0.121762,0.19342 0.181938,0.287076 0.06017,0.09366 0.119894,0.185283 0.179133,0.274895 0.05924,0.08961 0.118038,0.177222 0.176331,0.262845 0.05829,0.08562 0.116105,0.1693 0.173462,0.250989 0.05736,0.08169 0.114249,0.161388 0.170659,0.239198 0.05641,0.0778 0.112317,0.153684 0.167791,0.227668 0.05546,0.07399 0.110513,0.14612 0.165051,0.216333 0.05453,0.07022 0.108593,0.138504 0.162182,0.204999 0.0027,0.0033 0.0053,0.0064 0.008,0.0098 C 8.00872,14.732698 7.120877,13.942779 6.658257,13.361473 5.598455,12.029821 5.055189,10.452858 5.217346,9.1790022 5.365603,8.0143522 5.882,7.0633652 6.841532,6.1881132 7.079748,5.9708212 7.106058,5.9999142 7.175394,6.0213522 7.413036,6.1657852 7.440231,6.1534602 7.2799,6.2422692 Z"
+ sodipodi:nodetypes="cssscssssssssssssssssssssssssssssssssssscsssssssssssccsscc" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 11.577603,11.492866 c 0.322335,-0.01399 0.357232,0.03938 0.593611,-0.181212 0.221983,-0.240393 0.274315,-0.351823 0.276651,-0.568905 0.0025,-0.225226 -0.0071,-0.251559 -0.197844,-0.709979 C 11.842608,9.0532972 11.354715,8.0707792 11.126601,7.7704282 10.78476,7.3203352 10.29549,6.9914762 9.74677,6.8222662 9.333842,6.6949312 8.625467,6.7136062 8.182495,6.8635162 6.779112,7.3384312 6.027958,8.8521302 6.491449,10.27125 c 0.271605,0.831606 0.705445,1.281983 1.214295,1.281983 z"
+ id="path3272-6"
+ sodipodi:nodetypes="ccsssssscc" />
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ id="path16756-1"
+ sodipodi:type="arc"
+ sodipodi:cx="-12.009243"
+ sodipodi:cy="12.377947"
+ sodipodi:rx="0.19409445"
+ sodipodi:ry="0.19840427"
+ sodipodi:start="0.57990659"
+ sodipodi:end="0.54828965"
+ sodipodi:arc-type="arc"
+ sodipodi:open="true"
+ transform="scale(-1,1)"
+ d="m -11.84688,12.486662 a 0.19409445,0.19840427 0 0 1 -0.26743,0.05811 0.19409445,0.19840427 0 0 1 -0.05896,-0.2729 0.19409445,0.19840427 0 0 1 0.266498,-0.06242 0.19409445,0.19840427 0 0 1 0.06317,0.271913" />
+ <path
+ id="rect20090-0"
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ d="m 11.857757,10.952689 -3.825845,0.06844 c -0.144962,0.0026 -0.261704,0.119311 -0.261704,0.267512 0,0.148204 0.11674,0.270106 0.261704,0.267514 l 3.825845,-0.06844 c 0.144962,-0.0026 0.261704,-0.119312 0.261704,-0.267515 0,-0.148201 -0.116741,-0.270105 -0.261704,-0.267511 z"
+ sodipodi:nodetypes="sssssss" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 12.18194,17.216199 c 0.624312,-0.131607 1.333297,-0.60414 1.680554,-1.120063 0.529917,-0.787312 0.660533,-1.614976 0.412007,-2.610703 -0.445153,-1.783509 -1.737094,-5.0026358 -2.421826,-6.0344618 -0.48947,-0.737586 -1.146722,-1.225567 -1.985164,-1.473899 -0.284388,-0.08423 -0.549789,-0.118307 -0.970098,-0.126588 -0.24378,-0.0048 -0.652706,0.03674 -1.040841,0.17381 -0.485591,0.168758 -0.640212,0.230083 -0.950323,0.458278 -0.405441,0.295742 -0.788291,0.729017 -1.012688,1.175025 -0.289449,0.575304 -0.391516,0.937594 -0.418687,1.546511 -0.04019,0.9006898 0.117467,1.4116638 0.816102,2.6547258 1.00275,1.681382 1.225081,2.020621 2.371836,3.434141 0.232245,0.299614 0.629643,0.75695 0.883104,1.016312 0.372595,0.381249 0.523321,0.502467 0.787118,0.633023 0.598301,0.296089 1.270931,0.395727 1.848906,0.273889 z"
+ id="path5762-6"
+ sodipodi:nodetypes="ssssssccssccsss" />
+ </g>
+ <g
+ id="g2347-1"
+ style="display:inline"
+ transform="matrix(-1,0,0,1,28.780726,6.499701)">
+ <path
+ id="path13615-2-5"
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 7.279849,6.2422412 c -0.03346,0.01692 -0.06557,0.03428 -0.09731,0.05262 -0.03173,0.01835 -0.06309,0.03768 -0.09514,0.05843 -0.03205,0.02076 -0.06477,0.04295 -0.09916,0.0671 -0.03438,0.02415 -0.07046,0.05023 -0.109229,0.07876 -0.02534,0.01848 -0.05048,0.03753 -0.07551,0.05707 -0.02503,0.01954 -0.0499,0.0396 -0.07456,0.06013 -0.04934,0.04106 -0.09793,0.084 -0.145486,0.128648 -0.04756,0.04465 -0.09414,0.091 -0.139497,0.138751 -0.04536,0.04774 -0.08948,0.09688 -0.132231,0.147218 -0.04275,0.05034 -0.08411,0.101873 -0.123825,0.15432 -0.0397,0.05244 -0.07777,0.105815 -0.114003,0.159856 -0.03625,0.05404 -0.07062,0.108751 -0.102975,0.163896 -0.01618,0.02756 -0.0319,0.05524 -0.04703,0.08299 -0.01513,0.02775 -0.02969,0.05557 -0.04371,0.08346 -0.03618,0.07192 -0.06927,0.14029 -0.09954,0.205974 -0.03028,0.0657 -0.05776,0.1287 -0.08266,0.190082 -0.02488,0.06138 -0.04721,0.121101 -0.06723,0.180116 -0.01,0.02952 -0.01941,0.05886 -0.0283,0.08813 -0.009,0.02927 -0.01728,0.05852 -0.0251,0.08781 -0.0078,0.02929 -0.01514,0.05865 -0.02198,0.0882 -0.0069,0.02954 -0.01321,0.05927 -0.01911,0.0893 -0.01181,0.06007 -0.02184,0.12132 -0.03033,0.184805 -0.0086,0.06349 -0.01548,0.129192 -0.02116,0.198028 -0.0057,0.06884 -0.01012,0.140872 -0.01351,0.216987 -0.0025,0.05629 -0.0043,0.110992 -0.0052,0.16448 -8.64e-4,0.05349 -9.69e-4,0.105698 -6.4e-5,0.156926 8.64e-4,0.05122 0.0027,0.101473 0.0056,0.150996 0.0029,0.04952 0.0067,0.09832 0.01168,0.146697 0.0049,0.04837 0.01103,0.09631 0.01829,0.144095 0.0073,0.04777 0.01565,0.09538 0.02537,0.1431138 0.0097,0.04773 0.02073,0.09559 0.03307,0.143831 0.01233,0.04823 0.02603,0.09688 0.04117,0.146177 0.01514,0.04931 0.03172,0.0993 0.04983,0.150215 0.01811,0.05092 0.03775,0.102736 0.05902,0.155817 0.02125,0.05309 0.04414,0.107386 0.0687,0.163179 0.02456,0.0558 0.05079,0.113155 0.07883,0.172233 0.02805,0.05908 0.0579,0.119891 0.0896,0.182787 0.03169,0.0629 0.06523,0.127824 0.100756,0.195098 0.03552,0.06727 0.07298,0.136893 0.112485,0.209101 0.03951,0.07221 0.08105,0.147045 0.124707,0.224738 0.06395,0.110373 0.12747,0.218614 0.190476,0.324728 0.063,0.10611 0.12555,0.210119 0.187612,0.312027 0.06207,0.101911 0.123617,0.201698 0.184741,0.299454 0.06112,0.09775 0.121762,0.19342 0.181938,0.287076 0.06017,0.09366 0.119894,0.185283 0.179133,0.274895 0.05924,0.08961 0.118038,0.177222 0.176331,0.262845 0.05829,0.08562 0.116105,0.1693 0.173462,0.250989 0.05736,0.08169 0.114249,0.161388 0.170659,0.239198 0.05641,0.0778 0.112317,0.153684 0.167791,0.227668 0.05546,0.07399 0.110513,0.14612 0.165051,0.216333 0.05453,0.07022 0.108593,0.138504 0.162182,0.204999 0.0027,0.0033 0.0053,0.0064 0.008,0.0098 C 8.00872,14.732698 7.120877,13.942779 6.658257,13.361473 5.598455,12.029821 5.055189,10.452858 5.217346,9.1790022 5.365603,8.0143522 5.882,7.0633652 6.841532,6.1881132 7.079748,5.9708212 7.106058,5.9999142 7.175394,6.0213522 7.413036,6.1657852 7.440231,6.1534602 7.2799,6.2422692 Z"
+ sodipodi:nodetypes="cssscssssssssssssssssssssssssssssssssssscsssssssssssccsscc" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 11.577603,11.492866 c 0.322335,-0.01399 0.357232,0.03938 0.593611,-0.181212 0.221983,-0.240393 0.274315,-0.351823 0.276651,-0.568905 0.0025,-0.225226 -0.0071,-0.251559 -0.197844,-0.709979 C 11.842608,9.0532972 11.354715,8.0707792 11.126601,7.7704282 10.78476,7.3203352 10.29549,6.9914762 9.74677,6.8222662 9.333842,6.6949312 8.625467,6.7136062 8.182495,6.8635162 6.779112,7.3384312 6.027958,8.8521302 6.491449,10.27125 c 0.271605,0.831606 0.705445,1.281983 1.214295,1.281983 z"
+ id="path3272-6-9"
+ sodipodi:nodetypes="ccsssssscc" />
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ id="path16756-1-4"
+ sodipodi:type="arc"
+ sodipodi:cx="-12.009243"
+ sodipodi:cy="12.377947"
+ sodipodi:rx="0.19409445"
+ sodipodi:ry="0.19840427"
+ sodipodi:start="0.57990659"
+ sodipodi:end="0.54828965"
+ sodipodi:arc-type="arc"
+ sodipodi:open="true"
+ transform="scale(-1,1)"
+ d="m -11.84688,12.486662 a 0.19409445,0.19840427 0 0 1 -0.26743,0.05811 0.19409445,0.19840427 0 0 1 -0.05896,-0.2729 0.19409445,0.19840427 0 0 1 0.266498,-0.06242 0.19409445,0.19840427 0 0 1 0.06317,0.271913" />
+ <path
+ id="rect20090-0-9"
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ d="m 11.857757,10.952689 -3.825845,0.06844 c -0.144962,0.0026 -0.261704,0.119311 -0.261704,0.267512 0,0.148204 0.11674,0.270106 0.261704,0.267514 l 3.825845,-0.06844 c 0.144962,-0.0026 0.261704,-0.119312 0.261704,-0.267515 0,-0.148201 -0.116741,-0.270105 -0.261704,-0.267511 z"
+ sodipodi:nodetypes="sssssss" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 12.18194,17.216199 c 0.624312,-0.131607 1.333297,-0.60414 1.680554,-1.120063 0.529917,-0.787312 0.660533,-1.614976 0.412007,-2.610703 -0.445153,-1.783509 -1.737094,-5.0026358 -2.421826,-6.0344618 -0.48947,-0.737586 -1.146722,-1.225567 -1.985164,-1.473899 -0.284388,-0.08423 -0.549789,-0.118307 -0.970098,-0.126588 -0.24378,-0.0048 -0.652706,0.03674 -1.040841,0.17381 -0.485591,0.168758 -0.640212,0.230083 -0.950323,0.458278 -0.405441,0.295742 -0.788291,0.729017 -1.012688,1.175025 -0.289449,0.575304 -0.391516,0.937594 -0.418687,1.546511 -0.04019,0.9006898 0.117467,1.4116638 0.816102,2.6547258 1.00275,1.681382 1.225081,2.020621 2.371836,3.434141 0.232245,0.299614 0.629643,0.75695 0.883104,1.016312 0.372595,0.381249 0.523321,0.502467 0.787118,0.633023 0.598301,0.296089 1.270931,0.395727 1.848906,0.273889 z"
+ id="path5762-6-0"
+ sodipodi:nodetypes="ssssssccssccsss" />
+ </g>
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.028144173"
+ x="0.013794855"
+ id="image1291"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAANW0lE QVR4nO2dS4xc2VnH/98591VVXd3TbntC92ggwZ5R1BWRRZMoIQG1UIQIs0BCcpINESyyQIqySlgl 2I6yQ2xmtpEQilgQI4EUiAQaQQtCFAiNQjLVUWbsjII19iS2u931vI9zzseiqtxP21V9b7vrlr/f pp/33HPr/O53vvO4VYAgCIIgCIIgCIIgCILwLEBnUz6f8mmfZR7ZpKfyop+GQPvKHNX52mmLKjyS K8NGONAEhclUdMMOy2PsSdMcfr08+LK+JTKdNhurQ0GuD3/RGP58hfc1eSESeUUUMuSQPE0CLgPr ILRv0xp2EMd3CHf3DkiS+yJTQYTh0p4QjS1E0TJvYhGorzA2VjGQ6RoAHklEKECiIhuQDsiz3qC1 9jLF8R1KknNkzDYZs0vAi3CuJeKcEkrNM3ALnrfAnneOw3Cbo2iZN+t3GBtNHkSjh5FoagQ6Ik/j LlSSnKM4NirLbit3fo7Y9AhYgrP9vTjq+iJTTkhVHoqgdIWB+yCvyupeh31/xUWR58Jwm5sX4Abd 21ZhEhUo0NUD8nS7UGkKlS1C2QRq3kA5F5KrJ8Q2IaAOdqnIUxCkAgbaIB2yaoesVMItD06HcP4O XBDA1WpwA4lGkegqYwoEok++ygE/3/+CJv6MVrjIwEIB5QpFw9xyUG9ZuL+Jt+6+tnH1vUneKJRX IPrEN/nFiHrfJqBhHJBYwLqcpQqnglZAqAFPAc7xG0miX3n9s9GtPCOzkwpEAPDJVznQy73NxKBx p8PoZjJBWAbmAsJyjeArfqP747sf2rj6V+mhIf6IJzaomvDchIcJM4Of73+hm6Lx0wdO5CkRnZRx 84FDz9AHgpeWPj+Ycrk2zGOv0j5vnhhgJhHo0DzPNXJwn77VcnDiTulwDNxqOZDWn8LaosI6FLBK B2Vi4GHQOJ5xJxKPThKuN+h+D5esyFNaLAPbfXfp0m6owhgcre0MJx8xnDN68sTjBDPRI3lWCeug xl2oTsbVgq5FOCNaKeba7dCL43nX7d7hS17IYQxurjccNpo4JNERxunC6KA8Ww/neTJb4JUIZ0Jm gbSmvbj2rtefr+leb0l3u1CNu1BYb+zr0h52ZwcYMwcadVt78vT7HS29V/lhANVAedX+olfrWC+t 93SaDtr4oETH8ySB9mXkl7HWXqbRDLN97sKkIzhhGmGmNA79xE/8JHDeXFLVab2i03SwFLXWvj1Y FN+LQgcYX4L1LYrjO2TMLmWLUHNJXxd4GcIZ4uvMDzx4WdLxsijQc0lfZ4tQcfy2iuMVOrQF50A0 GiOJHnZf7UVKkpCcWyKbQJkalF/whQhng/XIQwYKgwBpljIZsE2gnFtwgy03Sxh2Y0dC0JgR6DLW sAZjlsier9G8gXLZxJOQwpTiZ/Csb7Q1mY5Mql0V6jkXkT3fIWOWht0YcNzO0oklcLZLzsVkLRSY ZTV9BnAM5RtPs4NyUUDOBeRcTM7ODdt37ZHHji3QKP9h0yPnIpKtGLMDe1BgQy4g5SwUc0rOxYfa 9/Kxx55gS+sSsNAHerUTVFWYRpg9YoDAIOaM2PEgSJgeGZNRHIOAxWOPHTMCjbLwF/edNJMINDNY YjYUPvx5/OBwghyoP9xRKMwWIdgZAioTBQcZSQkD2J4oKIhAQi5EICEXRT5Y+FR56ZzC5z7o46Mv eHi+RvBLditkDvhFl/Hddwy+/oMMb+2UcyN5KQX67Ad8fOXjIXSJU3lfAS/UCZff7+MPXvZx9TsJ /rqZnXW1JqZk9y3wykUPV36z3PIcRivgq78V4nd/tXz3c6kECjTw5Y+Fp/6eNGcBAfjKx8LSdcWl qu5HVjTeU5tFfQYszxE+vFKuXTKlEuilc6Wq7ol4uWTXWKra1vzZjT4j5kp2jaUSSJg+RCAhFyKQ kIvyTTw8ht2kHA8aLYSPznNePqfwexc97MSM/3nXIpnyZ+9mSqBf/8supv1RawJw80/mHvn3Vy55 eOXSoFluPnD4o3/o45329F6UdGFTzMXnFL78G+GT//EMEYGeMnrCV/xDy9M9sSgCPWUmXaqoTPm8 kAgk5EIEespM+mZcjqc3gQZmbBR2HH/+2xF+7fmzuU9++AuHL/1LfOB36YTD8p9sT/dGs5kX6MU6 4aXFsxFop58vemQO+Iv/TAuqzekw8wKVjTe3HW7sOPxfy+Hv3zR4UyKQMAn/eMPgtc3pjjr7kSRa yIUIJORCBBJyIQIJuRCBhFyIQEIuRCAhFyKQkAsRSMiFCCTkQgQSciECCbkQgYRciEBCLkQgIRci kJALEUjIhQgk5EIEEnIhAgm5EIGEXIhAQi5EICEXIpCQCxFIyIUIJORCBBJyIQIJuRCBhFyIQEIu RCAhFyKQkAsRSMjFzL9D2a02Y/GMPhH51hR/REFRzLxAh98lVSgW6cKEXIhAQi5EICEXIpCQCxFI yMVMjcL++49rZ12FZ46ZEuhxn0UqnA7ShQm5EIGEXIhAQi5KJVAnm/21pXZarmsslUA3pvyzs4rg rTNa+D0ppRLoe7ct3u2W6w6dhDsdxn/dnvAzMc+YUgmUWuBr/5FgFhViAF/9ToKsXAGoXAIBwLdv Glz99wS2ZC/047AO+LN/S/BPb5uzrsrElHIi8RtvZPjebYvPfdDHR1/w8Es1gi7ZrWAc8PMu47vv GHz9B1npcp8RpRQIAN7advjTf00AJGddlWeakt23wrQhAgm5EIGEXIhAwgDSJ5odmVggpStMOpzF qZhnnASkPAb6Ex01pkCrQ2FuTVgpoRxoJvJ4NJ4lGn9B7gRd2H1gd7KTCNMNkWGCZpBhIp9JBaxU zORV2fMWOIqWH9nWYwsURcvseQtMXnVQuApEoBmBDBzIskojRypjooCVig617/Vjjz1BDlRjpSLW Gg5EItEMoAgu87Ql1Xc6YadUykpFrHRn2L6bjz52vFNcxyY24XnnWN/rcsuDI5WIPDNC5sPozFql 4foKTvXgHqiY9b059rz7vFlfGbb1lSNtPoZAVxhoMOorHIbbrNQ86xDOj8Ny7TsQHok2bJSX2TRj 4/mB9Tw4HcIptcthuDSUpnFswBi/C9tY5UEe1GJ/B67loZyrf8IRUsMms37mh3MmSGE6YcX6O3BR 9D4XRbcZG6v75Tkg0pMEYmD0qMx1bNbvcK0GFwRwfrVjwdKLzQAchEHmJzBhqkwn7Fn94K4LArgw 3B52X6ME+uhjU2NGoGE3trHKzeg212pwldacBUsQKj3s0Df9LK5VsnYltkG7aiuVOVurwTUvwGGj OWj7Y/IfYDyBhlHoCgNbjM1PuOYFuFoNDnHaK/ZqhKcNx0k76FoTtPu21oWpVu/vk2d1nzwE4Ohm 0AmG8fsk2mhy8wKc6/duzOT+0mcFZnC//9N6/ZfN4iJMrQZ3YyHZF3m29stzLONuKGMANCiIGbgG bDTBH8+uc2t3jebnAZLHiksFM7jdBiW9v72x8IZDfWWYLDcBrDDQwCF5jg0Vk+xIPCKR+8ndV+n9 5/8QzjWoXge80m5wfLYwGbjdAZLkR+n/3nwNmztuIM0W9sQBniTPgf+YEBqV+ytf/OF7qVL5Fitq kO8D2gdkgno6YQJsBs4yENOP2HV//2df+7ufDf54RBrgMeKMyNvvEMB4z+98o1r5yIc/D8efBqmL rNR8znKFU4Act8C4odhe737/+6/9/J+/FQ+S5KsjUSa+8wsRCLhGAFSjAXXvXsdPa+e9auK81E/9 wKt4Jkt9Z632fa3ZQTFbAhQhAJidJE85IVKMFAAcE2kmBZdl1iqtrfbIZNZkfgoTV5FW491sYWHB DhJluMeNsMYhb9LCg+z5KoBVjqLXuVJZsejeQ78+xxUvQZakHEaBsybTzkJzpCmwRgEAsyfyFAE7 UGAYAFKtHSWW/TCz2oNN0tQGEZtQOWPb1nqV4faMjcVhznNyeYD8EWhYBgP4lMJ6gxp3oXZ353Wy 0NK1LrwsCrTJUh2ZVNuQlLNQQAVgQ8yZCFQQRP5AAspYaTidsIu9wHp+YP04td0aTLg7bxcWWnZv mP5Nl1egAodNDcbGKpprO3wpSZzbAXXqPTsXA2TAWTVw1kKx84k5I+aU5A3piqOLFEQBE1WZkLEL 4fwenGdT1wl7Ntipuqjqub3oU8wMXhEC7evGrgP1Bocx2Bg4tKvoLoJtAjWfQjkXk3MgnksIAAzq BZxeAIAQbQAJqANWKmZlI25Fg1X1YKe6b20LDLyOwXA9X/QBiunChuWMkukmjbqyJDlHcfy2cm6B 7PkaOdslNj1ytipd1ymhdG+wa1TXWN/rslK7HEXvc2G4zXvLEwdmmHMJVGAXdnCWurnecGvtkMJw gZPkPpkWyJjdoTjzcK4lEhWMUvMM7ALYheeBvep9DsMljqJwEHk2msOu6/HLE5NQlEDHLnVsYpWx fofQTmgNy4jjUa1bBZ1WOEgLwAKAwR72TdwG6hjmPK/j0MIoUEAeVHQUeDhDPZwbGtIk4PLw+y3C esFnFfbYAPYew7qOgzsJi5UHKF6gQ+WO6nhNuqszY/x1rZNw2g0r4kwPskApCIIgCIJQEP8P03/B 430mofMAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.670942"
+ width="28.670942" />
+ <image
+ y="0.0055872202"
+ x="-0.031073861"
+ id="image3082"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAAL6klE QVR4nO3dTYvcRhrA8adKJZW6Zc0LmTEmmYUF5zYOJjQLOSTQEMhlA7uHsNlPsCHg5DP4MzghHyC3 xZvTGh8CgYaQXMwEkngOZo1f2JAYz2ReLHePSqqqZy+t2Z5xj6c1pbZa7ecHxo09LdVEf0rVkjIG IIQQQgghhBBCCCEvA9bw7ZPJ4TQ2Oo0DPG6bFFJ9xoVTWUxVH1g25vXxfTAAgG63W/GuSa/XK14e DwTH/HklEVUZ0PF4ilBYkiQMACBN0yP7U0rRzFQRKeWRIMIwRACAOI6x1+uNBlRpRNMIiAEAK8JJ 05QppZjWmmmtGQCAtZbCmRLOOQIACCFQCIFSSgzDEEdCGo1oZgJ6Jp6trS2ulGJpmvI8z/nKysph QMaYw/1STO6KaAAAPM87DGh7ext937dhGFopJa6urtqqI6oyoCPx9Pt9nmUZX15e5koprrXm1lpm rWWIyOI4pngqxDnHJEmAMYacc+ScoxDCSint7u6uDYLARlFkx0RUe0Ds2rVrQRRFn3qe93fO+UVE XKxgu6R6TxDxP4j4z4cPH3529epVNfzzM0fkGhD78ssv/2CtvckYW7fWgtYaEKdyyYE4YoyBEAI4 54CIt5VSf/7oo4/+O/IlpQ/cWQNiAADXrl0LFhYWNowx60mSQJZlZ9wceZGCIIA4jsHzvNsPHjz4 09WrVzM44/UiXnLfhx/PAQCiKPo0z/P1nZ0diqdBsiyDnZ0dyPP80tra2hUYWcPC+Gt5JyoT0DPX eRhjH+7t7dEpq4EQEfb29oAx9rdOp8O73S6H8SEdj+oIMeH+xl0kZP1+/3WKp7kQEQ4ODl7f39/n aZpip9NBAICNjY1xH/MZjDmlTRpQ4chH9SzL2mcdPJkNSqlzSZKINE1tv99HIQSur68X14yKLztx lpjkFDb2ImG/3+fGGLfRk9oZYyCKIhFFkVhYWPAGg4HX7/f51tYW73a7405nR0y6BnomnoODA6+i 74HUTCklDg4OxNOnT0Ucx16WZXxMRGOddgo78sYkSVhxhXlpaansJzgygxCR+b7ve55nfd83aZqa OI4hSRIQQuDwRjjCCWugiSPodrssTVOmtWbD2xM0A80Jz/P8LMuEUkqEYegppbzl5WWepilP05QN Z6HCkUllkkU0AwCWJAlTSjFrLSvubVX6XZDaaK2F7/ssz3PgnKPWGpVS3FprRx65cZuBhjtiw7vq vN1uU0BzwvM8YYzxgiDwtNZeu93m1lpmjGFa68PnucBhEX3IGMOGG+eISHfT5wAiciGEZ609fGKi +HXaeycOqFj/aK0n3jhpBmstR0RmreVSyuI1O3/+/KnvpdMQgeGZhCEiK+Kx1h5OGMcfRR5V5jrQ uJ2SORAEAUNEFoZh6feeaQ1E8cynYllS5vjSKYwAwNnPKBQQcUIBESdlH+eYGa+++ip0Oh1YW1uD KIqAsWYty4wxkCQJ3L9/H27dugX7+/t1D+lMGhnQO++8A2+99Vbdw3DieR4sLS3Bm2++CW+88Qbc vHkT7ty5U/ewSmvcKazT6TQ+nuOEEPD+++/D2tpa3UMprVEBtVotePvtt+sexlRwzuHdd9+texil NSqgixcvQhAEdQ9jas6fPw8rKyt1D6OURgXUtP+4Z/HKK6/UPYRSGhWQ7/t1D2HqpJR1D6GURgVE Zg8FRJxQQMRJIy8kjoOIoJQ6/Qtrxhh77jrnwoULoJSCfr8Pv/76K1hrX+DoypubgAaDAXzxxRd1 D+NU586dg48//vjEv798+TJcvnwZAAAePXoE169fhzRNX9TwSqNT2Ay7cOHCzF84pYBesHa73I8T mPXbGxTQC1b2WtasX/uigIgTCugFK/upatZ//tLcfAo7yQcffAALCwu17PvevXuj//wAAAA8ffq0 1Da2trYqHFH15j6gpaUlWF5ermXf4w5+mRklyzL4/vvvqxxS5eY+oKZ59OgR7O3twc7ODty+fXvm H3WlgGbMjz/+CD/99FPdw5gYLaKJEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgT Cog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMK iDihgIgTCog4oYCIEwqIOKGAiJO5/wllu7u7YIypZd9JktSy3xdp7gP66quv6h7CXKNTGHFCAREn FBBxQgERJxQQcTI3n8La7TZ88skndQ/jVIyxuodQqbkJiDEGYRjWPYyXDp3CiBMKiDihgIiTRgWU ZVndQ5g6pVTdQyilUQH9/vvvdQ9h6pr2PTYqoLt37871LPT48WPY3t6uexilNCqgNE3h22+/rXsY U2GthW+++abuYZTWqIAAAH744YeZ/3dEy8rzHG7cuAG//PJL3UMprZEXEr/77ju4d+8edDodWFtb g3PnzjXuCq8xBp48eQL379+HW7duwZMnT+oe0pk0MiAAgN9++w1u3LhR9zBeeo07hZHZQgERJxQQ cUIBEQAAYIzhWd5XOiDP8/CsOyOzjXNe+rhOGhAFM8eyLEPGGKZpCgDlZqMzn8JoFpofw2OJjDFk jCHnHDnnKIRAIQSGYXjisZ44oDAMDzdY7KCS0ZPacc7tMBybpulhRI8fPz79vWV35nkecs7R8zxL s9B8YIxZrbXhnFvP82wxQUwySZQKSAiB29vbKISw/X6f4pkTxhjteZ4ZxmMHg4EdThIohMA4jotj /cwxnyQgBACM4xillMg5Ryml9X2/np9YQConhNBZlpk8z7Xv+0YIYaWUdnisT4wHoMQM1Ov1sFgH 7e7uWiGErWT0pHZaay2lzKWUOssyLaU0u7u7NgxDG4Yh9nq90XiOhHTazVQEgMPb3HEcY5qmFgAg SRKDiI27C06egVLKfDAYWEQ0Ukq7t7dnW62WlVKOnr7GKnMdCItZKIoi22q1DCItg5oOEUFrnUdR lLdaLZMkiWm1WiaKIru6umqHs0/x6xmTroGK33FjY8Ourq7aKIpslmWDir4PUhOlVNLv93WSJKbf 7+t2u/28eJ6JqOzzQAgA0Ov1oNvt2sFgcFdK2aHTWDMhIgwGg3txHOtisRyGIW5sbIyGU9kpbPQ1 9no91FpfT5IE6FTWPIgISZKAMeZfi4uLdnV11W5ubpqNjQ0LABaejWfsQfZK7vfIVPPaa69tRFH0 V2PMed/3gXO6ud8EWmtIkgSyLPv5zp07/+j1evrBgwfPi+XEGeKs557D9125cuWPUsp/c87XhRAg RGOfkn0paK1Baw3W2p+VUn/5/PPPHw7/alwkp55aXBcvDADgvffea1+6dOkKAHzIGLvIOV9w3C6Z AmvtE0S8i4jXNzc3P/v6669TOGWRfJpKAhr+ztfX1/n29rYfRZFQSgnf9/08z4UQwjfGeL7ve9Za joiseO/wNXEwck+yuBFq8zw3nueZPM91EAR5lmW63W5naZrmi4uLZnNzc3StA3CGeADc/6+M0QuN GIYhtlot0+/3IY5jPDg4gCAIUGttpZSeMcYDABYEAQegeKpURJTnuUVElFKaLMvM8HcdhqEeXuMp Hs84dYE8iaoWLAgArLhSrbVmUkqmtTZSSuCco9YajTFWSskRERCRUUDVKQISQqBSyiKiDYLA+L5v ENG0Wi2TZZmNosg+7+Zo6f26bgCOnsZYp9Ph+/v7fDAYeHEce0opT2vN2+02N8Zway2jeKZj9IEw z/PsYDCwwxujJkkS0263zeLioh1+VHda+xSqmIHG3S9DrbVNkgSWl5dRKcWzLOPWWlYENPzaCnZP AP7/zyoUARljMAxDK6W0u7u7NgiCk+5tOc1CVc0CR2ahbrfLtra2uFKKpWnKrbXMGMNWVlaY1poZ Y2j2mZLiGZ7t7W0sHv4bhoST3p4oo+qAites2+2yJElYmqZMKcW01kxrffh11lqKqGKjTxAWjx9L KTEMQ4zjGE+4MToTAR3f1uHH9CIkAIA0TSmaF6R4EH4kHICK4wGoNqDj25vkNZmOkyKpNB6A6R3M 49ulaOpT6aL5uGkfWApndtAjE4QQQgghFfkfLnfEbGu/P8gAAAAASUVORK5CYII= "
+ style="display:inline;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.697369"
+ width="28.697369" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_sony_overhead.svg b/app/src/main/assets/ic_device_sony_overhead.svg
new file mode 100644
index 0000000..b4a7c5a
--- /dev/null
+++ b/app/src/main/assets/ic_device_sony_overhead.svg
@@ -0,0 +1,140 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_sony_overhead.svg"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="14.060255"
+ inkscape:cx="16.038116"
+ inkscape:cy="16.820463"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#1f7fdb;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#4dabf5;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ <path
+ style="fill:#fffffe;fill-opacity:1;stroke:#ffffff;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 11.787869,22.120059 c 0,0 -2.0910928,-0.292225 -2.6575991,-1.224839 C 8.591619,20.070641 8.1561259,19.180865 7.631892,18.347822 7.1090996,17.567626 7.597652,16.75833 7.689044,15.972138 7.681324,14.958429 7.4045423,13.938261 7.660211,12.932578 8.0157417,10.868804 9.1613178,8.9354471 10.817587,7.6863456 c 2.688723,-1.872765 6.803457,-1.2142137 8.663673,1.5537297 1.346536,1.7828427 2.026474,4.1325137 1.553678,6.3603877 -0.08676,0.923119 0.697056,1.927541 0.06127,2.790481 -0.596062,0.923479 -1.055471,1.947842 -1.712391,2.823233 -0.755807,0.595235 -2.691274,0.772608 -2.691274,0.772608 0,0 -1.514132,-0.618389 -1.649831,-1.431339 0.04222,-0.63115 0.500806,-1.13731 0.745842,-1.700974 0.841894,-1.549407 1.744777,-3.066705 2.737174,-4.521276 0.490354,-0.443872 1.602437,0.740266 1.564181,-0.407131 0.0762,-1.293714 -0.5099,-2.515494 -1.130701,-3.606183 C 18.440464,9.4639663 17.592752,8.8615802 16.642705,8.6047311 14.609615,7.9145591 12.211932,8.1319408 10.47655,9.4725077 9.3167635,10.728865 8.5844629,12.446821 8.637239,14.181989 c 0.3008752,0.859132 1.1571611,-0.488382 1.685694,0.352971 0.605742,0.619625 0.904223,1.470703 1.405699,2.171243 0.728953,1.200475 1.437492,2.419372 1.999338,3.711723 -0.01029,0.972211 -1.940049,1.702133 -1.940049,1.702133 z"
+ id="path4466"
+ sodipodi:nodetypes="cccccccccccccccccccccccc" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none"
+ sodipodi:insensitive="true">
+ <image
+ y="0.0079936879"
+ x="0.010951031"
+ id="image4124"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAAAA3NCSVQICAjb4U/gAAAACXBIWXMA ADFOAAAxTgGacD5yAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAABF0RVh0 VGl0bGUAUGViYmxlIEljb26KvwD9AAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAAY3RFWHRDb3B5 cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0dHA6Ly9jcmVh dGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzMuMC/eBBrlAAABy1BMVEUAAAAAAAAA AP8AAIAAAFUAAKoAVaoAAIAAQIAAM2YAM5kzM2YzM5krK4AkJG0kJJIgIIAcHHEcHI4aGoAXF3QX F4sXLnQXLosVK4AUJ3YUJ4kSJIAkJIARInciInciIogQIIAgIIAeHngeHoccHIAcK4AbKHkbKIYa GoAaJoAYJHkXI4AXI4sWIXoWIYUVIIAfH3ofH4UdJ4ATHIQcJnsbJIAaI4QaIoAZIXsZIYQYIHgY IIAXH4MXJ3wXHoAXJoBLpfAWJHwdJHwdJIMcI4Acas0VIoMbIoAaIXwaIIAZH3wZH4MZJXwYHoAY HoYYJIAehtsYHoIefdtHoO0jetZGovNEn+4gg+EfgN5Ep/VKqfREpvQgfdkgfttKqvQggd4hjesw nPMwnPRJqfUhgd4hjutJqfUgfttNq/VNq/QhlPEhlPIhlvMkmPMglvIff9sgjeoglPAhk/AhlPEh lPIhlvMkl/MkmPMlmPMmmPMnmfMpmvMzn/Q0n/Q1n/Q2oPRIqfVNq/VasfZbsvZhtPZitfdtuveQ yvmXzvmj0/qk1Pqu2fu83/vR6f3T6v3Z7f3i8f3j8v3l8/7m8/7p9f7q9f7x+P7y+f7////ougeB AAAAb3RSTlMAAQECAwMDBAQFBQUFBgcHCAkJCgsLCwsMDQ0ODg8PDxAQERESEhMTFBQVFhYXFxgZ GRobGxwdHh8fICAhISIiIiMjIyQkJSYnKCkpKSoqKiorKyssLC2xs7O0tba2tu7u7u7u7+/w8fHy +/v7+/ywJhX9AAAC0klEQVRo3u2a+VfTQBCAm00iW0nSTVNKbKQYbIMUC6jU4q2IB2rF+74vPEAR LSjIIXgAAiqef65py6MNJe17y1RF8/26L/N1N9O8ZGZcLof/DiYFoiV1cZHwiGVZjh7zalRAYYbn sFsQ6RFwGccyBeJjwSMrPn8lHX6fQqRy3tbAsFiU1So9FA4bNIRD63XNTwTO5pSYc9fuvwbgwaW9 SxqYs/eGBgcAGBy+fRrlpRPDsJdfJoEYuMgvzibz/vK3kmDcFMuxNZvM/BG74QTdFV6PJZsYlhe9 XXCCrlCVStwcyhG45QCkoCmiV0o4uwXEib4aQMHjbc0RTXYvCMx/mGdNHaSgraWxRhEXzsgUEC0C KWjfszlUYRWsrYcUHGttNlSRzxUEo5CCjrZYrSqVVlD3Nwj6vqcZsovzKrPeTy3o/5lmxE4wnFl/ 4QhKLvjy2YY5IEExHMH0B1umQQSj9o+EUUewIBh/m8c4qOBbfu58dQRLCmYnJmZLKphKJicdwR8X vJsymSmhIM37FSv4OJlhxnlUOAJHAC8Ye5PHmPNe9PsEJX/5dT5AigvmPtkw53wnr3xB34809uWc zDp9OWflV7xgBaBlzY79MWORALhuui+vbhoArfwe3rUp5MtWfl2Il1QDUtAaj+rKakv1XdEhBfGm Wo1kq+/pM1Ih+wdRI6iIq3IFvEAewgke6QHFg1mU26PjhbtwghsKEbG1X8ew3PmnUPGfXRAwv7hr yqAD13t6nwPQ23PnBMeivG4gg85c7XwCQOeVU+avZ5bslcrrNm7dfehIInGSguOJo+0Hd27ZoOam j3ULvOjTI02x+PYddLTEY42GJpfnpo/L0g50k8pqI1IfbWikoSFaXxfSFKnMrqFsZpKbKKoWDFbT EdQCqlfCdhtIG7AoESLTQogk4EJd/VRTn8fLgecKTg3MjyUsA4SKDD7MT1bQ8++NmvwCRmydh2Fg UvEAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.710279"
+ width="28.710279" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_sony_overhead_disabled.svg b/app/src/main/assets/ic_device_sony_overhead_disabled.svg
new file mode 100644
index 0000000..3cd7f09
--- /dev/null
+++ b/app/src/main/assets/ic_device_sony_overhead_disabled.svg
@@ -0,0 +1,192 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_sony_overhead_disabled.svg"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16"
+ inkscape:cx="7.34375"
+ inkscape:cy="7.65625"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer6"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#9f9f9f;fill-opacity:0.94117647;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#8a8a8a;fill-opacity:1;stroke:none;stroke-width:3.57115173;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer6"
+ inkscape:label="Foreground"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer7"
+ inkscape:label="Backplane"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer8"
+ inkscape:label="Frontplane"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer9"
+ inkscape:label="Base"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer10"
+ inkscape:label="Top"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer11"
+ inkscape:label="Toptop"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer12"
+ inkscape:label="Toptoptop"
+ style="display:inline" />
+ </g>
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#8a8a8a;fill-opacity:0.94117647"
+ d="m 12.48275,10.865376 h 3.907 v 6.978 h -3.907 z"
+ android:strokeWidth="1"
+ id="path2735" />
+ <path
+ style="display:inline;fill:#fffffe;fill-opacity:1;stroke:#ffffff;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 11.787869,22.120059 c 0,0 -2.0910935,-0.292225 -2.6575995,-1.224839 -0.538651,-0.824579 -0.974144,-1.714355 -1.498378,-2.547398 -0.522792,-0.780196 -0.03424,-1.589492 0.05715,-2.375684 -0.0077,-1.013709 -0.284501,-2.033877 -0.02883,-3.03956 0.355531,-2.063774 1.501107,-3.99713 3.1573765,-5.246232 2.688723,-1.872765 6.803457,-1.2142137 8.663673,1.55373 1.346536,1.782842 2.026474,4.132513 1.553678,6.360387 -0.08676,0.923119 0.697056,1.927541 0.06127,2.790481 -0.596062,0.923479 -1.055471,1.947842 -1.712391,2.823233 -0.755807,0.595235 -2.691274,0.772608 -2.691274,0.772608 0,0 -1.514132,-0.618389 -1.649831,-1.431339 0.04222,-0.63115 0.500806,-1.13731 0.745842,-1.700974 0.841894,-1.549407 1.744777,-3.066705 2.737174,-4.521276 0.490354,-0.443872 1.602437,0.740266 1.564181,-0.407131 0.0762,-1.293714 -0.5099,-2.515494 -1.130701,-3.606183 -0.518744,-0.855915 -1.366456,-1.458301 -2.316503,-1.71515 -2.03309,-0.690172 -4.430773,-0.472791 -6.166155,0.867776 -1.1597865,1.256357 -1.8920875,2.974313 -1.8393115,4.709481 0.300875,0.859132 1.157161,-0.488382 1.6856945,0.352971 0.605742,0.619625 0.904223,1.470703 1.405699,2.171243 0.728953,1.200475 1.437492,2.419372 1.999338,3.711723 -0.01029,0.972211 -1.940049,1.702133 -1.940049,1.702133 z"
+ id="path4466"
+ sodipodi:nodetypes="cccccccccccccccccccccccc" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.028144173"
+ x="0.013794855"
+ id="image1291"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAANW0lE QVR4nO2dS4xc2VnH/98591VVXd3TbntC92ggwZ5R1BWRRZMoIQG1UIQIs0BCcpINESyyQIqySlgl 2I6yQ2xmtpEQilgQI4EUiAQaQQtCFAiNQjLVUWbsjII19iS2u931vI9zzseiqtxP21V9b7vrlr/f pp/33HPr/O53vvO4VYAgCIIgCIIgCIIgCILwLEBnUz6f8mmfZR7ZpKfyop+GQPvKHNX52mmLKjyS K8NGONAEhclUdMMOy2PsSdMcfr08+LK+JTKdNhurQ0GuD3/RGP58hfc1eSESeUUUMuSQPE0CLgPr ILRv0xp2EMd3CHf3DkiS+yJTQYTh0p4QjS1E0TJvYhGorzA2VjGQ6RoAHklEKECiIhuQDsiz3qC1 9jLF8R1KknNkzDYZs0vAi3CuJeKcEkrNM3ALnrfAnneOw3Cbo2iZN+t3GBtNHkSjh5FoagQ6Ik/j LlSSnKM4NirLbit3fo7Y9AhYgrP9vTjq+iJTTkhVHoqgdIWB+yCvyupeh31/xUWR58Jwm5sX4Abd 21ZhEhUo0NUD8nS7UGkKlS1C2QRq3kA5F5KrJ8Q2IaAOdqnIUxCkAgbaIB2yaoesVMItD06HcP4O XBDA1WpwA4lGkegqYwoEok++ygE/3/+CJv6MVrjIwEIB5QpFw9xyUG9ZuL+Jt+6+tnH1vUneKJRX IPrEN/nFiHrfJqBhHJBYwLqcpQqnglZAqAFPAc7xG0miX3n9s9GtPCOzkwpEAPDJVznQy73NxKBx p8PoZjJBWAbmAsJyjeArfqP747sf2rj6V+mhIf6IJzaomvDchIcJM4Of73+hm6Lx0wdO5CkRnZRx 84FDz9AHgpeWPj+Ycrk2zGOv0j5vnhhgJhHo0DzPNXJwn77VcnDiTulwDNxqOZDWn8LaosI6FLBK B2Vi4GHQOJ5xJxKPThKuN+h+D5esyFNaLAPbfXfp0m6owhgcre0MJx8xnDN68sTjBDPRI3lWCeug xl2oTsbVgq5FOCNaKeba7dCL43nX7d7hS17IYQxurjccNpo4JNERxunC6KA8Ww/neTJb4JUIZ0Jm gbSmvbj2rtefr+leb0l3u1CNu1BYb+zr0h52ZwcYMwcadVt78vT7HS29V/lhANVAedX+olfrWC+t 93SaDtr4oETH8ySB9mXkl7HWXqbRDLN97sKkIzhhGmGmNA79xE/8JHDeXFLVab2i03SwFLXWvj1Y FN+LQgcYX4L1LYrjO2TMLmWLUHNJXxd4GcIZ4uvMDzx4WdLxsijQc0lfZ4tQcfy2iuMVOrQF50A0 GiOJHnZf7UVKkpCcWyKbQJkalF/whQhng/XIQwYKgwBpljIZsE2gnFtwgy03Sxh2Y0dC0JgR6DLW sAZjlsier9G8gXLZxJOQwpTiZ/Csb7Q1mY5Mql0V6jkXkT3fIWOWht0YcNzO0oklcLZLzsVkLRSY ZTV9BnAM5RtPs4NyUUDOBeRcTM7ODdt37ZHHji3QKP9h0yPnIpKtGLMDe1BgQy4g5SwUc0rOxYfa 9/Kxx55gS+sSsNAHerUTVFWYRpg9YoDAIOaM2PEgSJgeGZNRHIOAxWOPHTMCjbLwF/edNJMINDNY YjYUPvx5/OBwghyoP9xRKMwWIdgZAioTBQcZSQkD2J4oKIhAQi5EICEXRT5Y+FR56ZzC5z7o46Mv eHi+RvBLditkDvhFl/Hddwy+/oMMb+2UcyN5KQX67Ad8fOXjIXSJU3lfAS/UCZff7+MPXvZx9TsJ /rqZnXW1JqZk9y3wykUPV36z3PIcRivgq78V4nd/tXz3c6kECjTw5Y+Fp/6eNGcBAfjKx8LSdcWl qu5HVjTeU5tFfQYszxE+vFKuXTKlEuilc6Wq7ol4uWTXWKra1vzZjT4j5kp2jaUSSJg+RCAhFyKQ kIvyTTw8ht2kHA8aLYSPznNePqfwexc97MSM/3nXIpnyZ+9mSqBf/8supv1RawJw80/mHvn3Vy55 eOXSoFluPnD4o3/o45329F6UdGFTzMXnFL78G+GT//EMEYGeMnrCV/xDy9M9sSgCPWUmXaqoTPm8 kAgk5EIEespM+mZcjqc3gQZmbBR2HH/+2xF+7fmzuU9++AuHL/1LfOB36YTD8p9sT/dGs5kX6MU6 4aXFsxFop58vemQO+Iv/TAuqzekw8wKVjTe3HW7sOPxfy+Hv3zR4UyKQMAn/eMPgtc3pjjr7kSRa yIUIJORCBBJyIQIJuRCBhFyIQEIuRCAhFyKQkAsRSMiFCCTkQgQSciECCbkQgYRciEBCLkQgIRci kJALEUjIhQgk5EIEEnIhAgm5EIGEXIhAQi5EICEXIpCQCxFIyIUIJORCBBJyIQIJuRCBhFyIQEIu RCAhFyKQkAsRSMjFzL9D2a02Y/GMPhH51hR/REFRzLxAh98lVSgW6cKEXIhAQi5EICEXIpCQCxFI yMVMjcL++49rZ12FZ46ZEuhxn0UqnA7ShQm5EIGEXIhAQi5KJVAnm/21pXZarmsslUA3pvyzs4rg rTNa+D0ppRLoe7ct3u2W6w6dhDsdxn/dnvAzMc+YUgmUWuBr/5FgFhViAF/9ToKsXAGoXAIBwLdv Glz99wS2ZC/047AO+LN/S/BPb5uzrsrElHIi8RtvZPjebYvPfdDHR1/w8Es1gi7ZrWAc8PMu47vv GHz9B1npcp8RpRQIAN7advjTf00AJGddlWeakt23wrQhAgm5EIGEXIhAwgDSJ5odmVggpStMOpzF qZhnnASkPAb6Ex01pkCrQ2FuTVgpoRxoJvJ4NJ4lGn9B7gRd2H1gd7KTCNMNkWGCZpBhIp9JBaxU zORV2fMWOIqWH9nWYwsURcvseQtMXnVQuApEoBmBDBzIskojRypjooCVig617/Vjjz1BDlRjpSLW Gg5EItEMoAgu87Ql1Xc6YadUykpFrHRn2L6bjz52vFNcxyY24XnnWN/rcsuDI5WIPDNC5sPozFql 4foKTvXgHqiY9b059rz7vFlfGbb1lSNtPoZAVxhoMOorHIbbrNQ86xDOj8Ny7TsQHok2bJSX2TRj 4/mB9Tw4HcIptcthuDSUpnFswBi/C9tY5UEe1GJ/B67loZyrf8IRUsMms37mh3MmSGE6YcX6O3BR 9D4XRbcZG6v75Tkg0pMEYmD0qMx1bNbvcK0GFwRwfrVjwdKLzQAchEHmJzBhqkwn7Fn94K4LArgw 3B52X6ME+uhjU2NGoGE3trHKzeg212pwldacBUsQKj3s0Df9LK5VsnYltkG7aiuVOVurwTUvwGGj OWj7Y/IfYDyBhlHoCgNbjM1PuOYFuFoNDnHaK/ZqhKcNx0k76FoTtPu21oWpVu/vk2d1nzwE4Ohm 0AmG8fsk2mhy8wKc6/duzOT+0mcFZnC//9N6/ZfN4iJMrQZ3YyHZF3m29stzLONuKGMANCiIGbgG bDTBH8+uc2t3jebnAZLHiksFM7jdBiW9v72x8IZDfWWYLDcBrDDQwCF5jg0Vk+xIPCKR+8ndV+n9 5/8QzjWoXge80m5wfLYwGbjdAZLkR+n/3nwNmztuIM0W9sQBniTPgf+YEBqV+ytf/OF7qVL5Fitq kO8D2gdkgno6YQJsBs4yENOP2HV//2df+7ufDf54RBrgMeKMyNvvEMB4z+98o1r5yIc/D8efBqmL rNR8znKFU4Act8C4odhe737/+6/9/J+/FQ+S5KsjUSa+8wsRCLhGAFSjAXXvXsdPa+e9auK81E/9 wKt4Jkt9Z632fa3ZQTFbAhQhAJidJE85IVKMFAAcE2kmBZdl1iqtrfbIZNZkfgoTV5FW491sYWHB DhJluMeNsMYhb9LCg+z5KoBVjqLXuVJZsejeQ78+xxUvQZakHEaBsybTzkJzpCmwRgEAsyfyFAE7 UGAYAFKtHSWW/TCz2oNN0tQGEZtQOWPb1nqV4faMjcVhznNyeYD8EWhYBgP4lMJ6gxp3oXZ353Wy 0NK1LrwsCrTJUh2ZVNuQlLNQQAVgQ8yZCFQQRP5AAspYaTidsIu9wHp+YP04td0aTLg7bxcWWnZv mP5Nl1egAodNDcbGKpprO3wpSZzbAXXqPTsXA2TAWTVw1kKx84k5I+aU5A3piqOLFEQBE1WZkLEL 4fwenGdT1wl7Ntipuqjqub3oU8wMXhEC7evGrgP1Bocx2Bg4tKvoLoJtAjWfQjkXk3MgnksIAAzq BZxeAIAQbQAJqANWKmZlI25Fg1X1YKe6b20LDLyOwXA9X/QBiunChuWMkukmjbqyJDlHcfy2cm6B 7PkaOdslNj1ytipd1ymhdG+wa1TXWN/rslK7HEXvc2G4zXvLEwdmmHMJVGAXdnCWurnecGvtkMJw gZPkPpkWyJjdoTjzcK4lEhWMUvMM7ALYheeBvep9DsMljqJwEHk2msOu6/HLE5NQlEDHLnVsYpWx fofQTmgNy4jjUa1bBZ1WOEgLwAKAwR72TdwG6hjmPK/j0MIoUEAeVHQUeDhDPZwbGtIk4PLw+y3C esFnFfbYAPYew7qOgzsJi5UHKF6gQ+WO6nhNuqszY/x1rZNw2g0r4kwPskApCIIgCIJQEP8P03/B 430mofMAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.670942"
+ width="28.670942" />
+ <image
+ y="0.0055872202"
+ x="-0.031073861"
+ id="image3082"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAAL6klE QVR4nO3dTYvcRhrA8adKJZW6Zc0LmTEmmYUF5zYOJjQLOSTQEMhlA7uHsNlPsCHg5DP4MzghHyC3 xZvTGh8CgYaQXMwEkngOZo1f2JAYz2ReLHePSqqqZy+t2Z5xj6c1pbZa7ecHxo09LdVEf0rVkjIG IIQQQgghhBBCCCEvA9bw7ZPJ4TQ2Oo0DPG6bFFJ9xoVTWUxVH1g25vXxfTAAgG63W/GuSa/XK14e DwTH/HklEVUZ0PF4ilBYkiQMACBN0yP7U0rRzFQRKeWRIMIwRACAOI6x1+uNBlRpRNMIiAEAK8JJ 05QppZjWmmmtGQCAtZbCmRLOOQIACCFQCIFSSgzDEEdCGo1oZgJ6Jp6trS2ulGJpmvI8z/nKysph QMaYw/1STO6KaAAAPM87DGh7ext937dhGFopJa6urtqqI6oyoCPx9Pt9nmUZX15e5koprrXm1lpm rWWIyOI4pngqxDnHJEmAMYacc+ScoxDCSint7u6uDYLARlFkx0RUe0Ds2rVrQRRFn3qe93fO+UVE XKxgu6R6TxDxP4j4z4cPH3529epVNfzzM0fkGhD78ssv/2CtvckYW7fWgtYaEKdyyYE4YoyBEAI4 54CIt5VSf/7oo4/+O/IlpQ/cWQNiAADXrl0LFhYWNowx60mSQJZlZ9wceZGCIIA4jsHzvNsPHjz4 09WrVzM44/UiXnLfhx/PAQCiKPo0z/P1nZ0diqdBsiyDnZ0dyPP80tra2hUYWcPC+Gt5JyoT0DPX eRhjH+7t7dEpq4EQEfb29oAx9rdOp8O73S6H8SEdj+oIMeH+xl0kZP1+/3WKp7kQEQ4ODl7f39/n aZpip9NBAICNjY1xH/MZjDmlTRpQ4chH9SzL2mcdPJkNSqlzSZKINE1tv99HIQSur68X14yKLztx lpjkFDb2ImG/3+fGGLfRk9oZYyCKIhFFkVhYWPAGg4HX7/f51tYW73a7405nR0y6BnomnoODA6+i 74HUTCklDg4OxNOnT0Ucx16WZXxMRGOddgo78sYkSVhxhXlpaansJzgygxCR+b7ve55nfd83aZqa OI4hSRIQQuDwRjjCCWugiSPodrssTVOmtWbD2xM0A80Jz/P8LMuEUkqEYegppbzl5WWepilP05QN Z6HCkUllkkU0AwCWJAlTSjFrLSvubVX6XZDaaK2F7/ssz3PgnKPWGpVS3FprRx65cZuBhjtiw7vq vN1uU0BzwvM8YYzxgiDwtNZeu93m1lpmjGFa68PnucBhEX3IGMOGG+eISHfT5wAiciGEZ609fGKi +HXaeycOqFj/aK0n3jhpBmstR0RmreVSyuI1O3/+/KnvpdMQgeGZhCEiK+Kx1h5OGMcfRR5V5jrQ uJ2SORAEAUNEFoZh6feeaQ1E8cynYllS5vjSKYwAwNnPKBQQcUIBESdlH+eYGa+++ip0Oh1YW1uD KIqAsWYty4wxkCQJ3L9/H27dugX7+/t1D+lMGhnQO++8A2+99Vbdw3DieR4sLS3Bm2++CW+88Qbc vHkT7ty5U/ewSmvcKazT6TQ+nuOEEPD+++/D2tpa3UMprVEBtVotePvtt+sexlRwzuHdd9+texil NSqgixcvQhAEdQ9jas6fPw8rKyt1D6OURgXUtP+4Z/HKK6/UPYRSGhWQ7/t1D2HqpJR1D6GURgVE Zg8FRJxQQMRJIy8kjoOIoJQ6/Qtrxhh77jrnwoULoJSCfr8Pv/76K1hrX+DoypubgAaDAXzxxRd1 D+NU586dg48//vjEv798+TJcvnwZAAAePXoE169fhzRNX9TwSqNT2Ay7cOHCzF84pYBesHa73I8T mPXbGxTQC1b2WtasX/uigIgTCugFK/upatZ//tLcfAo7yQcffAALCwu17PvevXuj//wAAAA8ffq0 1Da2trYqHFH15j6gpaUlWF5ermXf4w5+mRklyzL4/vvvqxxS5eY+oKZ59OgR7O3twc7ODty+fXvm H3WlgGbMjz/+CD/99FPdw5gYLaKJEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgT Cog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMK iDihgIgTCog4oYCIEwqIOKGAiJO5/wllu7u7YIypZd9JktSy3xdp7gP66quv6h7CXKNTGHFCAREn FBBxQgERJxQQcTI3n8La7TZ88skndQ/jVIyxuodQqbkJiDEGYRjWPYyXDp3CiBMKiDihgIiTRgWU ZVndQ5g6pVTdQyilUQH9/vvvdQ9h6pr2PTYqoLt37871LPT48WPY3t6uexilNCqgNE3h22+/rXsY U2GthW+++abuYZTWqIAAAH744YeZ/3dEy8rzHG7cuAG//PJL3UMprZEXEr/77ju4d+8edDodWFtb g3PnzjXuCq8xBp48eQL379+HW7duwZMnT+oe0pk0MiAAgN9++w1u3LhR9zBeeo07hZHZQgERJxQQ cUIBEQAAYIzhWd5XOiDP8/CsOyOzjXNe+rhOGhAFM8eyLEPGGKZpCgDlZqMzn8JoFpofw2OJjDFk jCHnHDnnKIRAIQSGYXjisZ44oDAMDzdY7KCS0ZPacc7tMBybpulhRI8fPz79vWV35nkecs7R8zxL s9B8YIxZrbXhnFvP82wxQUwySZQKSAiB29vbKISw/X6f4pkTxhjteZ4ZxmMHg4EdThIohMA4jotj /cwxnyQgBACM4xillMg5Ryml9X2/np9YQConhNBZlpk8z7Xv+0YIYaWUdnisT4wHoMQM1Ov1sFgH 7e7uWiGErWT0pHZaay2lzKWUOssyLaU0u7u7NgxDG4Yh9nq90XiOhHTazVQEgMPb3HEcY5qmFgAg SRKDiI27C06egVLKfDAYWEQ0Ukq7t7dnW62WlVKOnr7GKnMdCItZKIoi22q1DCItg5oOEUFrnUdR lLdaLZMkiWm1WiaKIru6umqHs0/x6xmTroGK33FjY8Ourq7aKIpslmWDir4PUhOlVNLv93WSJKbf 7+t2u/28eJ6JqOzzQAgA0Ov1oNvt2sFgcFdK2aHTWDMhIgwGg3txHOtisRyGIW5sbIyGU9kpbPQ1 9no91FpfT5IE6FTWPIgISZKAMeZfi4uLdnV11W5ubpqNjQ0LABaejWfsQfZK7vfIVPPaa69tRFH0 V2PMed/3gXO6ud8EWmtIkgSyLPv5zp07/+j1evrBgwfPi+XEGeKs557D9125cuWPUsp/c87XhRAg RGOfkn0paK1Baw3W2p+VUn/5/PPPHw7/alwkp55aXBcvDADgvffea1+6dOkKAHzIGLvIOV9w3C6Z AmvtE0S8i4jXNzc3P/v6669TOGWRfJpKAhr+ztfX1/n29rYfRZFQSgnf9/08z4UQwjfGeL7ve9Za joiseO/wNXEwck+yuBFq8zw3nueZPM91EAR5lmW63W5naZrmi4uLZnNzc3StA3CGeADc/6+M0QuN GIYhtlot0+/3IY5jPDg4gCAIUGttpZSeMcYDABYEAQegeKpURJTnuUVElFKaLMvM8HcdhqEeXuMp Hs84dYE8iaoWLAgArLhSrbVmUkqmtTZSSuCco9YajTFWSskRERCRUUDVKQISQqBSyiKiDYLA+L5v ENG0Wi2TZZmNosg+7+Zo6f26bgCOnsZYp9Ph+/v7fDAYeHEce0opT2vN2+02N8Zway2jeKZj9IEw z/PsYDCwwxujJkkS0263zeLioh1+VHda+xSqmIHG3S9DrbVNkgSWl5dRKcWzLOPWWlYENPzaCnZP AP7/zyoUARljMAxDK6W0u7u7NgiCk+5tOc1CVc0CR2ahbrfLtra2uFKKpWnKrbXMGMNWVlaY1poZ Y2j2mZLiGZ7t7W0sHv4bhoST3p4oo+qAites2+2yJElYmqZMKcW01kxrffh11lqKqGKjTxAWjx9L KTEMQ4zjGE+4MToTAR3f1uHH9CIkAIA0TSmaF6R4EH4kHICK4wGoNqDj25vkNZmOkyKpNB6A6R3M 49ulaOpT6aL5uGkfWApndtAjE4QQQgghFfkfLnfEbGu/P8gAAAAASUVORK5CYII= "
+ style="display:inline;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.697369"
+ width="28.697369" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_supercars.svg b/app/src/main/assets/ic_device_supercars.svg
new file mode 100644
index 0000000..596f4e1
--- /dev/null
+++ b/app/src/main/assets/ic_device_supercars.svg
@@ -0,0 +1,134 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_supercars.svg"
+ inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="11.313709"
+ inkscape:cx="-18.826717"
+ inkscape:cy="11.357902"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1"
+ inkscape:pagecheckerboard="0"
+ inkscape:showpageshadow="2"
+ inkscape:deskcolor="#d1d1d1">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#1f7fdb;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#4dabf5;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ <g
+ id="g8"
+ transform="matrix(0.75,0,0,0.75,5.3712747,5.3700628)"
+ style="fill:#ffffff;fill-opacity:1;stroke-width:1.33333">
+ <path
+ d="M 21.96,11.22 C 21.57,7.01 17.76,4 13.56,4 13.37,4 13.18,4.01 12.99,4.02 2,4.74 2,17.2 2,17.2 V 18 c 0,1.1 0.9,2 2,2 h 10 c 4.67,0 8.41,-4.01 7.96,-8.78 z M 5.26,11.56 C 5.83,10.27 6.54,9.21 7.4,8.37 l 3.62,1.53 c 0.6,0.25 0.98,0.83 0.98,1.48 0,0.89 -0.72,1.61 -1.61,1.61 H 4.72 c 0.15,-0.46 0.32,-0.94 0.54,-1.43 z m 13.18,4.48 C 17.3,17.29 15.68,18 14,18 H 4 V 17.2 C 4,17.18 4.01,16.28 4.24,15 h 6.15 C 12.38,15 14,13.38 14,11.39 14,9.94 13.13,8.63 11.8,8.07 L 9.3,7.01 C 10.4,6.44 11.67,6.11 13.12,6.02 13.27,6 13.42,6 13.56,6 c 3.31,0 6.13,2.37 6.41,5.41 0.16,1.72 -0.38,3.36 -1.53,4.63 z"
+ id="path6"
+ style="fill:#ffffff;fill-opacity:1;stroke-width:1.33333" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:inline" />
+</svg>
diff --git a/app/src/main/assets/ic_device_supercars_disabled.svg b/app/src/main/assets/ic_device_supercars_disabled.svg
new file mode 100644
index 0000000..234fc3f
--- /dev/null
+++ b/app/src/main/assets/ic_device_supercars_disabled.svg
@@ -0,0 +1,179 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_supercars_disabled.svg"
+ inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16"
+ inkscape:cx="0.125"
+ inkscape:cy="7.65625"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer6"
+ inkscape:pagecheckerboard="0"
+ inkscape:showpageshadow="2"
+ inkscape:deskcolor="#d1d1d1">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#9f9f9f;fill-opacity:0.941176;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#8a8a8a;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer6"
+ inkscape:label="Foreground"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer7"
+ inkscape:label="Backplane"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer8"
+ inkscape:label="Frontplane"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer9"
+ inkscape:label="Base"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer10"
+ inkscape:label="Top"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer11"
+ inkscape:label="Toptop"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer12"
+ inkscape:label="Toptoptop"
+ style="display:inline" />
+ </g>
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#8a8a8a;fill-opacity:0.94117647"
+ d="m 12.48275,10.865376 h 3.907 v 6.978 h -3.907 z"
+ android:strokeWidth="1"
+ id="path2735" />
+ <g
+ id="g8"
+ transform="matrix(0.75,0,0,0.75,5.3712747,5.3700628)"
+ style="display:inline;fill:#ffffff;fill-opacity:1;stroke-width:1.33333">
+ <path
+ d="M 21.96,11.22 C 21.57,7.01 17.76,4 13.56,4 13.37,4 13.18,4.01 12.99,4.02 2,4.74 2,17.2 2,17.2 V 18 c 0,1.1 0.9,2 2,2 h 10 c 4.67,0 8.41,-4.01 7.96,-8.78 z M 5.26,11.56 C 5.83,10.27 6.54,9.21 7.4,8.37 l 3.62,1.53 c 0.6,0.25 0.98,0.83 0.98,1.48 0,0.89 -0.72,1.61 -1.61,1.61 H 4.72 c 0.15,-0.46 0.32,-0.94 0.54,-1.43 z m 13.18,4.48 C 17.3,17.29 15.68,18 14,18 H 4 V 17.2 C 4,17.18 4.01,16.28 4.24,15 h 6.15 C 12.38,15 14,13.38 14,11.39 14,9.94 13.13,8.63 11.8,8.07 L 9.3,7.01 C 10.4,6.44 11.67,6.11 13.12,6.02 13.27,6 13.42,6 13.56,6 c 3.31,0 6.13,2.37 6.41,5.41 0.16,1.72 -0.38,3.36 -1.53,4.63 z"
+ id="path6"
+ style="fill:#ffffff;fill-opacity:1;stroke-width:1.33333" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:inline" />
+</svg>
diff --git a/app/src/main/assets/ic_device_unknown.svg b/app/src/main/assets/ic_device_unknown.svg
new file mode 100644
index 0000000..303eabe
--- /dev/null
+++ b/app/src/main/assets/ic_device_unknown.svg
@@ -0,0 +1,144 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_unknown.svg"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="11.313709"
+ inkscape:cx="-17.89864"
+ inkscape:cy="11.269514"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer2"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#1f7fdb;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#4dabf5;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ <g
+ aria-label="?"
+ id="text2312"
+ style="font-style:italic;font-size:24px;line-height:124%;font-family:Sans;-inkscape-font-specification:'Sans Italic';letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px">
+ <path
+ d="m 15.634456,17.365391 h -4.230469 v -0.574219 q 0,-0.960937 0.386719,-1.699219 0.386718,-0.75 1.628906,-1.898437 l 0.75,-0.679688 q 0.667969,-0.609375 0.972656,-1.148437 0.316406,-0.539063 0.316406,-1.078125 0,-0.8203127 -0.5625,-1.277344 -0.5625,-0.46875 -1.570312,-0.46875 -0.949219,0 -2.050781,0.3984375 -1.101563,0.3867188 -2.2968753,1.1601565 V 6.4200783 q 1.4179683,-0.4921875 2.5898433,-0.7265625 1.171875,-0.234375 2.261719,-0.234375 2.859375,0 4.359375,1.171875 1.5,1.1601562 1.5,3.3984372 0,1.148438 -0.457031,2.0625 -0.457031,0.902344 -1.558594,1.945313 l -0.75,0.667969 q -0.796875,0.726562 -1.042969,1.171875 -0.246093,0.433593 -0.246093,0.960937 z m -4.230469,1.734375 h 4.230469 v 4.171875 h -4.230469 z"
+ style="font-style:normal;font-weight:bold;-inkscape-font-specification:'Sans Bold';fill:#ffffff;fill-opacity:1;stroke:none"
+ id="path14366" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.0079936879"
+ x="0.010951031"
+ id="image4124"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAAAA3NCSVQICAjb4U/gAAAACXBIWXMA ADFOAAAxTgGacD5yAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAABF0RVh0 VGl0bGUAUGViYmxlIEljb26KvwD9AAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAAY3RFWHRDb3B5 cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0dHA6Ly9jcmVh dGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzMuMC/eBBrlAAABy1BMVEUAAAAAAAAA AP8AAIAAAFUAAKoAVaoAAIAAQIAAM2YAM5kzM2YzM5krK4AkJG0kJJIgIIAcHHEcHI4aGoAXF3QX F4sXLnQXLosVK4AUJ3YUJ4kSJIAkJIARInciInciIogQIIAgIIAeHngeHoccHIAcK4AbKHkbKIYa GoAaJoAYJHkXI4AXI4sWIXoWIYUVIIAfH3ofH4UdJ4ATHIQcJnsbJIAaI4QaIoAZIXsZIYQYIHgY IIAXH4MXJ3wXHoAXJoBLpfAWJHwdJHwdJIMcI4Acas0VIoMbIoAaIXwaIIAZH3wZH4MZJXwYHoAY HoYYJIAehtsYHoIefdtHoO0jetZGovNEn+4gg+EfgN5Ep/VKqfREpvQgfdkgfttKqvQggd4hjesw nPMwnPRJqfUhgd4hjutJqfUgfttNq/VNq/QhlPEhlPIhlvMkmPMglvIff9sgjeoglPAhk/AhlPEh lPIhlvMkl/MkmPMlmPMmmPMnmfMpmvMzn/Q0n/Q1n/Q2oPRIqfVNq/VasfZbsvZhtPZitfdtuveQ yvmXzvmj0/qk1Pqu2fu83/vR6f3T6v3Z7f3i8f3j8v3l8/7m8/7p9f7q9f7x+P7y+f7////ougeB AAAAb3RSTlMAAQECAwMDBAQFBQUFBgcHCAkJCgsLCwsMDQ0ODg8PDxAQERESEhMTFBQVFhYXFxgZ GRobGxwdHh8fICAhISIiIiMjIyQkJSYnKCkpKSoqKiorKyssLC2xs7O0tba2tu7u7u7u7+/w8fHy +/v7+/ywJhX9AAAC0klEQVRo3u2a+VfTQBCAm00iW0nSTVNKbKQYbIMUC6jU4q2IB2rF+74vPEAR LSjIIXgAAiqef65py6MNJe17y1RF8/26L/N1N9O8ZGZcLof/DiYFoiV1cZHwiGVZjh7zalRAYYbn sFsQ6RFwGccyBeJjwSMrPn8lHX6fQqRy3tbAsFiU1So9FA4bNIRD63XNTwTO5pSYc9fuvwbgwaW9 SxqYs/eGBgcAGBy+fRrlpRPDsJdfJoEYuMgvzibz/vK3kmDcFMuxNZvM/BG74QTdFV6PJZsYlhe9 XXCCrlCVStwcyhG45QCkoCmiV0o4uwXEib4aQMHjbc0RTXYvCMx/mGdNHaSgraWxRhEXzsgUEC0C KWjfszlUYRWsrYcUHGttNlSRzxUEo5CCjrZYrSqVVlD3Nwj6vqcZsovzKrPeTy3o/5lmxE4wnFl/ 4QhKLvjy2YY5IEExHMH0B1umQQSj9o+EUUewIBh/m8c4qOBbfu58dQRLCmYnJmZLKphKJicdwR8X vJsymSmhIM37FSv4OJlhxnlUOAJHAC8Ye5PHmPNe9PsEJX/5dT5AigvmPtkw53wnr3xB34809uWc zDp9OWflV7xgBaBlzY79MWORALhuui+vbhoArfwe3rUp5MtWfl2Il1QDUtAaj+rKakv1XdEhBfGm Wo1kq+/pM1Ih+wdRI6iIq3IFvEAewgke6QHFg1mU26PjhbtwghsKEbG1X8ew3PmnUPGfXRAwv7hr yqAD13t6nwPQ23PnBMeivG4gg85c7XwCQOeVU+avZ5bslcrrNm7dfehIInGSguOJo+0Hd27ZoOam j3ULvOjTI02x+PYddLTEY42GJpfnpo/L0g50k8pqI1IfbWikoSFaXxfSFKnMrqFsZpKbKKoWDFbT EdQCqlfCdhtIG7AoESLTQogk4EJd/VRTn8fLgecKTg3MjyUsA4SKDD7MT1bQ8++NmvwCRmydh2Fg UvEAAAAASUVORK5CYII= "
+ style="opacity:0.490741;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.710279"
+ width="28.710279" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_unknown_disabled.svg b/app/src/main/assets/ic_device_unknown_disabled.svg
new file mode 100644
index 0000000..8c15510
--- /dev/null
+++ b/app/src/main/assets/ic_device_unknown_disabled.svg
@@ -0,0 +1,197 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_unknown_disabled.svg"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16"
+ inkscape:cx="7.34375"
+ inkscape:cy="7.65625"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer6"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#9f9f9f;fill-opacity:0.941176;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="fill:#8a8a8a;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer6"
+ inkscape:label="Foreground"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer7"
+ inkscape:label="Backplane"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer8"
+ inkscape:label="Frontplane"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer9"
+ inkscape:label="Base"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer10"
+ inkscape:label="Top"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer11"
+ inkscape:label="Toptop"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer12"
+ inkscape:label="Toptoptop"
+ style="display:inline" />
+ </g>
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#8a8a8a;fill-opacity:0.94117647"
+ d="m 12.48275,10.865376 h 3.907 v 6.978 h -3.907 z"
+ android:strokeWidth="1"
+ id="path2735" />
+ <g
+ aria-label="?"
+ id="text2312"
+ style="font-style:italic;font-size:24px;line-height:124%;font-family:Sans;-inkscape-font-specification:'Sans Italic';letter-spacing:0px;word-spacing:0px;display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px">
+ <path
+ d="m 15.634456,17.365391 h -4.230469 v -0.574219 q 0,-0.960937 0.386719,-1.699219 0.386718,-0.75 1.628906,-1.898437 l 0.75,-0.679688 q 0.667969,-0.609375 0.972656,-1.148437 0.316406,-0.539063 0.316406,-1.078125 0,-0.8203127 -0.5625,-1.277344 -0.5625,-0.46875 -1.570312,-0.46875 -0.949219,0 -2.050781,0.3984375 -1.101563,0.3867188 -2.2968753,1.1601565 V 6.4200783 q 1.4179683,-0.4921875 2.5898433,-0.7265625 1.171875,-0.234375 2.261719,-0.234375 2.859375,0 4.359375,1.171875 1.5,1.1601562 1.5,3.3984372 0,1.148438 -0.457031,2.0625 -0.457031,0.902344 -1.558594,1.945313 l -0.75,0.667969 q -0.796875,0.726562 -1.042969,1.171875 -0.246093,0.433593 -0.246093,0.960937 z m -4.230469,1.734375 h 4.230469 v 4.171875 h -4.230469 z"
+ style="font-style:normal;font-weight:bold;-inkscape-font-specification:'Sans Bold';fill:#ffffff;fill-opacity:1;stroke:none"
+ id="path14366" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.028144173"
+ x="0.013794855"
+ id="image1291"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAANW0lE QVR4nO2dS4xc2VnH/98591VVXd3TbntC92ggwZ5R1BWRRZMoIQG1UIQIs0BCcpINESyyQIqySlgl 2I6yQ2xmtpEQilgQI4EUiAQaQQtCFAiNQjLVUWbsjII19iS2u931vI9zzseiqtxP21V9b7vrlr/f pp/33HPr/O53vvO4VYAgCIIgCIIgCIIgCILwLEBnUz6f8mmfZR7ZpKfyop+GQPvKHNX52mmLKjyS K8NGONAEhclUdMMOy2PsSdMcfr08+LK+JTKdNhurQ0GuD3/RGP58hfc1eSESeUUUMuSQPE0CLgPr ILRv0xp2EMd3CHf3DkiS+yJTQYTh0p4QjS1E0TJvYhGorzA2VjGQ6RoAHklEKECiIhuQDsiz3qC1 9jLF8R1KknNkzDYZs0vAi3CuJeKcEkrNM3ALnrfAnneOw3Cbo2iZN+t3GBtNHkSjh5FoagQ6Ik/j LlSSnKM4NirLbit3fo7Y9AhYgrP9vTjq+iJTTkhVHoqgdIWB+yCvyupeh31/xUWR58Jwm5sX4Abd 21ZhEhUo0NUD8nS7UGkKlS1C2QRq3kA5F5KrJ8Q2IaAOdqnIUxCkAgbaIB2yaoesVMItD06HcP4O XBDA1WpwA4lGkegqYwoEok++ygE/3/+CJv6MVrjIwEIB5QpFw9xyUG9ZuL+Jt+6+tnH1vUneKJRX IPrEN/nFiHrfJqBhHJBYwLqcpQqnglZAqAFPAc7xG0miX3n9s9GtPCOzkwpEAPDJVznQy73NxKBx p8PoZjJBWAbmAsJyjeArfqP747sf2rj6V+mhIf6IJzaomvDchIcJM4Of73+hm6Lx0wdO5CkRnZRx 84FDz9AHgpeWPj+Ycrk2zGOv0j5vnhhgJhHo0DzPNXJwn77VcnDiTulwDNxqOZDWn8LaosI6FLBK B2Vi4GHQOJ5xJxKPThKuN+h+D5esyFNaLAPbfXfp0m6owhgcre0MJx8xnDN68sTjBDPRI3lWCeug xl2oTsbVgq5FOCNaKeba7dCL43nX7d7hS17IYQxurjccNpo4JNERxunC6KA8Ww/neTJb4JUIZ0Jm gbSmvbj2rtefr+leb0l3u1CNu1BYb+zr0h52ZwcYMwcadVt78vT7HS29V/lhANVAedX+olfrWC+t 93SaDtr4oETH8ySB9mXkl7HWXqbRDLN97sKkIzhhGmGmNA79xE/8JHDeXFLVab2i03SwFLXWvj1Y FN+LQgcYX4L1LYrjO2TMLmWLUHNJXxd4GcIZ4uvMDzx4WdLxsijQc0lfZ4tQcfy2iuMVOrQF50A0 GiOJHnZf7UVKkpCcWyKbQJkalF/whQhng/XIQwYKgwBpljIZsE2gnFtwgy03Sxh2Y0dC0JgR6DLW sAZjlsier9G8gXLZxJOQwpTiZ/Csb7Q1mY5Mql0V6jkXkT3fIWOWht0YcNzO0oklcLZLzsVkLRSY ZTV9BnAM5RtPs4NyUUDOBeRcTM7ODdt37ZHHji3QKP9h0yPnIpKtGLMDe1BgQy4g5SwUc0rOxYfa 9/Kxx55gS+sSsNAHerUTVFWYRpg9YoDAIOaM2PEgSJgeGZNRHIOAxWOPHTMCjbLwF/edNJMINDNY YjYUPvx5/OBwghyoP9xRKMwWIdgZAioTBQcZSQkD2J4oKIhAQi5EICEXRT5Y+FR56ZzC5z7o46Mv eHi+RvBLditkDvhFl/Hddwy+/oMMb+2UcyN5KQX67Ad8fOXjIXSJU3lfAS/UCZff7+MPXvZx9TsJ /rqZnXW1JqZk9y3wykUPV36z3PIcRivgq78V4nd/tXz3c6kECjTw5Y+Fp/6eNGcBAfjKx8LSdcWl qu5HVjTeU5tFfQYszxE+vFKuXTKlEuilc6Wq7ol4uWTXWKra1vzZjT4j5kp2jaUSSJg+RCAhFyKQ kIvyTTw8ht2kHA8aLYSPznNePqfwexc97MSM/3nXIpnyZ+9mSqBf/8supv1RawJw80/mHvn3Vy55 eOXSoFluPnD4o3/o45329F6UdGFTzMXnFL78G+GT//EMEYGeMnrCV/xDy9M9sSgCPWUmXaqoTPm8 kAgk5EIEespM+mZcjqc3gQZmbBR2HH/+2xF+7fmzuU9++AuHL/1LfOB36YTD8p9sT/dGs5kX6MU6 4aXFsxFop58vemQO+Iv/TAuqzekw8wKVjTe3HW7sOPxfy+Hv3zR4UyKQMAn/eMPgtc3pjjr7kSRa yIUIJORCBBJyIQIJuRCBhFyIQEIuRCAhFyKQkAsRSMiFCCTkQgQSciECCbkQgYRciEBCLkQgIRci kJALEUjIhQgk5EIEEnIhAgm5EIGEXIhAQi5EICEXIpCQCxFIyIUIJORCBBJyIQIJuRCBhFyIQEIu RCAhFyKQkAsRSMjFzL9D2a02Y/GMPhH51hR/REFRzLxAh98lVSgW6cKEXIhAQi5EICEXIpCQCxFI yMVMjcL++49rZ12FZ46ZEuhxn0UqnA7ShQm5EIGEXIhAQi5KJVAnm/21pXZarmsslUA3pvyzs4rg rTNa+D0ppRLoe7ct3u2W6w6dhDsdxn/dnvAzMc+YUgmUWuBr/5FgFhViAF/9ToKsXAGoXAIBwLdv Glz99wS2ZC/047AO+LN/S/BPb5uzrsrElHIi8RtvZPjebYvPfdDHR1/w8Es1gi7ZrWAc8PMu47vv GHz9B1npcp8RpRQIAN7advjTf00AJGddlWeakt23wrQhAgm5EIGEXIhAwgDSJ5odmVggpStMOpzF qZhnnASkPAb6Ex01pkCrQ2FuTVgpoRxoJvJ4NJ4lGn9B7gRd2H1gd7KTCNMNkWGCZpBhIp9JBaxU zORV2fMWOIqWH9nWYwsURcvseQtMXnVQuApEoBmBDBzIskojRypjooCVig617/Vjjz1BDlRjpSLW Gg5EItEMoAgu87Ql1Xc6YadUykpFrHRn2L6bjz52vFNcxyY24XnnWN/rcsuDI5WIPDNC5sPozFql 4foKTvXgHqiY9b059rz7vFlfGbb1lSNtPoZAVxhoMOorHIbbrNQ86xDOj8Ny7TsQHok2bJSX2TRj 4/mB9Tw4HcIptcthuDSUpnFswBi/C9tY5UEe1GJ/B67loZyrf8IRUsMms37mh3MmSGE6YcX6O3BR 9D4XRbcZG6v75Tkg0pMEYmD0qMx1bNbvcK0GFwRwfrVjwdKLzQAchEHmJzBhqkwn7Fn94K4LArgw 3B52X6ME+uhjU2NGoGE3trHKzeg212pwldacBUsQKj3s0Df9LK5VsnYltkG7aiuVOVurwTUvwGGj OWj7Y/IfYDyBhlHoCgNbjM1PuOYFuFoNDnHaK/ZqhKcNx0k76FoTtPu21oWpVu/vk2d1nzwE4Ohm 0AmG8fsk2mhy8wKc6/duzOT+0mcFZnC//9N6/ZfN4iJMrQZ3YyHZF3m29stzLONuKGMANCiIGbgG bDTBH8+uc2t3jebnAZLHiksFM7jdBiW9v72x8IZDfWWYLDcBrDDQwCF5jg0Vk+xIPCKR+8ndV+n9 5/8QzjWoXge80m5wfLYwGbjdAZLkR+n/3nwNmztuIM0W9sQBniTPgf+YEBqV+ytf/OF7qVL5Fitq kO8D2gdkgno6YQJsBs4yENOP2HV//2df+7ufDf54RBrgMeKMyNvvEMB4z+98o1r5yIc/D8efBqmL rNR8znKFU4Act8C4odhe737/+6/9/J+/FQ+S5KsjUSa+8wsRCLhGAFSjAXXvXsdPa+e9auK81E/9 wKt4Jkt9Z632fa3ZQTFbAhQhAJidJE85IVKMFAAcE2kmBZdl1iqtrfbIZNZkfgoTV5FW491sYWHB DhJluMeNsMYhb9LCg+z5KoBVjqLXuVJZsejeQ78+xxUvQZakHEaBsybTzkJzpCmwRgEAsyfyFAE7 UGAYAFKtHSWW/TCz2oNN0tQGEZtQOWPb1nqV4faMjcVhznNyeYD8EWhYBgP4lMJ6gxp3oXZ353Wy 0NK1LrwsCrTJUh2ZVNuQlLNQQAVgQ8yZCFQQRP5AAspYaTidsIu9wHp+YP04td0aTLg7bxcWWnZv mP5Nl1egAodNDcbGKpprO3wpSZzbAXXqPTsXA2TAWTVw1kKx84k5I+aU5A3piqOLFEQBE1WZkLEL 4fwenGdT1wl7Ntipuqjqub3oU8wMXhEC7evGrgP1Bocx2Bg4tKvoLoJtAjWfQjkXk3MgnksIAAzq BZxeAIAQbQAJqANWKmZlI25Fg1X1YKe6b20LDLyOwXA9X/QBiunChuWMkukmjbqyJDlHcfy2cm6B 7PkaOdslNj1ytipd1ymhdG+wa1TXWN/rslK7HEXvc2G4zXvLEwdmmHMJVGAXdnCWurnecGvtkMJw gZPkPpkWyJjdoTjzcK4lEhWMUvMM7ALYheeBvep9DsMljqJwEHk2msOu6/HLE5NQlEDHLnVsYpWx fofQTmgNy4jjUa1bBZ1WOEgLwAKAwR72TdwG6hjmPK/j0MIoUEAeVHQUeDhDPZwbGtIk4PLw+y3C esFnFfbYAPYew7qOgzsJi5UHKF6gQ+WO6nhNuqszY/x1rZNw2g0r4kwPskApCIIgCIJQEP8P03/B 430mofMAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.670942"
+ width="28.670942" />
+ <image
+ y="0.0055872202"
+ x="-0.031073861"
+ id="image3082"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAAL6klE QVR4nO3dTYvcRhrA8adKJZW6Zc0LmTEmmYUF5zYOJjQLOSTQEMhlA7uHsNlPsCHg5DP4MzghHyC3 xZvTGh8CgYaQXMwEkngOZo1f2JAYz2ReLHePSqqqZy+t2Z5xj6c1pbZa7ecHxo09LdVEf0rVkjIG IIQQQgghhBBCCCEvA9bw7ZPJ4TQ2Oo0DPG6bFFJ9xoVTWUxVH1g25vXxfTAAgG63W/GuSa/XK14e DwTH/HklEVUZ0PF4ilBYkiQMACBN0yP7U0rRzFQRKeWRIMIwRACAOI6x1+uNBlRpRNMIiAEAK8JJ 05QppZjWmmmtGQCAtZbCmRLOOQIACCFQCIFSSgzDEEdCGo1oZgJ6Jp6trS2ulGJpmvI8z/nKysph QMaYw/1STO6KaAAAPM87DGh7ext937dhGFopJa6urtqqI6oyoCPx9Pt9nmUZX15e5koprrXm1lpm rWWIyOI4pngqxDnHJEmAMYacc+ScoxDCSint7u6uDYLARlFkx0RUe0Ds2rVrQRRFn3qe93fO+UVE XKxgu6R6TxDxP4j4z4cPH3529epVNfzzM0fkGhD78ssv/2CtvckYW7fWgtYaEKdyyYE4YoyBEAI4 54CIt5VSf/7oo4/+O/IlpQ/cWQNiAADXrl0LFhYWNowx60mSQJZlZ9wceZGCIIA4jsHzvNsPHjz4 09WrVzM44/UiXnLfhx/PAQCiKPo0z/P1nZ0diqdBsiyDnZ0dyPP80tra2hUYWcPC+Gt5JyoT0DPX eRhjH+7t7dEpq4EQEfb29oAx9rdOp8O73S6H8SEdj+oIMeH+xl0kZP1+/3WKp7kQEQ4ODl7f39/n aZpip9NBAICNjY1xH/MZjDmlTRpQ4chH9SzL2mcdPJkNSqlzSZKINE1tv99HIQSur68X14yKLztx lpjkFDb2ImG/3+fGGLfRk9oZYyCKIhFFkVhYWPAGg4HX7/f51tYW73a7405nR0y6BnomnoODA6+i 74HUTCklDg4OxNOnT0Ucx16WZXxMRGOddgo78sYkSVhxhXlpaansJzgygxCR+b7ve55nfd83aZqa OI4hSRIQQuDwRjjCCWugiSPodrssTVOmtWbD2xM0A80Jz/P8LMuEUkqEYegppbzl5WWepilP05QN Z6HCkUllkkU0AwCWJAlTSjFrLSvubVX6XZDaaK2F7/ssz3PgnKPWGpVS3FprRx65cZuBhjtiw7vq vN1uU0BzwvM8YYzxgiDwtNZeu93m1lpmjGFa68PnucBhEX3IGMOGG+eISHfT5wAiciGEZ609fGKi +HXaeycOqFj/aK0n3jhpBmstR0RmreVSyuI1O3/+/KnvpdMQgeGZhCEiK+Kx1h5OGMcfRR5V5jrQ uJ2SORAEAUNEFoZh6feeaQ1E8cynYllS5vjSKYwAwNnPKBQQcUIBESdlH+eYGa+++ip0Oh1YW1uD KIqAsWYty4wxkCQJ3L9/H27dugX7+/t1D+lMGhnQO++8A2+99Vbdw3DieR4sLS3Bm2++CW+88Qbc vHkT7ty5U/ewSmvcKazT6TQ+nuOEEPD+++/D2tpa3UMprVEBtVotePvtt+sexlRwzuHdd9+texil NSqgixcvQhAEdQ9jas6fPw8rKyt1D6OURgXUtP+4Z/HKK6/UPYRSGhWQ7/t1D2HqpJR1D6GURgVE Zg8FRJxQQMRJIy8kjoOIoJQ6/Qtrxhh77jrnwoULoJSCfr8Pv/76K1hrX+DoypubgAaDAXzxxRd1 D+NU586dg48//vjEv798+TJcvnwZAAAePXoE169fhzRNX9TwSqNT2Ay7cOHCzF84pYBesHa73I8T mPXbGxTQC1b2WtasX/uigIgTCugFK/upatZ//tLcfAo7yQcffAALCwu17PvevXuj//wAAAA8ffq0 1Da2trYqHFH15j6gpaUlWF5ermXf4w5+mRklyzL4/vvvqxxS5eY+oKZ59OgR7O3twc7ODty+fXvm H3WlgGbMjz/+CD/99FPdw5gYLaKJEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgT Cog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMK iDihgIgTCog4oYCIEwqIOKGAiJO5/wllu7u7YIypZd9JktSy3xdp7gP66quv6h7CXKNTGHFCAREn FBBxQgERJxQQcTI3n8La7TZ88skndQ/jVIyxuodQqbkJiDEGYRjWPYyXDp3CiBMKiDihgIiTRgWU ZVndQ5g6pVTdQyilUQH9/vvvdQ9h6pr2PTYqoLt37871LPT48WPY3t6uexilNCqgNE3h22+/rXsY U2GthW+++abuYZTWqIAAAH744YeZ/3dEy8rzHG7cuAG//PJL3UMprZEXEr/77ju4d+8edDodWFtb g3PnzjXuCq8xBp48eQL379+HW7duwZMnT+oe0pk0MiAAgN9++w1u3LhR9zBeeo07hZHZQgERJxQQ cUIBEQAAYIzhWd5XOiDP8/CsOyOzjXNe+rhOGhAFM8eyLEPGGKZpCgDlZqMzn8JoFpofw2OJjDFk jCHnHDnnKIRAIQSGYXjisZ44oDAMDzdY7KCS0ZPacc7tMBybpulhRI8fPz79vWV35nkecs7R8zxL s9B8YIxZrbXhnFvP82wxQUwySZQKSAiB29vbKISw/X6f4pkTxhjteZ4ZxmMHg4EdThIohMA4jotj /cwxnyQgBACM4xillMg5Ryml9X2/np9YQConhNBZlpk8z7Xv+0YIYaWUdnisT4wHoMQM1Ov1sFgH 7e7uWiGErWT0pHZaay2lzKWUOssyLaU0u7u7NgxDG4Yh9nq90XiOhHTazVQEgMPb3HEcY5qmFgAg SRKDiI27C06egVLKfDAYWEQ0Ukq7t7dnW62WlVKOnr7GKnMdCItZKIoi22q1DCItg5oOEUFrnUdR lLdaLZMkiWm1WiaKIru6umqHs0/x6xmTroGK33FjY8Ourq7aKIpslmWDir4PUhOlVNLv93WSJKbf 7+t2u/28eJ6JqOzzQAgA0Ov1oNvt2sFgcFdK2aHTWDMhIgwGg3txHOtisRyGIW5sbIyGU9kpbPQ1 9no91FpfT5IE6FTWPIgISZKAMeZfi4uLdnV11W5ubpqNjQ0LABaejWfsQfZK7vfIVPPaa69tRFH0 V2PMed/3gXO6ud8EWmtIkgSyLPv5zp07/+j1evrBgwfPi+XEGeKs557D9125cuWPUsp/c87XhRAg RGOfkn0paK1Baw3W2p+VUn/5/PPPHw7/alwkp55aXBcvDADgvffea1+6dOkKAHzIGLvIOV9w3C6Z AmvtE0S8i4jXNzc3P/v6669TOGWRfJpKAhr+ztfX1/n29rYfRZFQSgnf9/08z4UQwjfGeL7ve9Za joiseO/wNXEwck+yuBFq8zw3nueZPM91EAR5lmW63W5naZrmi4uLZnNzc3StA3CGeADc/6+M0QuN GIYhtlot0+/3IY5jPDg4gCAIUGttpZSeMcYDABYEAQegeKpURJTnuUVElFKaLMvM8HcdhqEeXuMp Hs84dYE8iaoWLAgArLhSrbVmUkqmtTZSSuCco9YajTFWSskRERCRUUDVKQISQqBSyiKiDYLA+L5v ENG0Wi2TZZmNosg+7+Zo6f26bgCOnsZYp9Ph+/v7fDAYeHEce0opT2vN2+02N8Zway2jeKZj9IEw z/PsYDCwwxujJkkS0263zeLioh1+VHda+xSqmIHG3S9DrbVNkgSWl5dRKcWzLOPWWlYENPzaCnZP AP7/zyoUARljMAxDK6W0u7u7NgiCk+5tOc1CVc0CR2ahbrfLtra2uFKKpWnKrbXMGMNWVlaY1poZ Y2j2mZLiGZ7t7W0sHv4bhoST3p4oo+qAites2+2yJElYmqZMKcW01kxrffh11lqKqGKjTxAWjx9L KTEMQ4zjGE+4MToTAR3f1uHH9CIkAIA0TSmaF6R4EH4kHICK4wGoNqDj25vkNZmOkyKpNB6A6R3M 49ulaOpT6aL5uGkfWApndtAjE4QQQgghFfkfLnfEbGu/P8gAAAAASUVORK5CYII= "
+ style="display:inline;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.697369"
+ width="28.697369" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_vesc.svg b/app/src/main/assets/ic_device_vesc.svg
new file mode 100644
index 0000000..d0287e2
--- /dev/null
+++ b/app/src/main/assets/ic_device_vesc.svg
@@ -0,0 +1,146 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_vesc.svg"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="22.627418"
+ inkscape:cx="1.3037281"
+ inkscape:cy="13.50132"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer2"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#1f7fdb;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#4dabf5;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline">
+ <rect
+ style="fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ <g
+ id="g870"
+ transform="matrix(0.851388,0,0,0.851388,4.1035946,4.1325617)"
+ style="fill:#fffffe;fill-opacity:1;stroke:none;stroke-width:1.17455">
+ <path
+ d="m 20.38,8.57 -1.23,1.85 A 8,8 0 0 1 18.93,18 H 5.07 A 8,8 0 0 1 15.58,6.85 L 17.43,5.62 A 10,10 0 0 0 3.35,19 2,2 0 0 0 5.07,20 H 18.92 A 2,2 0 0 0 20.66,19 10,10 0 0 0 20.39,8.56 Z"
+ id="path4"
+ style="fill:#fffffe;fill-opacity:1;stroke:none;stroke-width:1.17455" />
+ <path
+ d="m 10.59,15.41 a 2,2 0 0 0 2.83,0 l 5.66,-8.49 -8.49,5.66 a 2,2 0 0 0 0,2.83 z"
+ id="path6"
+ style="fill:#fffffe;fill-opacity:1;stroke:none;stroke-width:1.17455" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.0079936879"
+ x="0.010951031"
+ id="image4124"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAAAA3NCSVQICAjb4U/gAAAACXBIWXMA ADFOAAAxTgGacD5yAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAABF0RVh0 VGl0bGUAUGViYmxlIEljb26KvwD9AAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAAY3RFWHRDb3B5 cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0dHA6Ly9jcmVh dGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzMuMC/eBBrlAAABy1BMVEUAAAAAAAAA AP8AAIAAAFUAAKoAVaoAAIAAQIAAM2YAM5kzM2YzM5krK4AkJG0kJJIgIIAcHHEcHI4aGoAXF3QX F4sXLnQXLosVK4AUJ3YUJ4kSJIAkJIARInciInciIogQIIAgIIAeHngeHoccHIAcK4AbKHkbKIYa GoAaJoAYJHkXI4AXI4sWIXoWIYUVIIAfH3ofH4UdJ4ATHIQcJnsbJIAaI4QaIoAZIXsZIYQYIHgY IIAXH4MXJ3wXHoAXJoBLpfAWJHwdJHwdJIMcI4Acas0VIoMbIoAaIXwaIIAZH3wZH4MZJXwYHoAY HoYYJIAehtsYHoIefdtHoO0jetZGovNEn+4gg+EfgN5Ep/VKqfREpvQgfdkgfttKqvQggd4hjesw nPMwnPRJqfUhgd4hjutJqfUgfttNq/VNq/QhlPEhlPIhlvMkmPMglvIff9sgjeoglPAhk/AhlPEh lPIhlvMkl/MkmPMlmPMmmPMnmfMpmvMzn/Q0n/Q1n/Q2oPRIqfVNq/VasfZbsvZhtPZitfdtuveQ yvmXzvmj0/qk1Pqu2fu83/vR6f3T6v3Z7f3i8f3j8v3l8/7m8/7p9f7q9f7x+P7y+f7////ougeB AAAAb3RSTlMAAQECAwMDBAQFBQUFBgcHCAkJCgsLCwsMDQ0ODg8PDxAQERESEhMTFBQVFhYXFxgZ GRobGxwdHh8fICAhISIiIiMjIyQkJSYnKCkpKSoqKiorKyssLC2xs7O0tba2tu7u7u7u7+/w8fHy +/v7+/ywJhX9AAAC0klEQVRo3u2a+VfTQBCAm00iW0nSTVNKbKQYbIMUC6jU4q2IB2rF+74vPEAR LSjIIXgAAiqef65py6MNJe17y1RF8/26L/N1N9O8ZGZcLof/DiYFoiV1cZHwiGVZjh7zalRAYYbn sFsQ6RFwGccyBeJjwSMrPn8lHX6fQqRy3tbAsFiU1So9FA4bNIRD63XNTwTO5pSYc9fuvwbgwaW9 SxqYs/eGBgcAGBy+fRrlpRPDsJdfJoEYuMgvzibz/vK3kmDcFMuxNZvM/BG74QTdFV6PJZsYlhe9 XXCCrlCVStwcyhG45QCkoCmiV0o4uwXEib4aQMHjbc0RTXYvCMx/mGdNHaSgraWxRhEXzsgUEC0C KWjfszlUYRWsrYcUHGttNlSRzxUEo5CCjrZYrSqVVlD3Nwj6vqcZsovzKrPeTy3o/5lmxE4wnFl/ 4QhKLvjy2YY5IEExHMH0B1umQQSj9o+EUUewIBh/m8c4qOBbfu58dQRLCmYnJmZLKphKJicdwR8X vJsymSmhIM37FSv4OJlhxnlUOAJHAC8Ye5PHmPNe9PsEJX/5dT5AigvmPtkw53wnr3xB34809uWc zDp9OWflV7xgBaBlzY79MWORALhuui+vbhoArfwe3rUp5MtWfl2Il1QDUtAaj+rKakv1XdEhBfGm Wo1kq+/pM1Ih+wdRI6iIq3IFvEAewgke6QHFg1mU26PjhbtwghsKEbG1X8ew3PmnUPGfXRAwv7hr yqAD13t6nwPQ23PnBMeivG4gg85c7XwCQOeVU+avZ5bslcrrNm7dfehIInGSguOJo+0Hd27ZoOam j3ULvOjTI02x+PYddLTEY42GJpfnpo/L0g50k8pqI1IfbWikoSFaXxfSFKnMrqFsZpKbKKoWDFbT EdQCqlfCdhtIG7AoESLTQogk4EJd/VRTn8fLgecKTg3MjyUsA4SKDD7MT1bQ8++NmvwCRmydh2Fg UvEAAAAASUVORK5CYII= "
+ style="opacity:0.490741;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.710279"
+ width="28.710279" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_device_vesc_disabled.svg b/app/src/main/assets/ic_device_vesc_disabled.svg
new file mode 100644
index 0000000..b9bead5
--- /dev/null
+++ b/app/src/main/assets/ic_device_vesc_disabled.svg
@@ -0,0 +1,199 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ version="1.1"
+ id="svg2"
+ width="28.740126"
+ height="28.740126"
+ viewBox="0 0 28.740126 28.740126"
+ sodipodi:docname="ic_device_vesc_disabled.svg"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <metadata
+ id="metadata8">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs6">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 14.370063 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="28.740126 : 14.370063 : 1"
+ inkscape:persp3d-origin="14.370063 : 9.580042 : 1"
+ id="perspective903" />
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview4"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16"
+ inkscape:cx="7.34375"
+ inkscape:cy="7.65625"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer2"
+ inkscape:pagecheckerboard="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid972" />
+ </sodipodi:namedview>
+ <g
+ inkscape:groupmode="layer"
+ id="layer1"
+ inkscape:label="Background"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="Bottom"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-5"
+ width="22.819572"
+ height="21.904638"
+ x="2.9239759"
+ y="3.8768768"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Middle"
+ style="display:inline"
+ sodipodi:insensitive="true">
+ <rect
+ style="display:inline;fill:#9f9f9f;fill-opacity:0.941176;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817-7"
+ width="22.819572"
+ height="21.904638"
+ x="2.9317887"
+ y="3.0349438"
+ ry="0.9472276" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Top"
+ style="display:inline">
+ <rect
+ style="fill:#8a8a8a;fill-opacity:1;stroke:none;stroke-width:3.57115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect817"
+ width="22.819572"
+ height="21.904638"
+ x="2.9238882"
+ y="3.4130721"
+ ry="0.9472276" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer6"
+ inkscape:label="Foreground"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer7"
+ inkscape:label="Backplane"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer8"
+ inkscape:label="Frontplane"
+ style="display:inline">
+ <g
+ inkscape:groupmode="layer"
+ id="layer9"
+ inkscape:label="Base"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer10"
+ inkscape:label="Top"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer11"
+ inkscape:label="Toptop"
+ style="display:inline" />
+ <g
+ inkscape:groupmode="layer"
+ id="layer12"
+ inkscape:label="Toptoptop"
+ style="display:inline" />
+ </g>
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#8a8a8a;fill-opacity:0.94117647"
+ d="m 12.48275,10.865376 h 3.907 v 6.978 h -3.907 z"
+ android:strokeWidth="1"
+ id="path2735" />
+ <g
+ id="g870"
+ transform="matrix(0.851388,0,0,0.851388,4.1035946,4.1325617)"
+ style="display:inline;fill:#fffffe;fill-opacity:1;stroke:none;stroke-width:1.17455">
+ <path
+ d="m 20.38,8.57 -1.23,1.85 A 8,8 0 0 1 18.93,18 H 5.07 A 8,8 0 0 1 15.58,6.85 L 17.43,5.62 A 10,10 0 0 0 3.35,19 2,2 0 0 0 5.07,20 H 18.92 A 2,2 0 0 0 20.66,19 10,10 0 0 0 20.39,8.56 Z"
+ id="path4"
+ style="fill:#fffffe;fill-opacity:1;stroke:none;stroke-width:1.17455" />
+ <path
+ d="m 10.59,15.41 a 2,2 0 0 0 2.83,0 l 5.66,-8.49 -8.49,5.66 a 2,2 0 0 0 0,2.83 z"
+ id="path6"
+ style="fill:#fffffe;fill-opacity:1;stroke:none;stroke-width:1.17455" />
+ </g>
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Original"
+ style="display:none">
+ <image
+ y="0.028144173"
+ x="0.013794855"
+ id="image1291"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAANW0lE QVR4nO2dS4xc2VnH/98591VVXd3TbntC92ggwZ5R1BWRRZMoIQG1UIQIs0BCcpINESyyQIqySlgl 2I6yQ2xmtpEQilgQI4EUiAQaQQtCFAiNQjLVUWbsjII19iS2u931vI9zzseiqtxP21V9b7vrlr/f pp/33HPr/O53vvO4VYAgCIIgCIIgCIIgCILwLEBnUz6f8mmfZR7ZpKfyop+GQPvKHNX52mmLKjyS K8NGONAEhclUdMMOy2PsSdMcfr08+LK+JTKdNhurQ0GuD3/RGP58hfc1eSESeUUUMuSQPE0CLgPr ILRv0xp2EMd3CHf3DkiS+yJTQYTh0p4QjS1E0TJvYhGorzA2VjGQ6RoAHklEKECiIhuQDsiz3qC1 9jLF8R1KknNkzDYZs0vAi3CuJeKcEkrNM3ALnrfAnneOw3Cbo2iZN+t3GBtNHkSjh5FoagQ6Ik/j LlSSnKM4NirLbit3fo7Y9AhYgrP9vTjq+iJTTkhVHoqgdIWB+yCvyupeh31/xUWR58Jwm5sX4Abd 21ZhEhUo0NUD8nS7UGkKlS1C2QRq3kA5F5KrJ8Q2IaAOdqnIUxCkAgbaIB2yaoesVMItD06HcP4O XBDA1WpwA4lGkegqYwoEok++ygE/3/+CJv6MVrjIwEIB5QpFw9xyUG9ZuL+Jt+6+tnH1vUneKJRX IPrEN/nFiHrfJqBhHJBYwLqcpQqnglZAqAFPAc7xG0miX3n9s9GtPCOzkwpEAPDJVznQy73NxKBx p8PoZjJBWAbmAsJyjeArfqP747sf2rj6V+mhIf6IJzaomvDchIcJM4Of73+hm6Lx0wdO5CkRnZRx 84FDz9AHgpeWPj+Ycrk2zGOv0j5vnhhgJhHo0DzPNXJwn77VcnDiTulwDNxqOZDWn8LaosI6FLBK B2Vi4GHQOJ5xJxKPThKuN+h+D5esyFNaLAPbfXfp0m6owhgcre0MJx8xnDN68sTjBDPRI3lWCeug xl2oTsbVgq5FOCNaKeba7dCL43nX7d7hS17IYQxurjccNpo4JNERxunC6KA8Ww/neTJb4JUIZ0Jm gbSmvbj2rtefr+leb0l3u1CNu1BYb+zr0h52ZwcYMwcadVt78vT7HS29V/lhANVAedX+olfrWC+t 93SaDtr4oETH8ySB9mXkl7HWXqbRDLN97sKkIzhhGmGmNA79xE/8JHDeXFLVab2i03SwFLXWvj1Y FN+LQgcYX4L1LYrjO2TMLmWLUHNJXxd4GcIZ4uvMDzx4WdLxsijQc0lfZ4tQcfy2iuMVOrQF50A0 GiOJHnZf7UVKkpCcWyKbQJkalF/whQhng/XIQwYKgwBpljIZsE2gnFtwgy03Sxh2Y0dC0JgR6DLW sAZjlsier9G8gXLZxJOQwpTiZ/Csb7Q1mY5Mql0V6jkXkT3fIWOWht0YcNzO0oklcLZLzsVkLRSY ZTV9BnAM5RtPs4NyUUDOBeRcTM7ODdt37ZHHji3QKP9h0yPnIpKtGLMDe1BgQy4g5SwUc0rOxYfa 9/Kxx55gS+sSsNAHerUTVFWYRpg9YoDAIOaM2PEgSJgeGZNRHIOAxWOPHTMCjbLwF/edNJMINDNY YjYUPvx5/OBwghyoP9xRKMwWIdgZAioTBQcZSQkD2J4oKIhAQi5EICEXRT5Y+FR56ZzC5z7o46Mv eHi+RvBLditkDvhFl/Hddwy+/oMMb+2UcyN5KQX67Ad8fOXjIXSJU3lfAS/UCZff7+MPXvZx9TsJ /rqZnXW1JqZk9y3wykUPV36z3PIcRivgq78V4nd/tXz3c6kECjTw5Y+Fp/6eNGcBAfjKx8LSdcWl qu5HVjTeU5tFfQYszxE+vFKuXTKlEuilc6Wq7ol4uWTXWKra1vzZjT4j5kp2jaUSSJg+RCAhFyKQ kIvyTTw8ht2kHA8aLYSPznNePqfwexc97MSM/3nXIpnyZ+9mSqBf/8supv1RawJw80/mHvn3Vy55 eOXSoFluPnD4o3/o45329F6UdGFTzMXnFL78G+GT//EMEYGeMnrCV/xDy9M9sSgCPWUmXaqoTPm8 kAgk5EIEespM+mZcjqc3gQZmbBR2HH/+2xF+7fmzuU9++AuHL/1LfOB36YTD8p9sT/dGs5kX6MU6 4aXFsxFop58vemQO+Iv/TAuqzekw8wKVjTe3HW7sOPxfy+Hv3zR4UyKQMAn/eMPgtc3pjjr7kSRa yIUIJORCBBJyIQIJuRCBhFyIQEIuRCAhFyKQkAsRSMiFCCTkQgQSciECCbkQgYRciEBCLkQgIRci kJALEUjIhQgk5EIEEnIhAgm5EIGEXIhAQi5EICEXIpCQCxFIyIUIJORCBBJyIQIJuRCBhFyIQEIu RCAhFyKQkAsRSMjFzL9D2a02Y/GMPhH51hR/REFRzLxAh98lVSgW6cKEXIhAQi5EICEXIpCQCxFI yMVMjcL++49rZ12FZ46ZEuhxn0UqnA7ShQm5EIGEXIhAQi5KJVAnm/21pXZarmsslUA3pvyzs4rg rTNa+D0ppRLoe7ct3u2W6w6dhDsdxn/dnvAzMc+YUgmUWuBr/5FgFhViAF/9ToKsXAGoXAIBwLdv Glz99wS2ZC/047AO+LN/S/BPb5uzrsrElHIi8RtvZPjebYvPfdDHR1/w8Es1gi7ZrWAc8PMu47vv GHz9B1npcp8RpRQIAN7advjTf00AJGddlWeakt23wrQhAgm5EIGEXIhAwgDSJ5odmVggpStMOpzF qZhnnASkPAb6Ex01pkCrQ2FuTVgpoRxoJvJ4NJ4lGn9B7gRd2H1gd7KTCNMNkWGCZpBhIp9JBaxU zORV2fMWOIqWH9nWYwsURcvseQtMXnVQuApEoBmBDBzIskojRypjooCVig617/Vjjz1BDlRjpSLW Gg5EItEMoAgu87Ql1Xc6YadUykpFrHRn2L6bjz52vFNcxyY24XnnWN/rcsuDI5WIPDNC5sPozFql 4foKTvXgHqiY9b059rz7vFlfGbb1lSNtPoZAVxhoMOorHIbbrNQ86xDOj8Ny7TsQHok2bJSX2TRj 4/mB9Tw4HcIptcthuDSUpnFswBi/C9tY5UEe1GJ/B67loZyrf8IRUsMms37mh3MmSGE6YcX6O3BR 9D4XRbcZG6v75Tkg0pMEYmD0qMx1bNbvcK0GFwRwfrVjwdKLzQAchEHmJzBhqkwn7Fn94K4LArgw 3B52X6ME+uhjU2NGoGE3trHKzeg212pwldacBUsQKj3s0Df9LK5VsnYltkG7aiuVOVurwTUvwGGj OWj7Y/IfYDyBhlHoCgNbjM1PuOYFuFoNDnHaK/ZqhKcNx0k76FoTtPu21oWpVu/vk2d1nzwE4Ohm 0AmG8fsk2mhy8wKc6/duzOT+0mcFZnC//9N6/ZfN4iJMrQZ3YyHZF3m29stzLONuKGMANCiIGbgG bDTBH8+uc2t3jebnAZLHiksFM7jdBiW9v72x8IZDfWWYLDcBrDDQwCF5jg0Vk+xIPCKR+8ndV+n9 5/8QzjWoXge80m5wfLYwGbjdAZLkR+n/3nwNmztuIM0W9sQBniTPgf+YEBqV+ytf/OF7qVL5Fitq kO8D2gdkgno6YQJsBs4yENOP2HV//2df+7ufDf54RBrgMeKMyNvvEMB4z+98o1r5yIc/D8efBqmL rNR8znKFU4Act8C4odhe737/+6/9/J+/FQ+S5KsjUSa+8wsRCLhGAFSjAXXvXsdPa+e9auK81E/9 wKt4Jkt9Z632fa3ZQTFbAhQhAJidJE85IVKMFAAcE2kmBZdl1iqtrfbIZNZkfgoTV5FW491sYWHB DhJluMeNsMYhb9LCg+z5KoBVjqLXuVJZsejeQ78+xxUvQZakHEaBsybTzkJzpCmwRgEAsyfyFAE7 UGAYAFKtHSWW/TCz2oNN0tQGEZtQOWPb1nqV4faMjcVhznNyeYD8EWhYBgP4lMJ6gxp3oXZ353Wy 0NK1LrwsCrTJUh2ZVNuQlLNQQAVgQ8yZCFQQRP5AAspYaTidsIu9wHp+YP04td0aTLg7bxcWWnZv mP5Nl1egAodNDcbGKpprO3wpSZzbAXXqPTsXA2TAWTVw1kKx84k5I+aU5A3piqOLFEQBE1WZkLEL 4fwenGdT1wl7Ntipuqjqub3oU8wMXhEC7evGrgP1Bocx2Bg4tKvoLoJtAjWfQjkXk3MgnksIAAzq BZxeAIAQbQAJqANWKmZlI25Fg1X1YKe6b20LDLyOwXA9X/QBiunChuWMkukmjbqyJDlHcfy2cm6B 7PkaOdslNj1ytipd1ymhdG+wa1TXWN/rslK7HEXvc2G4zXvLEwdmmHMJVGAXdnCWurnecGvtkMJw gZPkPpkWyJjdoTjzcK4lEhWMUvMM7ALYheeBvep9DsMljqJwEHk2msOu6/HLE5NQlEDHLnVsYpWx fofQTmgNy4jjUa1bBZ1WOEgLwAKAwR72TdwG6hjmPK/j0MIoUEAeVHQUeDhDPZwbGtIk4PLw+y3C esFnFfbYAPYew7qOgzsJi5UHKF6gQ+WO6nhNuqszY/x1rZNw2g0r4kwPskApCIIgCIJQEP8P03/B 430mofMAAAAASUVORK5CYII= "
+ style="opacity:0.49074079;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.670942"
+ width="28.670942" />
+ <image
+ y="0.0055872202"
+ x="-0.031073861"
+ id="image3082"
+ xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz AABO4wAATuMBYdZxSQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAZdEVY dFRpdGxlAFR3ZWxmIEljb24gVGVtcGxhdGXyQOONAAAADHRFWHRBdXRob3IAeHBobnh/4OzMAAAA Y3RFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tTm9uQ29tbWVyY2lhbC1TaGFyZUFsaWtlIGh0 dHA6Ly9jcmVhdGl2ZWNvbW1vbnMub3JnL2xpY2Vuc2VzL2J5LW5jLXNhLzQuMC9D0yJcAAAL6klE QVR4nO3dTYvcRhrA8adKJZW6Zc0LmTEmmYUF5zYOJjQLOSTQEMhlA7uHsNlPsCHg5DP4MzghHyC3 xZvTGh8CgYaQXMwEkngOZo1f2JAYz2ReLHePSqqqZy+t2Z5xj6c1pbZa7ecHxo09LdVEf0rVkjIG IIQQQgghhBBCCCEvA9bw7ZPJ4TQ2Oo0DPG6bFFJ9xoVTWUxVH1g25vXxfTAAgG63W/GuSa/XK14e DwTH/HklEVUZ0PF4ilBYkiQMACBN0yP7U0rRzFQRKeWRIMIwRACAOI6x1+uNBlRpRNMIiAEAK8JJ 05QppZjWmmmtGQCAtZbCmRLOOQIACCFQCIFSSgzDEEdCGo1oZgJ6Jp6trS2ulGJpmvI8z/nKysph QMaYw/1STO6KaAAAPM87DGh7ext937dhGFopJa6urtqqI6oyoCPx9Pt9nmUZX15e5koprrXm1lpm rWWIyOI4pngqxDnHJEmAMYacc+ScoxDCSint7u6uDYLARlFkx0RUe0Ds2rVrQRRFn3qe93fO+UVE XKxgu6R6TxDxP4j4z4cPH3529epVNfzzM0fkGhD78ssv/2CtvckYW7fWgtYaEKdyyYE4YoyBEAI4 54CIt5VSf/7oo4/+O/IlpQ/cWQNiAADXrl0LFhYWNowx60mSQJZlZ9wceZGCIIA4jsHzvNsPHjz4 09WrVzM44/UiXnLfhx/PAQCiKPo0z/P1nZ0diqdBsiyDnZ0dyPP80tra2hUYWcPC+Gt5JyoT0DPX eRhjH+7t7dEpq4EQEfb29oAx9rdOp8O73S6H8SEdj+oIMeH+xl0kZP1+/3WKp7kQEQ4ODl7f39/n aZpip9NBAICNjY1xH/MZjDmlTRpQ4chH9SzL2mcdPJkNSqlzSZKINE1tv99HIQSur68X14yKLztx lpjkFDb2ImG/3+fGGLfRk9oZYyCKIhFFkVhYWPAGg4HX7/f51tYW73a7405nR0y6BnomnoODA6+i 74HUTCklDg4OxNOnT0Ucx16WZXxMRGOddgo78sYkSVhxhXlpaansJzgygxCR+b7ve55nfd83aZqa OI4hSRIQQuDwRjjCCWugiSPodrssTVOmtWbD2xM0A80Jz/P8LMuEUkqEYegppbzl5WWepilP05QN Z6HCkUllkkU0AwCWJAlTSjFrLSvubVX6XZDaaK2F7/ssz3PgnKPWGpVS3FprRx65cZuBhjtiw7vq vN1uU0BzwvM8YYzxgiDwtNZeu93m1lpmjGFa68PnucBhEX3IGMOGG+eISHfT5wAiciGEZ609fGKi +HXaeycOqFj/aK0n3jhpBmstR0RmreVSyuI1O3/+/KnvpdMQgeGZhCEiK+Kx1h5OGMcfRR5V5jrQ uJ2SORAEAUNEFoZh6feeaQ1E8cynYllS5vjSKYwAwNnPKBQQcUIBESdlH+eYGa+++ip0Oh1YW1uD KIqAsWYty4wxkCQJ3L9/H27dugX7+/t1D+lMGhnQO++8A2+99Vbdw3DieR4sLS3Bm2++CW+88Qbc vHkT7ty5U/ewSmvcKazT6TQ+nuOEEPD+++/D2tpa3UMprVEBtVotePvtt+sexlRwzuHdd9+texil NSqgixcvQhAEdQ9jas6fPw8rKyt1D6OURgXUtP+4Z/HKK6/UPYRSGhWQ7/t1D2HqpJR1D6GURgVE Zg8FRJxQQMRJIy8kjoOIoJQ6/Qtrxhh77jrnwoULoJSCfr8Pv/76K1hrX+DoypubgAaDAXzxxRd1 D+NU586dg48//vjEv798+TJcvnwZAAAePXoE169fhzRNX9TwSqNT2Ay7cOHCzF84pYBesHa73I8T mPXbGxTQC1b2WtasX/uigIgTCugFK/upatZ//tLcfAo7yQcffAALCwu17PvevXuj//wAAAA8ffq0 1Da2trYqHFH15j6gpaUlWF5ermXf4w5+mRklyzL4/vvvqxxS5eY+oKZ59OgR7O3twc7ODty+fXvm H3WlgGbMjz/+CD/99FPdw5gYLaKJEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgT Cog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMKiDihgIgTCog4oYCIEwqIOKGAiBMK iDihgIgTCog4oYCIEwqIOKGAiJO5/wllu7u7YIypZd9JktSy3xdp7gP66quv6h7CXKNTGHFCAREn FBBxQgERJxQQcTI3n8La7TZ88skndQ/jVIyxuodQqbkJiDEGYRjWPYyXDp3CiBMKiDihgIiTRgWU ZVndQ5g6pVTdQyilUQH9/vvvdQ9h6pr2PTYqoLt37871LPT48WPY3t6uexilNCqgNE3h22+/rXsY U2GthW+++abuYZTWqIAAAH744YeZ/3dEy8rzHG7cuAG//PJL3UMprZEXEr/77ju4d+8edDodWFtb g3PnzjXuCq8xBp48eQL379+HW7duwZMnT+oe0pk0MiAAgN9++w1u3LhR9zBeeo07hZHZQgERJxQQ cUIBEQAAYIzhWd5XOiDP8/CsOyOzjXNe+rhOGhAFM8eyLEPGGKZpCgDlZqMzn8JoFpofw2OJjDFk jCHnHDnnKIRAIQSGYXjisZ44oDAMDzdY7KCS0ZPacc7tMBybpulhRI8fPz79vWV35nkecs7R8zxL s9B8YIxZrbXhnFvP82wxQUwySZQKSAiB29vbKISw/X6f4pkTxhjteZ4ZxmMHg4EdThIohMA4jotj /cwxnyQgBACM4xillMg5Ryml9X2/np9YQConhNBZlpk8z7Xv+0YIYaWUdnisT4wHoMQM1Ov1sFgH 7e7uWiGErWT0pHZaay2lzKWUOssyLaU0u7u7NgxDG4Yh9nq90XiOhHTazVQEgMPb3HEcY5qmFgAg SRKDiI27C06egVLKfDAYWEQ0Ukq7t7dnW62WlVKOnr7GKnMdCItZKIoi22q1DCItg5oOEUFrnUdR lLdaLZMkiWm1WiaKIru6umqHs0/x6xmTroGK33FjY8Ourq7aKIpslmWDir4PUhOlVNLv93WSJKbf 7+t2u/28eJ6JqOzzQAgA0Ov1oNvt2sFgcFdK2aHTWDMhIgwGg3txHOtisRyGIW5sbIyGU9kpbPQ1 9no91FpfT5IE6FTWPIgISZKAMeZfi4uLdnV11W5ubpqNjQ0LABaejWfsQfZK7vfIVPPaa69tRFH0 V2PMed/3gXO6ud8EWmtIkgSyLPv5zp07/+j1evrBgwfPi+XEGeKs557D9125cuWPUsp/c87XhRAg RGOfkn0paK1Baw3W2p+VUn/5/PPPHw7/alwkp55aXBcvDADgvffea1+6dOkKAHzIGLvIOV9w3C6Z AmvtE0S8i4jXNzc3P/v6669TOGWRfJpKAhr+ztfX1/n29rYfRZFQSgnf9/08z4UQwjfGeL7ve9Za joiseO/wNXEwck+yuBFq8zw3nueZPM91EAR5lmW63W5naZrmi4uLZnNzc3StA3CGeADc/6+M0QuN GIYhtlot0+/3IY5jPDg4gCAIUGttpZSeMcYDABYEAQegeKpURJTnuUVElFKaLMvM8HcdhqEeXuMp Hs84dYE8iaoWLAgArLhSrbVmUkqmtTZSSuCco9YajTFWSskRERCRUUDVKQISQqBSyiKiDYLA+L5v ENG0Wi2TZZmNosg+7+Zo6f26bgCOnsZYp9Ph+/v7fDAYeHEce0opT2vN2+02N8Zway2jeKZj9IEw z/PsYDCwwxujJkkS0263zeLioh1+VHda+xSqmIHG3S9DrbVNkgSWl5dRKcWzLOPWWlYENPzaCnZP AP7/zyoUARljMAxDK6W0u7u7NgiCk+5tOc1CVc0CR2ahbrfLtra2uFKKpWnKrbXMGMNWVlaY1poZ Y2j2mZLiGZ7t7W0sHv4bhoST3p4oo+qAites2+2yJElYmqZMKcW01kxrffh11lqKqGKjTxAWjx9L KTEMQ4zjGE+4MToTAR3f1uHH9CIkAIA0TSmaF6R4EH4kHICK4wGoNqDj25vkNZmOkyKpNB6A6R3M 49ulaOpT6aL5uGkfWApndtAjE4QQQgghFfkfLnfEbGu/P8gAAAAASUVORK5CYII= "
+ style="display:inline;image-rendering:optimizeQuality"
+ preserveAspectRatio="none"
+ height="28.697369"
+ width="28.697369" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_distance.svg b/app/src/main/assets/ic_distance.svg
new file mode 100644
index 0000000..64b6a40
--- /dev/null
+++ b/app/src/main/assets/ic_distance.svg
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ height="24"
+ viewBox="0 0 24.170443 24"
+ width="24.170443"
+ version="1.1"
+ id="svg6"
+ sodipodi:docname="ic_distance.svg"
+ inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1042"
+ id="namedview8"
+ showgrid="false"
+ inkscape:snap-others="false"
+ inkscape:object-nodes="false"
+ inkscape:zoom="26.103965"
+ inkscape:cx="22.380713"
+ inkscape:cy="6.9428897"
+ inkscape:window-x="0"
+ inkscape:window-y="38"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg6"
+ inkscape:document-rotation="0"
+ lock-margins="true"
+ fit-margin-top="2.5"
+ fit-margin-left="2.5"
+ fit-margin-right="2.5"
+ fit-margin-bottom="2.5" />
+ <path
+ d="M 1.7281728,0.0307995 H 25.728173 V 24.030799 H 1.7281728 Z"
+ fill="none"
+ id="path2" />
+ <g
+ id="g3324"
+ transform="matrix(1,0,0,1.0133493,2.5,2.097751)"
+ style="stroke-width:0.993391">
+ <path
+ d="m 6.0804285,11.546119 c -0.8360589,0 -1.5201071,-0.684048 -1.5201071,-1.520107 0,-0.8360578 0.6840483,-1.5201055 1.5201071,-1.5201055 0.836059,0 1.5201069,0.6840477 1.5201069,1.5201055 0,0.836059 -0.6840479,1.520107 -1.5201069,1.520107 z M 10.64075,10.178023 c 0,-2.7589943 -2.0141423,-4.712332 -4.5603215,-4.712332 -2.5461794,0 -4.5603214,1.9533377 -4.5603214,4.712332 0,1.778525 1.4821045,4.134691 4.5603214,6.94689 3.0782168,-2.812199 4.5603215,-5.168365 4.5603215,-6.94689 z M 6.0804285,3.9455838 c 3.1922253,0 6.0804285,2.4473729 6.0804285,6.2324392 0,2.523378 -2.029343,5.510388 -6.0804285,8.968632 C 2.029343,15.688411 0,12.701401 0,10.178023 0,6.3929567 2.8882037,3.9455838 6.0804285,3.9455838 Z"
+ id="path4"
+ style="stroke-width:0.993391" />
+ <path
+ d="m 16.105923,4.2275996 c -0.421371,0 -0.76613,-0.344759 -0.76613,-0.76613 0,-0.4213716 0.344759,-0.7661296 0.76613,-0.7661296 0.421372,0 0.76613,0.344758 0.76613,0.7661296 0,0.421371 -0.344758,0.76613 -0.76613,0.76613 z m 2.29839,-0.689517 c 0,-1.3905256 -1.015122,-2.3750026 -2.29839,-2.3750026 -1.283268,0 -2.29839,0.984477 -2.29839,2.3750026 0,0.896372 0.746977,2.083873 2.29839,3.501214 1.551413,-1.417341 2.29839,-2.604842 2.29839,-3.501214 z m -2.29839,-3.14113259 c 1.608873,0 3.06452,1.23346899 3.06452,3.14113259 0,1.271776 -1.022784,2.777221 -3.06452,4.520167 -2.041736,-1.742946 -3.06452,-3.248391 -3.06452,-4.520167 0,-1.9076636 1.455647,-3.14113259 3.06452,-3.14113259 z"
+ id="path4-3"
+ style="stroke-width:0.99339" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.993391;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+ d="M 8.399119,18.046615 C 25.927241,13.211774 8.3489042,11.251336 15.01982,7.7851976"
+ id="path852"
+ sodipodi:nodetypes="cc" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_distance_total.svg b/app/src/main/assets/ic_distance_total.svg
new file mode 100644
index 0000000..330795e
--- /dev/null
+++ b/app/src/main/assets/ic_distance_total.svg
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ height="24"
+ viewBox="0 0 24.170443 24"
+ width="24.170443"
+ version="1.1"
+ id="svg6"
+ sodipodi:docname="ic_distance_total.svg"
+ inkscape:version="1.0.2 (e86c870879, 2021-01-15)">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1042"
+ id="namedview8"
+ showgrid="false"
+ inkscape:snap-others="false"
+ inkscape:object-nodes="false"
+ inkscape:zoom="26.103965"
+ inkscape:cx="22.380713"
+ inkscape:cy="13.072227"
+ inkscape:window-x="0"
+ inkscape:window-y="38"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg6"
+ inkscape:document-rotation="0"
+ lock-margins="true"
+ fit-margin-top="2.5"
+ fit-margin-left="2.5"
+ fit-margin-right="2.5"
+ fit-margin-bottom="2.5" />
+ <path
+ d="M 1.7281728,0.0307995 H 25.728173 V 24.030799 H 1.7281728 Z"
+ fill="none"
+ id="path2" />
+ <path
+ d="m 11.238544,10.768935 c -0.836058,0 -1.5201071,-0.693181 -1.5201071,-1.5404014 0,-0.8472175 0.6840491,-1.5403975 1.5201071,-1.5403975 0.836059,0 1.520107,0.69318 1.520107,1.5403975 0,0.8472204 -0.684048,1.5404014 -1.520107,1.5404014 z m 4.560322,-1.3863614 c 0,-2.795824 -2.014142,-4.7752374 -4.560322,-4.7752374 -2.5461791,0 -4.5603211,1.9794134 -4.5603211,4.7752374 0,1.8022694 1.4821045,4.1898874 4.5603211,7.0396274 3.078217,-2.84974 4.560322,-5.237358 4.560322,-7.0396274 z M 11.238544,3.0669367 c 3.192226,0 6.080429,2.4800436 6.080429,6.3156369 0,2.5570654 -2.029343,5.5839484 -6.080429,9.0883574 -4.0510852,-3.504409 -6.0804282,-6.531292 -6.0804282,-9.0883574 0,-3.8355933 2.8882037,-6.3156369 6.0804282,-6.3156369 z"
+ id="path4"
+ style="stroke-width:1" />
+ <path
+ d="m 20.107749,4.7842441 c -0.421371,0 -0.76613,-0.3493613 -0.76613,-0.7763573 0,-0.4269966 0.344759,-0.7763569 0.76613,-0.7763569 0.421372,0 0.76613,0.3493603 0.76613,0.7763569 0,0.426996 -0.344758,0.7763573 -0.76613,0.7763573 z m 2.29839,-0.6987216 c 0,-1.4090881 -1.015122,-2.4067071 -2.29839,-2.4067071 -1.283268,0 -2.29839,0.997619 -2.29839,2.4067071 0,0.908338 0.746977,2.1116913 2.29839,3.5479528 1.551413,-1.4362615 2.29839,-2.6396148 2.29839,-3.5479528 z m -2.29839,-3.18306443 c 1.608873,0 3.06452,1.24993493 3.06452,3.18306443 0,1.2887533 -1.022784,2.814295 -3.06452,4.5805085 -2.041736,-1.7662135 -3.06452,-3.2917552 -3.06452,-4.5805085 0,-1.9331295 1.455647,-3.18306443 3.06452,-3.18306443 z"
+ id="path4-3"
+ style="stroke-width:0.999999" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+ d="M 6.24625,21.451735 C 24.781737,20.731762 10.875735,11.944143 22.329352,9.1467685"
+ id="path852"
+ sodipodi:nodetypes="cc" />
+ <path
+ d="m 4.0025394,17.725181 c -0.421371,0 -0.76613,-0.349362 -0.76613,-0.776358 0,-0.426996 0.344759,-0.776356 0.76613,-0.776356 0.421372,0 0.76613,0.34936 0.76613,0.776356 0,0.426996 -0.344758,0.776358 -0.76613,0.776358 z m 2.29839,-0.698722 c 0,-1.409088 -1.015122,-2.406707 -2.29839,-2.406707 -1.283268,0 -2.29839,0.997619 -2.29839,2.406707 0,0.908338 0.746977,2.111691 2.29839,3.547953 1.551413,-1.436262 2.29839,-2.639615 2.29839,-3.547953 z m -2.29839,-3.183064 c 1.608873,0 3.06452,1.249935 3.06452,3.183064 0,1.288753 -1.022784,2.814295 -3.06452,4.580509 -2.041736,-1.766214 -3.06451998,-3.291756 -3.06451998,-4.580509 0,-1.933129 1.45564698,-3.183064 3.06451998,-3.183064 z"
+ id="path4-3-5"
+ style="stroke-width:0.999999" />
+</svg>
diff --git a/app/src/main/assets/ic_galaxy_buds_l.svg b/app/src/main/assets/ic_galaxy_buds_l.svg
new file mode 100644
index 0000000..7db1cba
--- /dev/null
+++ b/app/src/main/assets/ic_galaxy_buds_l.svg
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="22.722658mm"
+ height="24.000002mm"
+ viewBox="0 0 22.722658 24.000001"
+ version="1.1"
+ id="svg7609"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ sodipodi:docname="ic_galaxy_buds_l.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview7611"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ fit-margin-left="2"
+ fit-margin-right="2"
+ fit-margin-bottom="2"
+ fit-margin-top="2"
+ inkscape:zoom="5.0814254"
+ inkscape:cx="74.093384"
+ inkscape:cy="61.104902"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1" />
+ <defs
+ id="defs7606" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(4.0001844,3.9998056)">
+ <g
+ id="g1406"
+ transform="matrix(1.3339122,0,0,1.3438174,-98.67174,-164.47229)"
+ style="display:inline;stroke:#000000;stroke-width:0.74690652;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
+ <path
+ style="fill:none;stroke:#000000;stroke-width:0.74690652;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 80.707855,121.04264 c -1.01885,0.26765 -1.65323,1.66683 -2.43737,2.32029 -1.04852,0.87375 -2.21948,1.35316 -2.91032,2.60741 -0.52565,0.95434 -0.38154,2.31718 -0.93985,3.15709 -0.29192,0.43915 -0.8728,0.64296 -1.20255,1.07824 -1.02698,1.35557 -0.91369,3.40161 0.31962,4.59246 0.81316,0.78509 2.09427,1.10747 3.1937,0.85385 0.46858,-0.10813 0.92029,-0.49721 1.41111,-0.45694 1.1463,0.094 1.85787,0.65296 3.07879,0.33508 4.31184,-1.1226 5.92705,-6.16249 4.97237,-10.0729 -0.50492,-2.06804 -3.02696,-5.06045 -5.4855,-4.41458"
+ id="path3559" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.74690652;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 75.335215,131.12944 c -0.94858,1.31848 -0.26706,3.35249 2.35997,3.35394"
+ id="path10585"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.74690652;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 77.749325,125.52752 c 1.77783,-0.71339 3.04493,-0.68717 4.67949,-0.21743 0.76552,0.22 1.36638,1.12151 1.42838,1.9156 0.28599,3.66326 -1.93567,5.44894 -4.17558,5.28724 -1.3729,-0.0991 -2.75373,-1.82993 -3.08505,-3.34193 -0.27878,-1.27217 -0.0559,-3.1585 1.15276,-3.64348 z"
+ id="path5256"
+ sodipodi:nodetypes="ssssss" />
+ <path
+ style="fill:none;stroke:#000000;stroke-width:0.74690652;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 79.296745,123.82804 c 2.35625,-0.33954 4.76904,0.66271 5.7504,2.912 1.31529,3.01478 -0.58112,7.91081 -4.08272,8.42805 -6.4448,0.95186 -8.53815,-10.35004 -1.66768,-11.34005"
+ id="path3567" />
+ </g>
+ <g
+ aria-label="L"
+ id="text23582"
+ style="font-size:7.05556px;line-height:124%;font-family:Sans;-inkscape-font-specification:Sans;letter-spacing:0px;word-spacing:0px;stroke:#000000;stroke-width:0.264583px">
+ <path
+ d="M 6.9078682,5.6385593 H 7.6037779 V 10.196424 H 10.108364 V 10.78209 H 6.9078682 Z"
+ id="path24331" />
+ </g>
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_galaxy_buds_live.svg b/app/src/main/assets/ic_galaxy_buds_live.svg
new file mode 100644
index 0000000..48c148d
--- /dev/null
+++ b/app/src/main/assets/ic_galaxy_buds_live.svg
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="22.722673mm"
+ height="24.000002mm"
+ viewBox="0 0 22.722673 24.000002"
+ version="1.1"
+ id="svg1002"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ sodipodi:docname="ic_galaxy_buds_live.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview1004"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ fit-margin-left="2"
+ fit-margin-top="2"
+ fit-margin-right="2"
+ fit-margin-bottom="2"
+ inkscape:zoom="5.0814254"
+ inkscape:cx="-11.709313"
+ inkscape:cy="29.617674"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1" />
+ <defs
+ id="defs999" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(2.0000914,1.9999036)">
+ <g
+ id="g3814">
+ <path
+ style="fill:none;stroke:#000000;stroke-width:0.500001;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 6.3534766,13.150028 C 5.2590543,14.102574 3.7589552,14.39697 2.6009409,13.886465 1.3699015,13.343766 0.52234461,12.049487 0.50706651,10.688971 0.49818111,9.8974897 0.68425351,9.3259096 1.2026925,8.5521587 1.6467421,7.8894314 1.7546703,7.5633672 1.7383195,6.9339544 1.7242951,6.3940166 1.5887612,6.0540278 1.1304302,5.409047 0.69461341,4.7957481 0.51189951,4.3487514 0.43456241,3.7066569 0.29489991,2.5470862 0.74072801,1.4253568 1.6710145,0.59567225 2.8470953,-0.45322535 4.5920415,-0.46138405 5.9962565,0.57544755 7.6247819,1.7779049 8.5957069,4.0874446 8.6379302,6.8591974 8.6637127,8.551633 8.3637849,10.047519 7.7501356,11.287081 7.5146695,11.762713 7.3453654,12.031594 7.0219979,12.443468 6.7260442,12.820427 6.6974699,12.850626 6.3534853,13.150033 Z"
+ id="path11168-8" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 6.5908811,5.6846666 c 0,0 0.5148355,0.502981 0.476276,1.6957252 -0.03255,1.0068473 -0.6176138,1.4771457 -0.6176138,1.4771457"
+ id="path2468"
+ sodipodi:nodetypes="csc" />
+ </g>
+ <g
+ id="g3814-7"
+ transform="matrix(-1,0,0,1,19.129886,6.2222493)">
+ <path
+ style="fill:none;stroke:#000000;stroke-width:0.500001;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 6.3534766,13.150028 C 5.2590543,14.102574 3.7589552,14.39697 2.6009409,13.886465 1.3699015,13.343766 0.52234461,12.049487 0.50706651,10.688971 0.49818111,9.8974897 0.68425351,9.3259096 1.2026925,8.5521587 1.6467421,7.8894314 1.7546703,7.5633672 1.7383195,6.9339544 1.7242951,6.3940166 1.5887612,6.0540278 1.1304302,5.409047 0.69461341,4.7957481 0.51189951,4.3487514 0.43456241,3.7066569 0.29489991,2.5470862 0.74072801,1.4253568 1.6710145,0.59567225 2.8470953,-0.45322535 4.5920415,-0.46138405 5.9962565,0.57544755 7.6247819,1.7779049 8.5957069,4.0874446 8.6379302,6.8591974 8.6637127,8.551633 8.3637849,10.047519 7.7501356,11.287081 7.5146695,11.762713 7.3453654,12.031594 7.0219979,12.443468 6.7260442,12.820427 6.6974699,12.850626 6.3534853,13.150033 Z"
+ id="path11168-8-1" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+ d="m 6.5908811,5.6846666 c 0,0 0.5148355,0.502981 0.476276,1.6957252 -0.03255,1.0068473 -0.6176138,1.4771457 -0.6176138,1.4771457"
+ id="path2468-7"
+ sodipodi:nodetypes="csc" />
+ </g>
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_galaxy_buds_live_l.svg b/app/src/main/assets/ic_galaxy_buds_live_l.svg
new file mode 100644
index 0000000..1114bb3
--- /dev/null
+++ b/app/src/main/assets/ic_galaxy_buds_live_l.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="24mm"
+ height="24mm"
+ viewBox="0 0 24 23.999999"
+ version="1.1"
+ id="svg7609"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ sodipodi:docname="ic_galaxy_buds_live_l.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview7611"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ fit-margin-left="3"
+ fit-margin-right="3"
+ fit-margin-bottom="2"
+ fit-margin-top="2"
+ inkscape:zoom="4.3195774"
+ inkscape:cx="13.311487"
+ inkscape:cy="62.390362"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1" />
+ <defs
+ id="defs7606" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(1.1500002,4.0943479)">
+ <g
+ aria-label="L"
+ id="text23582"
+ style="font-size:7.05556px;line-height:124%;font-family:Sans;-inkscape-font-specification:Sans;letter-spacing:0px;word-spacing:0px;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
+ transform="translate(3.1921316,-0.30467255)">
+ <path
+ d="M 6.9078682,5.6385593 H 7.6037779 V 10.196424 H 10.108364 V 10.78209 H 6.9078682 Z"
+ id="path24331"
+ style="stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none" />
+ </g>
+ <path
+ style="fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 13.383555,16.454104 C 11.867284,17.773042 9.788966,18.180675 8.1845922,17.473808 6.4790457,16.722363 5.3047953,14.930245 5.2836284,13.046414 5.2713197,11.950494 5.529112,11.159059 6.2473848,10.087689 6.862595,9.1700478 7.0121239,8.7185645 6.9894715,7.8470523 6.9700367,7.0994299 6.7822655,6.6286664 6.1472693,5.7355971 5.5434653,4.8863961 5.2903238,4.2674645 5.1831776,3.3783918 4.9896817,1.7727986 5.6073548,0.21960185 6.896223,-0.92921641 c 1.629404,-1.45235029 4.046946,-1.46364719 5.99242,-0.028004 2.256246,1.66497608 3.601416,4.86286791 3.659915,8.70076011 0.03572,2.3434213 -0.379816,4.4146913 -1.229998,6.1310443 -0.326227,0.658582 -0.56079,1.030886 -1.0088,1.601185 -0.41003,0.521954 -0.449618,0.563769 -0.926193,0.978342 z"
+ id="path11168" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="trace"
+ transform="translate(-2.8501842,0.0945423)" />
+</svg>
diff --git a/app/src/main/assets/ic_galaxy_buds_live_r.svg b/app/src/main/assets/ic_galaxy_buds_live_r.svg
new file mode 100644
index 0000000..0e59629
--- /dev/null
+++ b/app/src/main/assets/ic_galaxy_buds_live_r.svg
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="24mm"
+ height="24mm"
+ viewBox="0 0 23.999999 24"
+ version="1.1"
+ id="svg1002"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ sodipodi:docname="ic_galaxy_buds_live_r.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview1004"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ fit-margin-left="3"
+ fit-margin-top="2"
+ fit-margin-right="3"
+ fit-margin-bottom="2"
+ inkscape:zoom="6.1088053"
+ inkscape:cx="37.077626"
+ inkscape:cy="50.009779"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1" />
+ <defs
+ id="defs999" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(0.55009208,2.2464716)">
+ <g
+ aria-label="R"
+ id="text20823"
+ style="font-size:7.05556px;line-height:124%;font-family:Sans;-inkscape-font-specification:Sans;letter-spacing:0px;word-spacing:0px;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
+ transform="translate(2.5829786,-0.5449331)">
+ <path
+ d="m 9.0560585,10.458658 q 0.2239314,0.07579 0.4340823,0.32384 0.2135961,0.248047 0.4271921,0.682129 l 0.7062451,1.4056 H 9.8759918 L 9.2179781,11.550754 Q 8.9630409,11.03399 8.721884,10.86518 8.4841723,10.69637 8.0707606,10.69637 H 7.3128391 v 2.173857 H 6.6169293 V 7.726696 h 1.5709646 q 0.881945,0 1.3160273,0.3686254 0.4340823,0.3686255 0.4340823,1.1127666 0,0.4857588 -0.2273764,0.806153 -0.2239314,0.320394 -0.6545686,0.444417 z M 7.3128391,8.2985822 v 1.8259018 h 0.8750548 q 0.5029843,0 0.7579215,-0.2308216 0.2583823,-0.2342666 0.2583823,-0.6855744 0,-0.4513078 -0.2583823,-0.6786843 Q 8.6908782,8.2985822 8.1878939,8.2985822 Z"
+ id="path21707"
+ style="stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none" />
+ </g>
+ <path
+ style="fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 8.9163768,18.30198 c 1.5162552,1.318938 3.5945542,1.726571 5.1989132,1.019704 1.705531,-0.751445 2.87977,-2.543563 2.900937,-4.427394 0.01231,-1.09592 -0.245482,-1.887355 -0.963748,-2.958725 C 15.437275,11.017923 15.287747,10.56644 15.3104,9.6949274 15.32983,8.9473054 15.517604,8.4765419 16.152594,7.5834726 16.756392,6.7342716 17.009531,6.11534 17.116677,5.2262673 17.310171,3.6206741 16.692503,2.0674774 15.403647,0.91865918 13.774258,-0.53369127 11.356739,-0.54498817 9.4112835,0.89065518 7.1550587,2.5556312 5.8099017,5.753523 5.7514033,9.5914154 c -0.03572,2.3434216 0.3798124,4.4146916 1.2299864,6.1310446 0.326224,0.658582 0.5607847,1.030886 1.0087907,1.601185 0.4100257,0.521954 0.4496137,0.563769 0.9261844,0.978342 z"
+ id="path11168" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_galaxy_buds_r.svg b/app/src/main/assets/ic_galaxy_buds_r.svg
new file mode 100644
index 0000000..437a846
--- /dev/null
+++ b/app/src/main/assets/ic_galaxy_buds_r.svg
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="22.722673mm"
+ height="24.000002mm"
+ viewBox="0 0 22.722673 24.000002"
+ version="1.1"
+ id="svg1002"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ sodipodi:docname="ic_galaxy_buds_r.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview1004"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ fit-margin-left="2"
+ fit-margin-top="2"
+ fit-margin-right="2"
+ fit-margin-bottom="2"
+ inkscape:zoom="5.0814254"
+ inkscape:cx="66.024782"
+ inkscape:cy="37.292686"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1" />
+ <defs
+ id="defs999" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(2.0000914,1.9999036)">
+ <g
+ id="g12091"
+ transform="matrix(0.88927548,0,0,0.8958785,-64.445881,-120.41835)"
+ style="display:inline;stroke:#000000;stroke-width:1.1203592;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
+ <g
+ id="g12078"
+ style="stroke:#000000;stroke-width:1.1203592;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1.1203592;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 89.229326,149.75243 c 1.422876,1.97773 0.400592,5.02874 -3.539949,5.03091"
+ id="path10585-6"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1.1203592;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 85.608172,141.34954 c -2.666757,-1.07007 -4.567397,-1.03074 -7.019236,-0.32613 -1.148282,0.32999 -2.049576,1.68227 -2.142569,2.8734 -0.428995,5.49489 2.903496,8.17341 6.263367,7.93085 2.05934,-0.14867 4.130587,-2.74489 4.627575,-5.01288 0.41816,-1.90826 0.08389,-4.73774 -1.729137,-5.46524 z"
+ id="path5256-7"
+ sodipodi:nodetypes="ssssss" />
+ <path
+ style="fill:none;stroke:#000000;stroke-width:1.1203592;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 83.287036,138.80034 c -3.534371,-0.50931 -7.153563,0.99406 -8.625608,4.36799 -1.972926,4.52216 0.871682,11.86623 6.124093,12.64208 9.667201,1.42779 12.807218,-15.52506 2.501515,-17.01007"
+ id="path3567-5" />
+ </g>
+ <path
+ style="fill:none;stroke:#000000;stroke-width:1.1203592;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 81.17037,134.62224 c 1.528272,0.40147 2.479848,2.50023 3.65606,3.48042 1.57278,1.31064 3.329209,2.02976 4.365471,3.91113 0.788477,1.43151 0.572308,3.47576 1.409777,4.73562 0.43788,0.65873 1.309196,0.96445 1.803823,1.61735 1.540471,2.03338 1.370542,5.10243 -0.479425,6.88871 -1.219739,1.17764 -3.141402,1.6612 -4.790555,1.28077 -0.702868,-0.16221 -1.380432,-0.74583 -2.116667,-0.68541 -1.719445,0.14105 -2.786803,0.97944 -4.618181,0.50261 -6.467763,-1.6839 -8.890577,-9.24373 -7.458556,-15.10934 0.757382,-3.10205 4.540443,-7.59068 8.228253,-6.62186"
+ id="path3559-3" />
+ </g>
+ <g
+ aria-label="R"
+ id="text20823"
+ style="font-size:7.05556px;line-height:124%;font-family:Sans;-inkscape-font-specification:Sans;letter-spacing:0px;word-spacing:0px;stroke:#000000;stroke-width:0.264583px">
+ <path
+ d="m 9.0560585,10.458658 q 0.2239314,0.07579 0.4340823,0.32384 0.2135961,0.248047 0.4271921,0.682129 l 0.7062451,1.4056 H 9.8759918 L 9.2179781,11.550754 Q 8.9630409,11.03399 8.721884,10.86518 8.4841723,10.69637 8.0707606,10.69637 H 7.3128391 v 2.173857 H 6.6169293 V 7.726696 h 1.5709646 q 0.881945,0 1.3160273,0.3686254 0.4340823,0.3686255 0.4340823,1.1127666 0,0.4857588 -0.2273764,0.806153 -0.2239314,0.320394 -0.6545686,0.444417 z M 7.3128391,8.2985822 v 1.8259018 h 0.8750548 q 0.5029843,0 0.7579215,-0.2308216 0.2583823,-0.2342666 0.2583823,-0.6855744 0,-0.4513078 -0.2583823,-0.6786843 Q 8.6908782,8.2985822 8.1878939,8.2985822 Z"
+ id="path21707" />
+ </g>
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_heartrate.svg b/app/src/main/assets/ic_heartrate.svg
new file mode 100644
index 0000000..ee74236
--- /dev/null
+++ b/app/src/main/assets/ic_heartrate.svg
@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ height="24"
+ viewBox="0 0 24 24"
+ width="24"
+ version="1.1"
+ id="svg6"
+ sodipodi:docname="ic_heartrate.svg"
+ inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1042"
+ id="namedview8"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="22.185475"
+ inkscape:cx="15.252703"
+ inkscape:cy="16.702097"
+ inkscape:window-x="0"
+ inkscape:window-y="38"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg6">
+ <sodipodi:guide
+ position="13.81042,14.118536"
+ orientation="0,-1"
+ id="guide843" />
+ <sodipodi:guide
+ position="12.237736,12.731366"
+ orientation="0,-1"
+ id="guide845" />
+ <sodipodi:guide
+ position="10.840426,11.852413"
+ orientation="0,-1"
+ id="guide847" />
+ <sodipodi:guide
+ position="10.569979,10.364953"
+ orientation="0,-1"
+ id="guide849" />
+ </sodipodi:namedview>
+ <path
+ d="M0 0h24v24H0V0z"
+ fill="none"
+ id="path2" />
+ <path
+ d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z"
+ id="path4" />
+ <path
+ fill="currentColor"
+ d="m 4.4092095,12.147587 3.5617512,0 2.0837433,-5.0286723 1.932068,5.4893343 1.823648,-2.726785 h 6.48905 l -0.488067,1.38717 -5.262995,0 -2.938855,4.509156 -1.60202,-4.54569 -0.9505981,2.403348 -3.5064865,-4.01e-4 z"
+ id="path2361"
+ sodipodi:nodetypes="ccccccccccccc"
+ style="stroke-width:1"
+ inkscape:transform-center-x="1.0957646"
+ inkscape:transform-center-y="-1.4792654" />
+</svg>
diff --git a/app/src/main/assets/ic_intensity.svg b/app/src/main/assets/ic_intensity.svg
new file mode 100644
index 0000000..c5ab1b9
--- /dev/null
+++ b/app/src/main/assets/ic_intensity.svg
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" height="24" viewBox="-2.7755576e-17 -2.7755576e-17 21.367055 24" width="21.367056" version="1.1" id="svg6" sodipodi:docname="ic_intensity.svg" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <g id="g3949"/>
+ <metadata id="metadata12">
+ <rdf:RDF>
+ <cc:Work rdf:about="">
+ <dc:format/>
+ <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+ <dc:title/>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs id="defs10"/>
+ <sodipodi:namedview pagecolor="#ffffff" bordercolor="#666666" borderopacity="1" objecttolerance="10" gridtolerance="10" guidetolerance="10" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-width="1920" inkscape:window-height="1042" id="namedview8" showgrid="false" showguides="true" inkscape:guide-bbox="true" inkscape:zoom="22.185475" inkscape:cx="23.811004" inkscape:cy="13.427636" inkscape:window-x="0" inkscape:window-y="38" inkscape:window-maximized="1" inkscape:current-layer="svg6" inkscape:document-rotation="0" inkscape:snap-others="false" fit-margin-top="2.5" fit-margin-left="2.5" fit-margin-right="2.5" fit-margin-bottom="2.5" lock-margins="true" viewbox-height="25.46567" inkscape:pagecheckerboard="true" scale-x="1" viewbox-x="0" viewbox-y="0">
+ <sodipodi:guide position="15.732031,12.43419" orientation="0,-1" id="guide843"/>
+ <sodipodi:guide position="8.6890316,12.859034" orientation="0,-1" id="guide845"/>
+ <sodipodi:guide position="7.2917227,11.980081" orientation="0,-1" id="guide847"/>
+ <sodipodi:guide position="7.0212757,10.49262" orientation="0,-1" id="guide849"/>
+ </sodipodi:namedview>
+</svg>
diff --git a/app/src/main/assets/ic_intensity_hollow.svg b/app/src/main/assets/ic_intensity_hollow.svg
new file mode 100644
index 0000000..0fa7d66
--- /dev/null
+++ b/app/src/main/assets/ic_intensity_hollow.svg
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ height="24"
+ viewBox="0 0 24 24"
+ width="24"
+ version="1.1"
+ id="svg6"
+ sodipodi:docname="ic_intensity_hollow.svg"
+ inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1042"
+ id="namedview8"
+ showgrid="false"
+ inkscape:zoom="32.125"
+ inkscape:cx="12"
+ inkscape:cy="12"
+ inkscape:window-x="0"
+ inkscape:window-y="38"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg6" />
+ <path
+ d="M0 0h24v24H0V0z"
+ fill="none"
+ id="path2" />
+ <path
+ d="m 13.5,0.67 c 0,0 0.74,2.65 0.74,4.8 0,2.06 -1.35,3.73 -3.41,3.73 C 8.76,9.2 7.2,7.53 7.2,5.47 L 7.23,5.11 C 5.21,7.51 4,10.62 4,14 c 0,4.42 3.58,8 8,8 4.42,0 8,-3.58 8,-8 C 20,8.61 17.41,3.8 13.5,0.67 Z M 12,20 C 8.69,20 6,17.31 6,14 6,12.47 6.3,10.96 6.86,9.57 c 1.01,1.01 2.41,1.63 3.97,1.63 2.66,0 4.75,-1.83 5.28,-4.43 1.23,2.2 1.89,4.67 1.89,7.23 0,3.31 -2.69,6 -6,6 z"
+ id="path4"
+ sodipodi:nodetypes="cssccssscsscscss" />
+</svg>
diff --git a/app/src/main/assets/ic_launcher.svg b/app/src/main/assets/ic_launcher.svg
new file mode 100644
index 0000000..248c776
--- /dev/null
+++ b/app/src/main/assets/ic_launcher.svg
@@ -0,0 +1,574 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="5.6444445mm"
+ height="5.6444445mm"
+ viewBox="-1 -1 22 22"
+ id="svg2"
+ version="1.1"
+ inkscape:version="0.92.1 r15371"
+ sodipodi:docname="template1.svg"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648">
+ <title
+ id="title4560">Gadgetbridge Icon</title>
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="11.2"
+ inkscape:cx="-40.722947"
+ inkscape:cy="18.815618"
+ inkscape:document-units="px"
+ inkscape:current-layer="layer1"
+ showgrid="false"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:window-width="1920"
+ inkscape:window-height="1017"
+ inkscape:window-x="-8"
+ inkscape:window-y="-8"
+ inkscape:window-maximized="1"
+ inkscape:snap-grids="true"
+ inkscape:snap-to-guides="false"
+ inkscape:snap-page="true">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4138" />
+ <sodipodi:guide
+ position="10,18"
+ orientation="0,1"
+ id="guide4140"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,2"
+ orientation="0,1"
+ id="guide4142"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="2,10"
+ orientation="1,0"
+ id="guide4144"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="0,0"
+ orientation="0,20"
+ id="guide4146"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="20,0"
+ orientation="-20,0"
+ id="guide4148"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="20,20"
+ orientation="0,-20"
+ id="guide4150"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="0,20"
+ orientation="20,0"
+ id="guide4152"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="17.934153,10.890707"
+ orientation="1,0"
+ id="guide4154"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="8.5170198,7.5753346"
+ orientation="-0.70710678,0.70710678"
+ id="guide4156"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="4,10"
+ orientation="1,0"
+ id="guide4160"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="16,10"
+ orientation="1,0"
+ id="guide4162"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,4"
+ orientation="0,1"
+ id="guide4166"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,15"
+ orientation="0,1"
+ id="guide4168"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,5"
+ orientation="0,1"
+ id="guide4170"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="5,10"
+ orientation="1,0"
+ id="guide4172"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="15,10"
+ orientation="1,0"
+ id="guide4174"
+ inkscape:locked="false" />
+ </sodipodi:namedview>
+ <defs
+ id="defs4">
+ <linearGradient
+ inkscape:collect="always"
+ id="linearGradient13455">
+ <stop
+ style="stop-color:#000000;stop-opacity:0.2"
+ offset="0"
+ id="stop13457" />
+ <stop
+ style="stop-color:#000000;stop-opacity:0;"
+ offset="1"
+ id="stop13459" />
+ </linearGradient>
+ <clipPath
+ id="clipPath12478"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle12480"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5790">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5792"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath5055"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5057"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5119">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5121"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath5194"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5196"
+ style="opacity:1;fill:#cddc39;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath6175">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle6177"
+ style="opacity:1;fill:#f44336;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath7176"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle7178"
+ style="opacity:1;fill:#9c27b0;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath8165">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle8167"
+ style="opacity:1;fill:#ffc107;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath9162"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle9164"
+ style="opacity:1;fill:#ff9800;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5147">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5149"
+ style="opacity:1;fill:#ff9800;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath6144"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle6146"
+ style="opacity:1;fill:#e91e63;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath7143">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle7145"
+ style="opacity:1;fill:#ffeb3b;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath8148"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle8150"
+ style="opacity:1;fill:#9c27b0;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5332">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5334"
+ style="opacity:1;fill:#f44336;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath6341"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle6343"
+ style="opacity:1;fill:#4caf50;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath7354">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle7356"
+ style="opacity:1;fill:#f44336;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath8344"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle8346"
+ style="opacity:1;fill:#673ab7;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath9436">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle9438"
+ style="opacity:1;fill:#9e9e9e;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath10432"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle10434"
+ style="opacity:1;fill:#795548;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath11433">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle11435"
+ style="opacity:1;fill:#03a9f4;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath12452"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle12454"
+ style="opacity:1;fill:#ffc107;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient13455"
+ id="linearGradient13461"
+ x1="9.9553576"
+ y1="9.9553566"
+ x2="20"
+ y2="20"
+ gradientUnits="userSpaceOnUse" />
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath13465">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle13467"
+ style="opacity:1;fill:#e91e63;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ </defs>
+ <metadata
+ id="metadata7">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title>Gadgetbridge Icon</dc:title>
+ <cc:license
+ rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/" />
+ <dc:date>18/07/2017</dc:date>
+ <dc:creator>
+ <cc:Agent>
+ <dc:title>xphnx</dc:title>
+ </cc:Agent>
+ </dc:creator>
+ <dc:rights>
+ <cc:Agent>
+ <dc:title>CC (BY-SA)</dc:title>
+ </cc:Agent>
+ </dc:rights>
+ <dc:description>Launcher icon for Gadgetbridge. Based on Blue Minimal Template (GPLv3)</dc:description>
+ <dc:source>https://github.com/scoute-dich/Blue-Minimal/tree/master/IconTemplates</dc:source>
+ <dc:contributor>
+ <cc:Agent>
+ <dc:title>xphnx based on a work of scoute-dich</dc:title>
+ </cc:Agent>
+ </dc:contributor>
+ </cc:Work>
+ <cc:License
+ rdf:about="http://creativecommons.org/licenses/by-sa/4.0/">
+ <cc:permits
+ rdf:resource="http://creativecommons.org/ns#Reproduction" />
+ <cc:permits
+ rdf:resource="http://creativecommons.org/ns#Distribution" />
+ <cc:requires
+ rdf:resource="http://creativecommons.org/ns#Notice" />
+ <cc:requires
+ rdf:resource="http://creativecommons.org/ns#Attribution" />
+ <cc:permits
+ rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
+ <cc:requires
+ rdf:resource="http://creativecommons.org/ns#ShareAlike" />
+ </cc:License>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:label="Hintergrund"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(1058.1428,-861.93365)">
+ <circle
+ inkscape:export-ydpi="648"
+ inkscape:export-xdpi="648"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ style="opacity:1;fill:#0091ea;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="path4136"
+ cx="-1048.1428"
+ cy="871.93365"
+ r="10" />
+ <path
+ style="fill:url(#linearGradient13461);fill-opacity:1;stroke-width:0.2;stroke-linejoin:round"
+ d="M 6.134855,7.8575793 C 5.5887444,7.7737975 8.9732,11.36764 8.9732,11.91964 c 0.7720204,2.953627 -0.3190754,2.368775 -1.3911929,4.54893 0,0.314113 2.8336269,3.63544 3.0589809,3.818814 0.04391,0.05518 0.09008,0.103734 0.144531,0.148437 0.04494,0.0568 0.09237,0.106597 0.148438,0.152344 0.04462,0.05589 0.09299,0.10335 0.148437,0.148437 0.0433,0.05399 0.08913,0.10065 0.142578,0.144532 0.04401,0.05535 0.08991,0.103622 0.144532,0.148437 0.04462,0.05589 0.09299,0.10335 0.148437,0.148438 0.04281,0.05303 0.08799,0.09936 0.140625,0.142578 0.0433,0.05399 0.08913,0.100649 0.142578,0.144531 0.0429,0.05343 0.08778,0.101002 0.140625,0.144531 0.04305,0.0535 0.08955,0.09904 0.142578,0.142578 0.04259,0.05283 0.08633,0.09947 0.138672,0.142579 0.04241,0.05266 0.08853,0.09762 0.140625,0.140624 0.04231,0.05246 0.08674,0.09775 0.138672,0.140626 0.04224,0.05233 0.08685,0.09785 0.138672,0.140624 0.04196,0.05198 0.0853,0.09806 0.136719,0.140626 0.04204,0.05191 0.218042,0.230956 0.269531,0.273437 0.04036,0.0496 -0.04904,-0.04097 0,0 0.04207,0.05223 0.08706,0.09788 0.138672,0.140625 0.04161,0.05109 1.939454,1.964844 0.136719,0.136719 0.0402,0.04908 0.673987,-1.491056 0.722706,-1.450484 0.0407,0.05002 0.08329,0.09352 0.132812,0.134766 0.03972,0.04878 -0.511067,1.675676 -0.46294,1.716108 0.04056,0.05008 0.08344,0.09344 0.132812,0.134766 0.04069,0.04997 0.08332,0.09357 0.132813,0.134766 0.03936,0.0479 0.08141,0.08913 0.128906,0.128906 0.03997,0.04887 0.0805,0.09236 0.128907,0.132812 0.0394,0.04798 0.208293,0.219929 0.255859,0.259766 0.03933,0.04803 -0.0475,-0.0399 0,0 0.03941,0.04797 0.08133,0.08909 0.128907,0.128906 0.03805,0.04609 0.07742,0.08645 0.123046,0.125 0.03972,0.04853 0.08085,0.09063 0.128907,0.13086 0.03812,0.04614 0.07734,0.08643 0.123047,0.125 0.03891,0.04709 0.08015,0.08776 0.126953,0.126953 0.03709,0.04468 0.0749,0.08353 0.11914,0.121094 0.03888,0.04724 0.07819,0.08957 0.125,0.128906 0.03698,0.0444 0.0751,0.08376 0.119141,0.121094 0.03744,0.0452 0.07636,0.08512 0.121094,0.123046 0.939453,0.953125 0.07678,0.08344 0.121094,0.121094 0.03702,0.04459 0.075,0.0836 0.11914,0.121094 0.03695,0.04448 0.0751,0.08368 0.119141,0.121094 0.03643,0.04385 0.07387,0.08215 0.117187,0.11914 0.03701,0.04454 0.07503,0.08364 0.119141,0.121094 0.03593,0.0429 0.07267,0.08091 0.115234,0.117188 0.03646,0.04374 0.07386,0.08225 0.117188,0.11914 0.03472,0.04132 0.07043,0.07812 0.111328,0.113281 0.03672,0.04408 0.07545,0.08202 0.119141,0.119141 0.159451,0.01975 0.495139,0.310768 0.33789,0.34375 0.03367,0.03982 0.06799,0.0753 0.107422,0.109375 0.03567,0.04259 0.07302,0.07917 0.115234,0.115234 0.03376,0.03984 0.06789,0.07531 0.107422,0.109375 0.03482,0.04131 0.07033,0.07814 0.111328,0.113282 0.03318,0.03905 0.06673,0.07392 0.105469,0.107422 1.997761,0.694544 9.103709,2.908451 10.44526,2.898372 h 0.002 c 0.275418,0 0.52417,-0.112641 0.705078,-0.292969 C 28.886751,29.72516 29,29.4758 29,29.199219 L 27.879654,21.07464 h -0.002 l -0.002,-0.002 -0.002,-0.002 v -0.002 l -0.0039,-0.002 -0.0039,-0.0059 -0.0039,-0.0039 -0.0059,-0.0059 -0.0059,-0.0059 -0.0078,-0.0078 -0.0078,-0.0078 -0.0078,-0.0078 -0.0098,-0.0098 -0.0098,-0.0098 -0.01172,-0.01172 -0.01172,-0.01172 -0.01172,-0.01172 -0.01367,-0.01367 -0.01367,-0.01367 -0.01367,-0.01563 -0.01562,-0.01563 -0.01563,-0.01563 -0.01758,-0.01563 -0.01758,-0.01953 -0.01758,-0.01758 -0.01953,-0.01953 -0.01953,-0.01953 -0.01953,-0.01953 v -0.002 l -0.02148,-0.02148 -0.02148,-0.02148 -0.02344,-0.02344 -0.02344,-0.02344 -0.02344,-0.02344 -0.02539,-0.02539 -0.02539,-0.02734 -0.02539,-0.02539 -0.02734,-0.02734 -0.02734,-0.0293 -0.0293,-0.0293 -0.0293,-0.0293 -0.03125,-0.0293 -0.0293,-0.03125 -0.03125,-0.0332 -0.0332,-0.0332 -0.0332,-0.0332 -0.0332,-0.0332 -0.03516,-0.03516 -0.03516,-0.03516 -0.03516,-0.03711 -0.03711,-0.03711 -0.03711,-0.03906 -0.03906,-0.03711 -0.03906,-0.04102 -0.03906,-0.03906 -0.04102,-0.04102 -0.04102,-0.04297 -0.04297,-0.04102 -0.04102,-0.04102 v -0.002 -0.002 l -0.04492,-0.04297 -0.04297,-0.04492 -0.04492,-0.04492 -0.04687,-0.04687 -0.04492,-0.04687 -0.04883,-0.04883 -0.04687,-0.04687 -0.04883,-0.05078 -0.04883,-0.04883 -0.05078,-0.05078 -0.05078,-0.05273 -0.05078,-0.05078 -0.05273,-0.05469 -0.05273,-0.05273 -0.05469,-0.05469 -0.05469,-0.05469 -0.05469,-0.05664 -0.05664,-0.05664 -0.05664,-0.05859 -0.05859,-0.05859 -0.05664,-0.05859 -0.06055,-0.05859 -0.05859,-0.06055 -0.06055,-0.0625 -0.0625,-0.0625 -0.06055,-0.0625 -0.0625,-0.0625 -0.06445,-0.06445 -0.06445,-0.06641 -0.06445,-0.06445 -0.06641,-0.06641 -0.06641,-0.06836 -0.06641,-0.06836 -0.06836,-0.06836 -0.06836,-0.07031 -0.07031,-0.07031 -0.07031,-0.07031 -0.07031,-0.07227 -0.07227,-0.07227 -0.07227,-0.07422 -0.07227,-0.07422 -0.07422,-0.07422 -0.07422,-0.07617 -0.07617,-0.07617 -0.07617,-0.07617 -0.07617,-0.07813 -0.07813,-0.07813 -0.07813,-0.08008 -0.07813,-0.08008 -0.08008,-0.08008 -0.08203,-0.08203 -0.08008,-0.08203 -0.08203,-0.08398 -0.08398,-0.08398 -0.08203,-0.08398 -0.08398,-0.08594 -0.08594,-0.08594 -0.08594,-0.08789 -0.08594,-0.08594 -0.08789,-0.08984 -0.08789,-0.08789 -0.08789,-0.08984 -0.08984,-0.0918 -0.08984,-0.0918 -0.0918,-0.0918 -0.0918,-0.0918 -0.0918,-0.09375 -0.09375,-0.0957 -0.09375,-0.0957 -0.09375,-0.0957 -0.0957,-0.0957 -0.09766,-0.09766 -0.0957,-0.09766 -0.09766,-0.09961 -0.09961,-0.09961 -0.09766,-0.09961 -0.09961,-0.101562 L 21.012211,14.117126 20.910649,14.01361 20.809087,13.910094 20.705571,13.806579 20.602055,13.70111 20.49854,13.595641 20.393071,13.488219 20.287602,13.380797 20.18018,13.273376 20.072758,13.164 19.965337,13.054626 19.855961,12.94525 19.851219,12.854848 19.741843,12.74352 19.630515,12.630239 19.519187,12.516958 19.405906,12.403676 19.292625,12.288442 19.179343,12.173208 19.064109,12.05602 18.948875,11.940786 18.831687,11.821645 18.7145,11.704458 18.597312,11.585317 18.480125,11.46813 v -0.0039 l -0.119141,-0.121093 -0.121094,-0.121094 -0.121093,-0.123047 -0.121094,-0.123047 -0.121094,-0.123047 -0.123047,-0.125 -0.125,-0.125 -0.123047,-0.125 -0.125,-0.126953 -0.126953,-0.128906 -0.126953,-0.126953 -0.126953,-0.1289072 -0.126953,-0.126953 v -0.0039 L 16.744052,9.7049408 16.613192,9.5740803 16.484286,9.4432213 16.353427,9.3104083 16.220614,9.1756433 16.087802,9.0408773 15.954989,8.9061124 15.820224,8.7713464 15.685458,8.6346274 15.550692,8.4998624 v -0.0039 l -0.136718,-0.136725 -0.136719,-0.138672 -0.136719,-0.136719 v -0.0039 L 15.001864,7.9393154 14.861239,7.7986902 14.722567,7.6580652 14.581942,7.5174402 v -0.00391 L 14.439364,7.3709558 14.296786,7.2264245 14.154208,7.0818933 v 0 L 14.007009,6.9923893 13.395553,6.3374386 12.445852,5.7116899 11.682337,4.9970274 11.461514,4.8293121 10.294776,3.8349929 C 9.7457728,3.3921062 9.0255387,4.1644713 9.0255387,4.1644713 L 7.8983224,5.0493128 C 7.7279474,5.1830537 9.7681952,6.5418811 9.4189571,6.5258336 9.1783033,6.5147756 7.9346571,5.7850447 7.4996252,5.5958611 6.9046896,5.3371397 6.6803727,7.9412701 6.134855,7.8575793 Z"
+ transform="translate(-1058.1428,861.93365)"
+ id="path12581"
+ clip-path="url(#clipPath13465)"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="sccccccccccccccccccccccccccccccccccccccccccccccccccccccccccsccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccssss" />
+ <path
+ inkscape:connector-curvature="0"
+ id="path12456"
+ d="m -1050.6874,868.40686 0.1339,3.79465 0.2678,0.89285 1.1161,0.75893 1.5179,0.0446 1.6071,-0.40178 0.5357,-1.25 -0.1339,-3.83929 -4.9107,0 z"
+ style="fill:none;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+ <g
+ style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-opacity:0.5098038"
+ id="g6427"
+ transform="matrix(0.10900039,0,0,0.10900039,-1058.4564,854.81407)">
+ <g
+ transform="translate(-0.19198408,15.28344)"
+ id="g5859"
+ style="fill:#ff5000;fill-opacity:1">
+ <g
+ id="g4591"
+ transform="matrix(0.96469159,0,0,0.96469159,3.3476544,3.8272566)">
+ <g
+ style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-opacity:0.5098038"
+ id="g4600"
+ transform="matrix(2.0186569,0,0,2.0186569,394.57822,-108.82609)">
+ <path
+ style="fill:#ffffff;fill-opacity:1;stroke-width:0.26458332"
+ d="m -136.77732,155.22239 -0.52821,-12.65678 c -0.18542,-8.88992 7.31203,-16.43236 9.31124,-23.07234 -2.12299,2.02696 -6.52318,11.59925 -11.09114,10.79579 -2.54776,-0.85577 -3.35187,-2.05496 -4.38456,-4.23069 -3.42275,0.6668 -6.14301,-1.35553 -7.13076,-3.79658 l 0.19658,9.86107 -4.46061,-12.79348 c -3.48621,-1.12814 -6.68377,-6.4807 -3.47994,-5.86765 l 5.75274,1.10079 c 3.93852,0.82896 6.15619,-4.4975 2.63121,-7.26229 -0.7499,-0.53152 -8.27096,-4.04173 -9.68588,-4.44078 -1.26974,-0.3581 -2.78089,0.14533 -3.75024,1.24936 l -5.85918,10.88112 c -0.60845,0.8393 -0.61164,1.71753 -0.25253,2.49043 l 10.72151,21.51532 c 0.34668,0.65416 0.17252,2.20422 0.17823,2.73721 l -1.28045,13.4895 z m 8.9707,-38.19102 c 1.9434,-2.20392 -1.65635,-8.33602 -3.1421,-6.55824 l -9.61026,12.58549 c -2.81396,3.5023 1.43178,6.96399 4.448,4.80543 0.26564,-0.24803 0.41253,-0.37935 0.60397,-0.63064 z m -4.69458,-9.5376 c -1.42924,-1.48182 -4.34931,-3.63245 -4.73609,-3.48403 l -10.71091,14.46057 c -2.88854,2.81736 0.36005,6.17635 3.51209,5.84536 0.43402,-0.12765 0.58795,-0.21146 0.85842,-0.46309 l 11.1656,-14.42916 c 0.4913,-0.6349 0.32485,-1.41907 -0.0891,-1.92965 z m -6.36258,-4.6579 c -0.10642,-0.74107 -4.82207,-4.542483 -5.18836,-4.06928 l -5.44048,7.02851 c 6.87574,7.48663 -1.14912,12.14096 -5.91293,9.41949 0.59683,1.91529 4.16535,3.90113 5.27266,2.44458 z m -6.5656,-5.245518 c -1.03574,-2.192098 -5.14677,-3.858126 -7.00157,-3.15132 l -5.88314,7.251058 7.38969,3.05534 z"
+ id="path4606"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccscccccccccccccccccccccscccsccscccccc" />
+ </g>
+ <rect
+ ry="6.5778751"
+ y="175.19803"
+ x="69.227318"
+ height="23.844799"
+ width="52.825401"
+ id="rect4610"
+ style="opacity:1;fill:#ff5000;fill-opacity:1;stroke:none;stroke-width:16.03646088;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.5098038" />
+ <rect
+ ry="4.0110912"
+ y="179.85034"
+ x="82.062675"
+ height="14.540206"
+ width="27.154684"
+ id="rect4610-3"
+ style="opacity:1;fill:#1359ff;fill-opacity:1;stroke:none;stroke-width:8.97837925;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.5098038" />
+ </g>
+ </g>
+ </g>
+ </g>
+ <g
+ style="display:inline"
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Icon">
+ <path
+ d="m 3.9999777,4.0333382 12.0000003,0 0,11.9333328 -12.0000003,0 z"
+ id="path4"
+ inkscape:connector-curvature="0"
+ style="fill:none" />
+ <path
+ style="fill:none"
+ inkscape:connector-curvature="0"
+ id="path9956"
+ d="m 18.999978,19.033339 12,0 0,11.933333 -12,0 z" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_launcher_neon.svg b/app/src/main/assets/ic_launcher_neon.svg
new file mode 100644
index 0000000..f86d8fc
--- /dev/null
+++ b/app/src/main/assets/ic_launcher_neon.svg
@@ -0,0 +1,751 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="5.6444445mm"
+ height="5.6444445mm"
+ viewBox="-1 -1 22 22"
+ id="svg2"
+ version="1.1"
+ inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
+ sodipodi:docname="ic_launcher_neon.svg"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <title
+ id="title4560">Gadgetbridge Icon</title>
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="40.651137"
+ inkscape:cx="12.22598"
+ inkscape:cy="11.352696"
+ inkscape:document-units="px"
+ inkscape:current-layer="layer1"
+ showgrid="false"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:snap-grids="true"
+ inkscape:snap-to-guides="false"
+ inkscape:snap-page="true"
+ inkscape:showpageshadow="2"
+ inkscape:pagecheckerboard="0"
+ inkscape:deskcolor="#d1d1d1">
+ <inkscape:grid
+ type="xygrid"
+ id="grid4138" />
+ <sodipodi:guide
+ position="10,18"
+ orientation="0,1"
+ id="guide4140"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,2"
+ orientation="0,1"
+ id="guide4142"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="2,10"
+ orientation="1,0"
+ id="guide4144"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="0,0"
+ orientation="0,20"
+ id="guide4146"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="20,0"
+ orientation="-20,0"
+ id="guide4148"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="20,20"
+ orientation="0,-20"
+ id="guide4150"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="0,20"
+ orientation="20,0"
+ id="guide4152"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="17.934153,10.890707"
+ orientation="1,0"
+ id="guide4154"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="8.5170198,7.5753346"
+ orientation="-0.70710678,0.70710678"
+ id="guide4156"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="4,10"
+ orientation="1,0"
+ id="guide4160"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="16,10"
+ orientation="1,0"
+ id="guide4162"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,4"
+ orientation="0,1"
+ id="guide4166"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,15"
+ orientation="0,1"
+ id="guide4168"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="10,5"
+ orientation="0,1"
+ id="guide4170"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="5,10"
+ orientation="1,0"
+ id="guide4172"
+ inkscape:locked="false" />
+ <sodipodi:guide
+ position="15,10"
+ orientation="1,0"
+ id="guide4174"
+ inkscape:locked="false" />
+ </sodipodi:namedview>
+ <defs
+ id="defs4">
+ <linearGradient
+ inkscape:collect="always"
+ id="linearGradient13455">
+ <stop
+ style="stop-color:#000000;stop-opacity:0.2"
+ offset="0"
+ id="stop13457" />
+ <stop
+ style="stop-color:#000000;stop-opacity:0;"
+ offset="1"
+ id="stop13459" />
+ </linearGradient>
+ <clipPath
+ id="clipPath12478"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle12480"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5790">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5792"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath5055"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5057"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5119">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5121"
+ style="opacity:1;fill:#2196f3;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath5194"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5196"
+ style="opacity:1;fill:#cddc39;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath6175">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle6177"
+ style="opacity:1;fill:#f44336;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath7176"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle7178"
+ style="opacity:1;fill:#9c27b0;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath8165">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle8167"
+ style="opacity:1;fill:#ffc107;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath9162"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle9164"
+ style="opacity:1;fill:#ff9800;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5147">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5149"
+ style="opacity:1;fill:#ff9800;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath6144"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle6146"
+ style="opacity:1;fill:#e91e63;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath7143">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle7145"
+ style="opacity:1;fill:#ffeb3b;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath8148"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle8150"
+ style="opacity:1;fill:#9c27b0;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath5332">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle5334"
+ style="opacity:1;fill:#f44336;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath6341"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle6343"
+ style="opacity:1;fill:#4caf50;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath7354">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle7356"
+ style="opacity:1;fill:#f44336;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath8344"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle8346"
+ style="opacity:1;fill:#673ab7;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath9436">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle9438"
+ style="opacity:1;fill:#9e9e9e;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath10432"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle10434"
+ style="opacity:1;fill:#795548;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath11433">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle11435"
+ style="opacity:1;fill:#03a9f4;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <clipPath
+ id="clipPath12452"
+ clipPathUnits="userSpaceOnUse">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle12454"
+ style="opacity:1;fill:#ffc107;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient13455"
+ id="linearGradient13461"
+ x1="9.9553576"
+ y1="9.9553566"
+ x2="20"
+ y2="20"
+ gradientUnits="userSpaceOnUse" />
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath13465">
+ <circle
+ r="10"
+ cy="10.000005"
+ cx="9.9999781"
+ id="circle13467"
+ style="opacity:1;fill:#e91e63;fill-opacity:1;stroke:none;stroke-width:0.05069799;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ inkscape:export-xdpi="648"
+ inkscape:export-ydpi="648" />
+ </clipPath>
+ <filter
+ inkscape:collect="always"
+ style="color-interpolation-filters:sRGB"
+ id="filter19962"
+ x="-0.29186323"
+ y="-0.20359246"
+ width="1.5848434"
+ height="1.4061885">
+ <feGaussianBlur
+ inkscape:collect="always"
+ stdDeviation="8.2518751"
+ id="feGaussianBlur19964" />
+ </filter>
+ <filter
+ inkscape:collect="always"
+ style="color-interpolation-filters:sRGB"
+ id="filter21418"
+ x="-0.14902796"
+ y="-0.10438991"
+ width="1.2987264"
+ height="1.208183">
+ <feGaussianBlur
+ inkscape:collect="always"
+ stdDeviation="4.0460295"
+ id="feGaussianBlur21420" />
+ </filter>
+ <filter
+ inkscape:collect="always"
+ style="color-interpolation-filters:sRGB"
+ id="filter25052"
+ x="-0.049952583"
+ y="-0.035255832"
+ width="1.1002407"
+ height="1.070215">
+ <feGaussianBlur
+ inkscape:collect="always"
+ stdDeviation="1.1619187"
+ id="feGaussianBlur25054" />
+ </filter>
+ <filter
+ inkscape:collect="always"
+ style="color-interpolation-filters:sRGB"
+ id="filter30363"
+ x="-0.26535899"
+ y="-0.58787221"
+ width="1.530718"
+ height="2.1757444">
+ <feGaussianBlur
+ inkscape:collect="always"
+ stdDeviation="4.4104817"
+ id="feGaussianBlur30365" />
+ </filter>
+ <filter
+ inkscape:collect="always"
+ style="color-interpolation-filters:sRGB"
+ id="filter31093"
+ x="-0.044116035"
+ y="-0.097733983"
+ width="1.0882321"
+ height="1.195468">
+ <feGaussianBlur
+ inkscape:collect="always"
+ stdDeviation="0.76670202"
+ id="feGaussianBlur31095" />
+ </filter>
+ <filter
+ inkscape:collect="always"
+ style="color-interpolation-filters:sRGB"
+ id="filter33109"
+ x="-0.52248213"
+ y="-0.97576567"
+ width="2.0449643"
+ height="2.9515313">
+ <feGaussianBlur
+ inkscape:collect="always"
+ stdDeviation="5.0943279"
+ id="feGaussianBlur33111" />
+ </filter>
+ <filter
+ inkscape:collect="always"
+ style="color-interpolation-filters:sRGB"
+ id="filter33113"
+ x="-0.084551713"
+ y="-0.15790523"
+ width="1.1691034"
+ height="1.3158105">
+ <feGaussianBlur
+ inkscape:collect="always"
+ stdDeviation="0.75233853"
+ id="feGaussianBlur33115" />
+ </filter>
+ </defs>
+ <metadata
+ id="metadata7">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title>Gadgetbridge Icon</dc:title>
+ <cc:license
+ rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/" />
+ <dc:date>18/07/2017</dc:date>
+ <dc:creator>
+ <cc:Agent>
+ <dc:title>xphnx</dc:title>
+ </cc:Agent>
+ </dc:creator>
+ <dc:rights>
+ <cc:Agent>
+ <dc:title>CC (BY-SA)</dc:title>
+ </cc:Agent>
+ </dc:rights>
+ <dc:description>Launcher icon for Gadgetbridge. Based on Blue Minimal Template (GPLv3)</dc:description>
+ <dc:source>https://github.com/scoute-dich/Blue-Minimal/tree/master/IconTemplates</dc:source>
+ <dc:contributor>
+ <cc:Agent>
+ <dc:title>xphnx based on a work of scoute-dich</dc:title>
+ </cc:Agent>
+ </dc:contributor>
+ </cc:Work>
+ <cc:License
+ rdf:about="http://creativecommons.org/licenses/by-sa/4.0/">
+ <cc:permits
+ rdf:resource="http://creativecommons.org/ns#Reproduction" />
+ <cc:permits
+ rdf:resource="http://creativecommons.org/ns#Distribution" />
+ <cc:requires
+ rdf:resource="http://creativecommons.org/ns#Notice" />
+ <cc:requires
+ rdf:resource="http://creativecommons.org/ns#Attribution" />
+ <cc:permits
+ rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
+ <cc:requires
+ rdf:resource="http://creativecommons.org/ns#ShareAlike" />
+ </cc:License>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:label="Hintergrund"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(1058.1428,-861.93365)"
+ style="display:inline">
+ <circle
+ inkscape:export-ydpi="648"
+ inkscape:export-xdpi="648"
+ inkscape:export-filename="/home/juergen/Schreibtisch/path4136.png"
+ style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.050698;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="path4136"
+ cx="-1048.1428"
+ cy="871.93365"
+ r="10" />
+ <path
+ style="fill:url(#linearGradient13461);fill-opacity:1;stroke-width:0.2;stroke-linejoin:round"
+ d="M 6.134855,7.8575793 C 5.5887444,7.7737975 8.9732,11.36764 8.9732,11.91964 c 0.7720204,2.953627 -0.3190754,2.368775 -1.3911929,4.54893 0,0.314113 2.8336269,3.63544 3.0589809,3.818814 0.04391,0.05518 0.09008,0.103734 0.144531,0.148437 0.04494,0.0568 0.09237,0.106597 0.148438,0.152344 0.04462,0.05589 0.09299,0.10335 0.148437,0.148437 0.0433,0.05399 0.08913,0.10065 0.142578,0.144532 0.04401,0.05535 0.08991,0.103622 0.144532,0.148437 0.04462,0.05589 0.09299,0.10335 0.148437,0.148438 0.04281,0.05303 0.08799,0.09936 0.140625,0.142578 0.0433,0.05399 0.08913,0.100649 0.142578,0.144531 0.0429,0.05343 0.08778,0.101002 0.140625,0.144531 0.04305,0.0535 0.08955,0.09904 0.142578,0.142578 0.04259,0.05283 0.08633,0.09947 0.138672,0.142579 0.04241,0.05266 0.08853,0.09762 0.140625,0.140624 0.04231,0.05246 0.08674,0.09775 0.138672,0.140626 0.04224,0.05233 0.08685,0.09785 0.138672,0.140624 0.04196,0.05198 0.0853,0.09806 0.136719,0.140626 0.04204,0.05191 0.218042,0.230956 0.269531,0.273437 0.04036,0.0496 -0.04904,-0.04097 0,0 0.04207,0.05223 0.08706,0.09788 0.138672,0.140625 0.04161,0.05109 1.939454,1.964844 0.136719,0.136719 0.0402,0.04908 0.673987,-1.491056 0.722706,-1.450484 0.0407,0.05002 0.08329,0.09352 0.132812,0.134766 0.03972,0.04878 -0.511067,1.675676 -0.46294,1.716108 0.04056,0.05008 0.08344,0.09344 0.132812,0.134766 0.04069,0.04997 0.08332,0.09357 0.132813,0.134766 0.03936,0.0479 0.08141,0.08913 0.128906,0.128906 0.03997,0.04887 0.0805,0.09236 0.128907,0.132812 0.0394,0.04798 0.208293,0.219929 0.255859,0.259766 0.03933,0.04803 -0.0475,-0.0399 0,0 0.03941,0.04797 0.08133,0.08909 0.128907,0.128906 0.03805,0.04609 0.07742,0.08645 0.123046,0.125 0.03972,0.04853 0.08085,0.09063 0.128907,0.13086 0.03812,0.04614 0.07734,0.08643 0.123047,0.125 0.03891,0.04709 0.08015,0.08776 0.126953,0.126953 0.03709,0.04468 0.0749,0.08353 0.11914,0.121094 0.03888,0.04724 0.07819,0.08957 0.125,0.128906 0.03698,0.0444 0.0751,0.08376 0.119141,0.121094 0.03744,0.0452 0.07636,0.08512 0.121094,0.123046 0.939453,0.953125 0.07678,0.08344 0.121094,0.121094 0.03702,0.04459 0.075,0.0836 0.11914,0.121094 0.03695,0.04448 0.0751,0.08368 0.119141,0.121094 0.03643,0.04385 0.07387,0.08215 0.117187,0.11914 0.03701,0.04454 0.07503,0.08364 0.119141,0.121094 0.03593,0.0429 0.07267,0.08091 0.115234,0.117188 0.03646,0.04374 0.07386,0.08225 0.117188,0.11914 0.03472,0.04132 0.07043,0.07812 0.111328,0.113281 0.03672,0.04408 0.07545,0.08202 0.119141,0.119141 0.159451,0.01975 0.495139,0.310768 0.33789,0.34375 0.03367,0.03982 0.06799,0.0753 0.107422,0.109375 0.03567,0.04259 0.07302,0.07917 0.115234,0.115234 0.03376,0.03984 0.06789,0.07531 0.107422,0.109375 0.03482,0.04131 0.07033,0.07814 0.111328,0.113282 0.03318,0.03905 0.06673,0.07392 0.105469,0.107422 1.997761,0.694544 9.103709,2.908451 10.44526,2.898372 h 0.002 c 0.275418,0 0.52417,-0.112641 0.705078,-0.292969 C 28.886751,29.72516 29,29.4758 29,29.199219 L 27.879654,21.07464 h -0.002 l -0.002,-0.002 -0.002,-0.002 v -0.002 l -0.0039,-0.002 -0.0039,-0.0059 -0.0039,-0.0039 -0.0059,-0.0059 -0.0059,-0.0059 -0.0078,-0.0078 -0.0078,-0.0078 -0.0078,-0.0078 -0.0098,-0.0098 -0.0098,-0.0098 -0.01172,-0.01172 -0.01172,-0.01172 -0.01172,-0.01172 -0.01367,-0.01367 -0.01367,-0.01367 -0.01367,-0.01563 -0.01562,-0.01563 -0.01563,-0.01563 -0.01758,-0.01563 -0.01758,-0.01953 -0.01758,-0.01758 -0.01953,-0.01953 -0.01953,-0.01953 -0.01953,-0.01953 v -0.002 l -0.02148,-0.02148 -0.02148,-0.02148 -0.02344,-0.02344 -0.02344,-0.02344 -0.02344,-0.02344 -0.02539,-0.02539 -0.02539,-0.02734 -0.02539,-0.02539 -0.02734,-0.02734 -0.02734,-0.0293 -0.0293,-0.0293 -0.0293,-0.0293 -0.03125,-0.0293 -0.0293,-0.03125 -0.03125,-0.0332 -0.0332,-0.0332 -0.0332,-0.0332 -0.0332,-0.0332 -0.03516,-0.03516 -0.03516,-0.03516 -0.03516,-0.03711 -0.03711,-0.03711 -0.03711,-0.03906 -0.03906,-0.03711 -0.03906,-0.04102 -0.03906,-0.03906 -0.04102,-0.04102 -0.04102,-0.04297 -0.04297,-0.04102 -0.04102,-0.04102 v -0.002 -0.002 l -0.04492,-0.04297 -0.04297,-0.04492 -0.04492,-0.04492 -0.04687,-0.04687 -0.04492,-0.04687 -0.04883,-0.04883 -0.04687,-0.04687 -0.04883,-0.05078 -0.04883,-0.04883 -0.05078,-0.05078 -0.05078,-0.05273 -0.05078,-0.05078 -0.05273,-0.05469 -0.05273,-0.05273 -0.05469,-0.05469 -0.05469,-0.05469 -0.05469,-0.05664 -0.05664,-0.05664 -0.05664,-0.05859 -0.05859,-0.05859 -0.05664,-0.05859 -0.06055,-0.05859 -0.05859,-0.06055 -0.06055,-0.0625 -0.0625,-0.0625 -0.06055,-0.0625 -0.0625,-0.0625 -0.06445,-0.06445 -0.06445,-0.06641 -0.06445,-0.06445 -0.06641,-0.06641 -0.06641,-0.06836 -0.06641,-0.06836 -0.06836,-0.06836 -0.06836,-0.07031 -0.07031,-0.07031 -0.07031,-0.07031 -0.07031,-0.07227 -0.07227,-0.07227 -0.07227,-0.07422 -0.07227,-0.07422 -0.07422,-0.07422 -0.07422,-0.07617 -0.07617,-0.07617 -0.07617,-0.07617 -0.07617,-0.07813 -0.07813,-0.07813 -0.07813,-0.08008 -0.07813,-0.08008 -0.08008,-0.08008 -0.08203,-0.08203 -0.08008,-0.08203 -0.08203,-0.08398 -0.08398,-0.08398 -0.08203,-0.08398 -0.08398,-0.08594 -0.08594,-0.08594 -0.08594,-0.08789 -0.08594,-0.08594 -0.08789,-0.08984 -0.08789,-0.08789 -0.08789,-0.08984 -0.08984,-0.0918 -0.08984,-0.0918 -0.0918,-0.0918 -0.0918,-0.0918 -0.0918,-0.09375 -0.09375,-0.0957 -0.09375,-0.0957 -0.09375,-0.0957 -0.0957,-0.0957 -0.09766,-0.09766 -0.0957,-0.09766 -0.09766,-0.09961 -0.09961,-0.09961 -0.09766,-0.09961 -0.09961,-0.101562 L 21.012211,14.117126 20.910649,14.01361 20.809087,13.910094 20.705571,13.806579 20.602055,13.70111 20.49854,13.595641 20.393071,13.488219 20.287602,13.380797 20.18018,13.273376 20.072758,13.164 19.965337,13.054626 19.855961,12.94525 19.851219,12.854848 19.741843,12.74352 19.630515,12.630239 19.519187,12.516958 19.405906,12.403676 19.292625,12.288442 19.179343,12.173208 19.064109,12.05602 18.948875,11.940786 18.831687,11.821645 18.7145,11.704458 18.597312,11.585317 18.480125,11.46813 v -0.0039 l -0.119141,-0.121093 -0.121094,-0.121094 -0.121093,-0.123047 -0.121094,-0.123047 -0.121094,-0.123047 -0.123047,-0.125 -0.125,-0.125 -0.123047,-0.125 -0.125,-0.126953 -0.126953,-0.128906 -0.126953,-0.126953 -0.126953,-0.1289072 -0.126953,-0.126953 v -0.0039 L 16.744052,9.7049408 16.613192,9.5740803 16.484286,9.4432213 16.353427,9.3104083 16.220614,9.1756433 16.087802,9.0408773 15.954989,8.9061124 15.820224,8.7713464 15.685458,8.6346274 15.550692,8.4998624 v -0.0039 l -0.136718,-0.136725 -0.136719,-0.138672 -0.136719,-0.136719 v -0.0039 L 15.001864,7.9393154 14.861239,7.7986902 14.722567,7.6580652 14.581942,7.5174402 v -0.00391 L 14.439364,7.3709558 14.296786,7.2264245 14.154208,7.0818933 v 0 L 14.007009,6.9923893 13.395553,6.3374386 12.445852,5.7116899 11.682337,4.9970274 11.461514,4.8293121 10.294776,3.8349929 C 9.7457728,3.3921062 9.0255387,4.1644713 9.0255387,4.1644713 L 7.8983224,5.0493128 C 7.7279474,5.1830537 9.7681952,6.5418811 9.4189571,6.5258336 9.1783033,6.5147756 7.9346571,5.7850447 7.4996252,5.5958611 6.9046896,5.3371397 6.6803727,7.9412701 6.134855,7.8575793 Z"
+ id="path12581"
+ clip-path="url(#clipPath13465)"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="sccccccccccccccccccccccccccccccccccccccccccccccccccccccccccsccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccssss"
+ transform="translate(-1058.1428,861.93365)" />
+ <path
+ inkscape:connector-curvature="0"
+ id="path12456"
+ d="m -1050.6874,868.40686 0.1339,3.79465 0.2678,0.89285 1.1161,0.75893 1.5179,0.0446 1.6071,-0.40178 0.5357,-1.25 -0.1339,-3.83929 h -4.9107 z"
+ style="fill:none;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
+ <g
+ style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-opacity:0.509804"
+ id="g6427"
+ transform="matrix(0.10900039,0,0,0.10900039,-1058.4564,854.81407)">
+ <g
+ transform="translate(-0.19198408,15.28344)"
+ id="g5859"
+ style="fill:#ff5000;fill-opacity:1">
+ <g
+ id="g4591"
+ transform="matrix(0.96469159,0,0,0.96469159,3.3476544,3.8272566)">
+ <g
+ style="display:none;fill:#ffffff;fill-opacity:1;stroke:none;stroke-opacity:0.509804"
+ id="g4600"
+ transform="matrix(2.0186569,0,0,2.0186569,394.57822,-108.82609)"
+ inkscape:label="hand"
+ sodipodi:insensitive="true">
+ <path
+ style="display:inline;fill:#ffffff;fill-opacity:1;stroke-width:0.264583"
+ d="m -136.77732,155.22241 -0.52821,-12.65678 c -0.18542,-8.88992 7.31203,-16.43236 9.31124,-23.07234 -2.12299,2.02696 -6.52318,11.59925 -11.09114,10.79579 -2.54776,-0.85577 -3.35187,-2.05496 -4.38456,-4.23069 -3.42275,0.6668 -6.14301,-1.35553 -7.13076,-3.79658 l 0.19658,9.86107 -4.46061,-12.79348 c -3.48621,-1.12814 -6.68377,-6.4807 -3.47994,-5.86765 l 5.75274,1.10079 c 3.93852,0.82896 6.15619,-4.49751 2.63121,-7.2623 -0.7499,-0.53152 -8.27096,-4.04174 -9.68588,-4.44079 -1.26974,-0.3581 -2.78089,0.14533 -3.75024,1.24936 l -5.85918,10.88114 c -0.60845,0.8393 -0.61164,1.71753 -0.25253,2.49043 l 10.72151,21.51532 c 0.34668,0.65416 0.17252,2.20422 0.17823,2.73721 l -1.28045,13.4895 z m 8.9707,-38.19102 c 1.9434,-2.20392 -1.65635,-8.33603 -3.1421,-6.55825 l -9.61026,12.5855 c -2.81396,3.5023 1.43178,6.96399 4.448,4.80543 0.26564,-0.24803 0.41253,-0.37935 0.60397,-0.63064 z m -4.69458,-9.53761 c -1.42924,-1.48182 -4.34931,-3.63246 -4.73609,-3.48404 l -10.71091,14.46059 c -2.88854,2.81736 0.36005,6.17635 3.51209,5.84536 0.43402,-0.12765 0.58795,-0.21146 0.85842,-0.46309 l 11.1656,-14.42917 c 0.4913,-0.6349 0.32485,-1.41907 -0.0891,-1.92965 z m -6.36258,-4.65791 c -0.10642,-0.74107 -4.82207,-4.542486 -5.18836,-4.069285 l -5.44048,7.028525 c 6.87574,7.48664 -1.14912,12.14097 -5.91293,9.4195 0.59683,1.91529 4.16535,3.90113 5.27266,2.44458 z m -6.5656,-5.245517 c -1.03574,-2.192099 -5.14677,-3.858127 -7.00157,-3.151321 l -5.88314,7.251058 7.38969,3.05534 z"
+ id="path4606"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccscccccccccccccccccccccscccsccscccccc"
+ inkscape:label="path4606" />
+ </g>
+ <g
+ id="g25785"
+ inkscape:label="g25785">
+ <path
+ d="m 118.564,204.31556 -1.05929,-25.33777 v -0.001 c -0.37115,-17.79617 14.66956,-32.89497 18.68001,-46.18729 v 6.2e-4 c -4.25923,4.05909 -13.08664,23.21997 -22.2511,21.61163 l -0.002,-6e-4 c -5.10987,-1.71303 -6.72288,-4.11372 -8.79531,-8.46888 v 2e-4 c -6.867333,1.33428 -12.32428,-2.71419 -14.30443,-7.60065 v 8e-4 l 0.39322,19.74023 v -10e-4 l -8.94779,-25.61044 -0.002,-3.5e-4 C 75.281593,130.20189 68.867683,119.48769 75.295643,120.7149 v 8e-5 l 11.539827,2.2036 0.002,2.6e-4 c 7.90054,1.65816 12.348357,-9.00414 5.27839,-14.53872 l -0.002,-6.5e-4 C 90.60517,107.3132 75.520333,100.28876 72.683523,99.490058 l -0.002,-4.4e-4 c -2.54791,-0.71603 -5.57732,0.29183 -7.52336,2.501542 v 8.4e-4 l -11.754509,21.78222 v 0.001 c -1.21979,1.6799 -1.22581,3.43764 -0.50557,4.98459 v 4.2e-4 l 21.508799,43.07133 v 8e-4 c 0.69616,1.31005 0.34708,4.41201 0.35711,5.47882 v 0.001 l -2.56798,27.00376 v 0 z m 14.99858,-63.71237 m 2.99931,-12.74259 c 0.65002,-0.73557 -0.55372,-2.78178 -1.05126,-2.18885 3.24808,-3.67727 -2.7686,-13.90579 -5.25231,-10.94017 l -3.21399,4.20012 -16.06789,20.99484 c -0.94093,1.16892 0.47948,2.32442 1.48661,1.60453 -4.7026,5.84238 2.39544,11.61648 7.4371,8.01565 0.0903,-0.0831 0.13843,-0.12712 0.20263,-0.211 0.44538,-0.4134 0.69014,-0.6326 1.00913,-1.0516 l 2.576,-3.40465 z m -7.84837,-15.9112 m -1.57088,-3.18224 c -0.47748,-0.49471 -1.45452,-1.21257 -1.58492,-1.16335 -2.38942,-2.47203 -7.27058,-6.05896 -7.91659,-5.81138 l -3.58312,4.8259 -17.905613,24.12289 c -0.965007,0.94032 0.12037,2.06133 1.17364,1.95118 -4.826997,4.69993 0.60388,10.30287 5.872243,9.75074 0.14645,-0.0426 0.19862,-0.0709 0.2889,-0.15494 0.72425,-0.21261 0.98104,-0.35254 1.43445,-0.77214 l 3.7336,-4.81539 18.66596,-24.07052 c 0.16452,-0.21208 0.11035,-0.47399 -0.0281,-0.6446 0.82055,-1.05905 0.54168,-2.36681 -0.15047,-3.21839 z m -10.63703,-7.77059 M 114.379,99.442448 c -0.0361,-0.24756 -1.61301,-1.51621 -1.73539,-1.35864 -0.18256,-1.23848 -8.06304,-7.577006 -8.67494,-6.787653 l -1.81965,2.345637 -9.09425,11.724788 c 2.29914,2.49832 -0.38319,4.05162 -1.97614,3.14398 11.49371,12.48897 -1.92197,20.25298 -9.8867,15.71301 0.19862,0.63943 1.39232,1.3024 1.76348,0.81682 0.9991,3.19482 6.96362,6.50662 8.81337,4.077 l 3.7697,-4.94693 z m -10.97809,-8.75087 m -2.19482,-1.750269 C 100.85902,88.209611 99.484747,87.65328 98.864817,87.888705 97.131437,84.232217 90.26009,81.453767 87.15846,82.632691 L 85.19235,85.0526 c -0.0762,0.09369 -9.836547,12.096118 -9.836547,12.096158 l 2.471677,1.01937 12.35437,5.096982 1.8377,-2.38719 z"
+ style="display:inline;mix-blend-mode:normal;fill:none;fill-opacity:0;stroke:#3a74ff;stroke-width:9.80725;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter19962)"
+ id="path14632" />
+ <path
+ d="m 118.4887,204.26369 -1.05929,-25.33776 v -0.001 c -0.37115,-17.79638 14.66955,-32.89538 18.68001,-46.18766 v 4e-4 c -4.25923,4.05887 -13.08664,23.22015 -22.2511,21.61182 v -6e-4 c -5.11188,-1.71304 -6.72489,-4.11373 -8.79532,-8.46888 h -0.002 c -6.865323,1.33447 -12.32228,-2.71399 -14.30444,-7.60045 v 6e-4 l 0.39323,19.74043 v -8e-4 L 82.202,132.40916 82.2,132.40876 C 75.206283,130.1498 68.792363,119.43538 75.220333,120.66255 v 2e-4 l 11.541837,2.20351 v 2e-4 c 7.90054,1.65838 12.350357,-9.00399 5.27839,-14.53867 l -0.002,-4e-4 c -1.50869,-1.06582 -16.591527,-8.09092 -19.430347,-8.889682 l -0.002,-2e-4 c -2.54591,-0.71628 -5.57732,0.29148 -7.52136,2.501392 l -0.002,6e-4 -11.754499,21.78237 v 8e-4 c -1.21979,1.68001 -1.22581,3.43788 -0.50557,4.98496 v 2e-4 l 21.508799,43.07148 0.002,6e-4 c 0.69416,1.30985 0.34507,4.41221 0.35711,5.47903 v 8e-4 l -2.56998,27.00395 v 0 z m 11.99926,-50.97001 m 5.99863,-25.48491 c 1.30004,-1.471 -1.10744,-5.56311 -2.10052,-4.37697 2.59807,-2.94181 -2.21689,-11.12461 -4.20306,-8.75215 l -6.42796,8.39921 -12.85191,16.79582 c -1.88385,2.33743 0.95697,4.64763 2.97323,3.20766 -3.76168,4.67386 1.91595,9.29306 5.95048,6.41232 0.17655,-0.16556 0.27486,-0.25344 0.40326,-0.4212 0.3551,-0.33072 0.55171,-0.50589 0.80851,-0.84121 l 5.14999,-6.80849 z m -6.2795,-12.72894 m -3.13975,-6.36447 c -0.95497,-0.98914 -2.90904,-2.42451 -3.16784,-2.32582 -1.91194,-1.97748 -5.81606,-4.84702 -6.33367,-4.64903 l -7.16224,9.6506 -14.326493,19.29821 c -1.932007,1.88019 0.24075,4.12193 2.34929,3.90132 -3.861997,3.75999 0.4815,8.24226 4.696593,7.80064 0.2909,-0.0853 0.39523,-0.14133 0.57579,-0.30929 0.5798,-0.17037 0.78644,-0.28227 1.14756,-0.61779 l 7.46719,-9.62959 14.93237,-19.25637 c 0.32902,-0.4238 0.21868,-0.9473 -0.0582,-1.28823 0.65604,-0.8472 0.43335,-1.8934 -0.12037,-2.57465 z m -8.50842,-6.21653 m -4.25522,-3.108362 c -0.0722,-0.49468 -3.22602,-3.0317 -3.47078,-2.716 -0.14445,-0.99094 -6.44802,-6.061578 -6.93955,-5.430176 l -3.63729,4.690476 -7.27661,9.379952 c 4.598287,4.99616 -0.76839,8.10232 -3.95428,6.28659 9.194557,9.99113 -1.53677,16.20226 -7.90856,12.57039 0.39924,1.27842 2.78665,2.60389 3.52695,1.63216 0.79848,2.55584 5.5713,5.20536 7.0519,3.26171 l 7.535407,-9.89264 z m -8.78127,-7.000683 m -4.39164,-3.500279 C 100.43864,87.42635 97.688097,86.314249 96.448247,86.785539 95.06194,83.860489 89.56486,81.637649 87.08516,82.580848 l -3.93622,4.839096 c -0.0602,0.07501 -7.866427,9.676934 -7.866427,9.676934 l 4.941347,2.03874 9.88269,4.077492 3.67541,-4.774352 z"
+ style="fill:none;fill-opacity:0;stroke:#6190ff;stroke-width:5.88435;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter21418)"
+ id="path14634" />
+ <path
+ d="m 118.40878,204.21796 -1.05929,-25.33776 v -8e-4 c -0.37116,-17.79658 14.66955,-32.89558 18.68001,-46.18807 v 4e-4 c -4.25923,4.05867 -13.08664,23.22036 -22.2511,21.61183 v -2e-4 c -5.11188,-1.71324 -6.72489,-4.11393 -8.79532,-8.46928 l -0.002,2e-4 c -6.865323,1.33447 -12.32228,-2.714 -14.30444,-7.60046 v 4e-4 l 0.39523,19.74044 v -6e-4 l -8.94979,-25.61064 v -2e-4 c -6.993717,-2.25895 -13.409647,-12.97357 -6.981677,-11.7464 v 0 l 11.541837,2.2037 v 2e-4 c 7.90053,1.65858 12.350357,-9.00399 5.27839,-14.53867 v -4e-4 c -1.50868,-1.06542 -16.593527,-8.09092 -19.432347,-8.889682 v -2e-4 c -2.54791,-0.71648 -5.57932,0.29148 -7.52336,2.501392 v 4e-4 l -11.754499,21.78257 -0.002,6e-4 c -1.21979,1.68001 -1.22581,3.43788 -0.50557,4.98496 v 4e-4 l 21.510799,43.07149 v 4e-4 c 0.69416,1.30985 0.34507,4.41221 0.35711,5.47923 v 6e-4 l -2.56998,27.00415 h 0.002 z m 8.99995,-38.22745 m 8.99794,-38.22768 c 1.95005,-2.2061 -1.66116,-8.34416 -3.15179,-6.56486 1.95006,-2.2063 -1.66116,-8.34336 -3.15179,-6.56406 l -9.63993,12.59802 -9.63994,12.59701 c -2.82277,3.50595 1.43646,6.97106 4.46186,4.8106 -2.82277,3.50554 1.43646,6.96985 4.46185,4.80939 0.26683,-0.24824 0.41329,-0.37997 0.60588,-0.63161 0.26683,-0.24803 0.41329,-0.37936 0.60588,-0.6308 l 7.72399,-10.21235 z m -4.70862,-9.5467 m -4.70863,-9.54671 c -1.43445,-1.48341 -4.36355,-3.63627 -4.75276,-3.48793 -1.43244,-1.48321 -4.36154,-3.63526 -4.75075,-3.48672 l -10.74336,14.475 -10.743373,14.47381 c -2.899007,2.82029 0.35912,6.1825 3.52094,5.85138 -2.89499,2.82009 0.36312,6.1817 3.524943,5.85038 0.43536,-0.12772 0.58984,-0.2116 0.86068,-0.46364 0.43535,-0.12772 0.58983,-0.2116 0.86067,-0.46324 l 11.20078,-14.44378 11.20079,-14.44217 c 0.49152,-0.63561 0.32501,-1.42076 -0.0903,-1.93205 0.49353,-0.6354 0.32701,-1.41995 -0.0883,-1.93104 z m -6.38382,-4.66225 m -6.38182,-4.662442 c -0.10633,-0.74191 -4.83702,-4.54714 -5.20416,-4.07369 -0.11034,-0.743112 -4.83702,-4.546138 -5.20416,-4.072686 l -5.458963,7.035716 -5.456947,7.034912 c 6.897427,7.49395 -1.15158,12.15299 -5.93042,9.42919 6.89541,7.49335 -1.15358,12.15179 -5.93242,9.42779 0.59986,1.91723 4.17897,3.90533 5.29043,2.44754 0.59785,1.91683 4.17697,3.90392 5.28842,2.44613 l 11.30512,-14.83795 z m -6.58645,-5.250413 m -6.58646,-5.250609 c -1.03923,-2.194494 -5.162033,-3.862284 -7.02382,-3.15521 -1.03923,-2.193692 -5.16203,-3.860883 -7.02181,-3.153408 l -5.90233,7.258323 c -0.0461,0.05625 -5.900317,7.257727 -5.900317,7.257727 l 7.413017,3.058122 7.41302,3.05791 5.51112,-7.161632 z"
+ style="fill:none;fill-opacity:0;stroke:#89acff;stroke-width:2.94218;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter25052)"
+ id="path14636" />
+ <path
+ d="m 120.08988,205.06635 -1.05929,-25.33817 v 0 c -0.37316,-17.79698 14.66956,-32.89538 18.68001,-46.18806 v 0 c -4.25923,4.05787 -13.08664,23.22015 -22.2511,21.61262 v -0.002 c -5.11188,-1.71164 -6.72488,-4.11193 -8.79732,-8.46808 v 0 c -6.867327,1.33527 -12.32428,-2.71459 -14.30644,-7.60125 v 0 l 0.39523,19.74083 v 0 l -8.9498,-25.61043 v 0 C 76.807463,130.95285 70.393543,120.23763 76.821503,121.465 v 0 l 11.539837,2.20371 v 0 c 7.90254,1.65918 12.35036,-9.00379 5.27839,-14.53847 v -2e-4 c -1.50467,-1.06442 -16.593527,-8.09112 -19.430337,-8.88988 l -0.002,-2e-4 c -2.54591,-0.716691 -5.57732,0.29128 -7.52336,2.50118 v 2e-4 l -11.754509,21.78298 v 2e-4 c -1.21979,1.68 -1.2258,3.43808 -0.50557,4.98536 v 0 l 21.508799,43.07148 v 0 c 0.69616,1.30925 0.34708,4.41221 0.35711,5.47923 v 0 l -2.56798,27.00576 v 0 z m 2.99931,-12.74216 m 14.99858,-63.71317 c 3.24809,-3.67671 -2.7706,-13.90666 -5.25431,-10.94104 0.65001,-0.7353 -0.55372,-2.78105 -1.04926,-2.18788 l -16.0679,20.99682 -3.21398,4.19801 c -4.70461,5.84357 2.39343,11.61708 7.43709,8.01765 -0.94092,1.16711 0.47949,2.32221 1.48662,1.60152 0.44538,-0.41239 0.69014,-0.6326 1.01114,-1.051 0.0883,-0.0821 0.13642,-0.12612 0.20062,-0.2102 l 12.87398,-17.02023 z m -1.57088,-3.18224 m -7.84837,-15.91117 c -2.38942,-2.47216 -7.27058,-6.05998 -7.91859,-5.81255 -0.47748,-0.49427 -1.45452,-1.21175 -1.58292,-1.1621 l -17.90761,24.12421 -3.581123,4.8246 c -4.829007,4.70088 0.60187,10.30423 5.872243,9.7517 -0.96701,0.9409 0.12037,2.05996 1.17364,1.94986 0.72626,-0.2122 0.98305,-0.35234 1.43646,-0.77274 0.14445,-0.042 0.19461,-0.0701 0.28689,-0.15415 l 18.66597,-24.07176 3.7336,-4.81399 c 0.82255,-1.05941 0.54368,-2.36766 -0.14847,-3.21947 0.16452,-0.2118 0.10834,-0.47325 -0.0301,-0.64361 z m -2.1266,-1.55408 m -10.63904,-7.77062 c -0.17655,-1.236385 -8.06104,-7.578263 -8.67294,-6.788915 -0.0361,-0.24764 -1.61301,-1.515248 -1.73539,-1.357498 l -9.09626,11.725633 -1.81764,2.34483 c 11.4937,12.48972 -1.92197,20.25452 -9.8867,15.71439 2.29914,2.49779 -0.38319,4.05067 -1.97614,3.1426 0.9971,3.19545 6.96363,6.50841 8.81538,4.07849 0.19861,0.63901 1.39232,1.30124 1.76147,0.81538 l 18.84052,-24.72939 z m -2.19482,-1.750285 m -10.97609,-8.750758 c -1.73137,-3.65709 -8.60473,-6.436541 -11.70636,-5.25762 -0.34708,-0.73109 -1.72135,-1.286831 -2.34127,-1.051001 L 78.8498,95.479995 c -0.0161,0.0186 -1.968107,2.41932 -1.968107,2.41932 l 12.354357,5.096875 2.47168,1.01897 9.18654,-11.936033 z"
+ style="display:none;opacity:0.425;fill:none;fill-opacity:0;stroke:#000000;stroke-width:0.980725;stroke-dasharray:none;stroke-opacity:1"
+ id="path25896"
+ inkscape:label="shadow" />
+ <path
+ d="m 118.23062,204.15112 -1.05929,-25.33817 v 0 c -0.37316,-17.79698 14.66956,-32.89538 18.68001,-46.18806 v 0 c -4.25923,4.05787 -13.08664,23.22015 -22.2511,21.61262 v -0.002 c -5.11188,-1.71164 -6.72488,-4.11193 -8.79732,-8.46808 v 0 c -6.867333,1.33527 -12.32428,-2.71459 -14.30644,-7.60125 v 0 l 0.39523,19.74083 v 0 l -8.9498,-25.61043 v 0 C 74.948203,130.03762 68.534283,119.3224 74.962243,120.54977 v 0 l 11.539837,2.20371 v 0 c 7.90254,1.65918 12.350357,-9.00379 5.27839,-14.53847 v -2e-4 C 90.2758,107.15039 75.186943,100.12369 72.350133,99.32492 l -0.002,-2e-4 c -2.54591,-0.71669 -5.57732,0.29128 -7.52336,2.50119 v 2e-4 l -11.754509,21.78298 v 2e-4 c -1.21979,1.68 -1.2258,3.43808 -0.50557,4.98536 v 0 l 21.508799,43.07148 v 0 c 0.69616,1.30925 0.34708,4.41221 0.35711,5.47923 v 0 l -2.56798,27.00576 v 0 z m 2.99931,-12.74216 m 14.99858,-63.71317 c 3.24809,-3.67671 -2.7706,-13.90666 -5.25431,-10.94104 0.65001,-0.7353 -0.55372,-2.78105 -1.04926,-2.18788 l -16.0679,20.99682 -3.21398,4.19801 c -4.70461,5.84357 2.39343,11.61708 7.43709,8.01765 -0.94092,1.16711 0.47949,2.32221 1.48662,1.60152 0.44538,-0.41239 0.69014,-0.6326 1.01114,-1.051 0.0883,-0.0821 0.13642,-0.12612 0.20062,-0.2102 l 12.87398,-17.02023 z m -1.57088,-3.18224 m -7.84837,-15.91117 c -2.38942,-2.47216 -7.27058,-6.05998 -7.91859,-5.81255 -0.47748,-0.49427 -1.45452,-1.21175 -1.58292,-1.1621 l -17.907613,24.12421 -3.58112,4.8246 c -4.829007,4.70088 0.60187,10.30423 5.872243,9.7517 -0.96701,0.9409 0.12037,2.05996 1.17364,1.94986 0.72626,-0.2122 0.98305,-0.35234 1.43646,-0.77274 0.14445,-0.042 0.19461,-0.0701 0.28689,-0.15415 l 18.66597,-24.07176 3.7336,-4.81399 c 0.82255,-1.05941 0.54368,-2.36766 -0.14847,-3.21947 0.16452,-0.2118 0.10834,-0.47325 -0.0301,-0.64361 z m -2.1266,-1.55408 m -10.63904,-7.77063 c -0.17655,-1.23638 -8.06104,-7.578258 -8.67294,-6.78891 -0.0361,-0.24764 -1.61301,-1.515248 -1.73539,-1.357498 l -9.09626,11.725638 -1.81764,2.34483 c 11.4937,12.48972 -1.92197,20.25452 -9.8867,15.71439 2.29914,2.49779 -0.38319,4.05067 -1.97614,3.1426 0.9971,3.19545 6.96363,6.50841 8.81538,4.07849 0.19861,0.63901 1.39232,1.30124 1.76147,0.81538 L 110.27592,104.2232 Z M 111.8488,97.52739 M 100.87271,88.776632 C 99.141337,85.119542 92.26798,82.340091 89.16635,83.519012 88.81927,82.787922 87.445,82.232181 86.82508,82.468011 L 76.99054,94.56476 c -0.0161,0.0186 -1.968107,2.41932 -1.968107,2.41932 l 12.354357,5.09688 2.47168,1.01897 9.186537,-11.936038 z"
+ style="display:none;fill:none;fill-opacity:0;stroke:#ffffff;stroke-width:0.980725;stroke-dasharray:none;stroke-opacity:1"
+ id="path14640" />
+ </g>
+ <path
+ style="display:inline;opacity:0.42;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.980725;stroke-dasharray:none;stroke-opacity:1"
+ d="m 120.40041,206.54569 -1.06605,-25.54964 c -0.37087,-17.94578 14.76008,-33.1713 18.79506,-46.57516 -4.28509,4.0917 -13.16718,23.41492 -22.38783,21.79298 -5.14287,-1.72751 -6.76618,-4.14829 -8.85072,-8.54033 -6.908843,1.34606 -12.400778,-2.73633 -14.394039,-7.66397 l 0.399413,19.90609 -9.003919,-25.82563 C 76.85504,131.81275 70.399755,121.0077 76.867402,122.24525 l 11.612473,2.22212 c 7.950225,1.67339 12.427465,-9.07888 5.311244,-14.66005 -1.513965,-1.07292 -16.695481,-8.15886 -19.551288,-8.96437 -2.563854,-0.72276 -5.613661,0.29291 -7.570787,2.52198 l -11.827388,21.96529 c -1.227721,1.69422 -1.234377,3.46708 -0.513531,5.0273 l 21.6425,43.43199 c 0.703728,1.32057 0.351863,4.44957 0.361373,5.52554 l -2.584776,27.23064 z m 18.10845,-77.09457 c 3.92278,-4.4489 -3.34362,-16.82758 -6.343,-13.23877 l -19.39892,25.40576 c -5.68017,7.06997 2.89001,14.05787 8.97909,9.70045 0.53255,-0.50023 0.83687,-0.76556 1.21916,-1.27302 z m -9.47645,-19.25313 c -2.88527,-2.99129 -8.77941,-7.33262 -9.56015,-7.03305 l -21.621391,29.19093 c -5.830482,5.68731 0.732258,12.46789 7.089551,11.79981 0.87489,-0.25773 1.18681,-0.42701 1.73267,-0.93484 l 22.53905,-29.12752 c 0.99187,-1.28167 0.65617,-2.86462 -0.18069,-3.89533 z m -12.84387,-9.40269 c -0.21873,-1.495929 -9.73322,-9.169699 -10.47307,-8.214509 L 94.733545,106.76895 c 13.879545,15.11292 -2.31945,24.50848 -11.935803,19.01471 1.204897,3.86632 8.407652,7.87509 10.643416,4.93477 z M 102.93577,90.206411 c -2.0912,-4.425124 -10.389488,-7.788263 -14.133511,-6.361468 l -11.875896,14.637418 14.917141,6.167659 z"
+ id="path39861"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccscccccccccccccccccccccscccsccscccccc"
+ inkscape:label="hand shadow" />
+ <path
+ style="display:inline;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.98072536;stroke-dasharray:none;stroke-opacity:1"
+ d="m 118.27655,204.80468 -1.06608,-25.54964 c -0.37089,-17.94578 14.76057,-33.1713 18.79569,-46.57516 -4.28523,4.0917 -13.16763,23.41492 -22.38859,21.79298 -5.14304,-1.72751 -6.76641,-4.14829 -8.85102,-8.54033 -6.90906,1.34606 -12.401122,-2.73633 -14.394431,-7.66397 l 0.399422,19.90609 -9.004129,-25.82563 c -7.037447,-2.27728 -13.492879,-13.08233 -7.025084,-11.84478 l 11.612739,2.22212 c 7.950414,1.67339 12.427753,-9.07888 5.311371,-14.66005 -1.514002,-1.07292 -16.695869,-8.158864 -19.551741,-8.964364 -2.563913,-0.72277 -5.613791,0.29291 -7.570962,2.521974 l -11.827667,21.96529 c -1.22775,1.69422 -1.234407,3.46708 -0.513544,5.0273 l 21.643005,43.43199 c 0.703745,1.32057 0.351872,4.44957 0.361382,5.52554 l -2.584835,27.23064 z m 18.10906,-77.09457 c 3.92291,-4.4489 -3.34373,-16.82758 -6.34321,-13.23877 l -19.39958,25.40576 c -5.68036,7.06997 2.89011,14.05787 8.9794,9.70045 0.53257,-0.50023 0.83689,-0.76556 1.21919,-1.27302 z m -9.47677,-19.25313 c -2.88536,-2.99129 -8.7797,-7.33262 -9.56047,-7.03305 l -21.622085,29.19093 c -5.830621,5.68731 0.732275,12.46789 7.089755,11.79981 0.87492,-0.25773 1.18685,-0.42701 1.73273,-0.93484 l 22.53981,-29.12752 c 0.9919,-1.28167 0.65619,-2.86462 -0.1807,-3.89533 z m -12.8443,-9.402684 c -0.21873,-1.49593 -9.73355,-9.169696 -10.47343,-8.214505 L 92.608886,105.02794 c 13.879944,15.11292 -2.319505,24.50848 -11.936082,19.01471 1.204925,3.86632 8.407848,7.87509 10.643664,4.93477 z M 100.81132,88.465414 C 98.720053,84.040285 90.421571,80.677146 86.677459,82.103941 L 74.80129,96.741357 89.718777,102.90901 Z"
+ id="path4606-3"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccscccccccccccccccccccccscccsccscccccc"
+ inkscape:label="hand white" />
+ <g
+ id="g31229">
+ <path
+ id="path27449"
+ style="display:none;opacity:1;fill:#ff5000;fill-opacity:1;stroke:#ffffff;stroke-width:0.980725;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:label="band"
+ d="m 75.805193,175.19803 h 39.669647 c 3.64415,0 6.57788,2.93373 6.57788,6.57787 v 10.68905 c 0,3.64415 -2.93373,6.57788 -6.57788,6.57788 H 75.805193 c -3.644143,0 -6.577875,-2.93373 -6.577875,-6.57788 V 181.7759 c 0,-3.64414 2.933732,-6.57787 6.577875,-6.57787 z" />
+ <path
+ id="path30353"
+ style="display:inline;opacity:1;fill:#ff5000;fill-opacity:1;stroke:#ff5000;stroke-width:6.8650775;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter30363)"
+ inkscape:label="band"
+ d="m 75.805193,175.19803 h 39.669647 c 3.64415,0 6.57788,2.93373 6.57788,6.57787 v 10.68905 c 0,3.64415 -2.93373,6.57788 -6.57788,6.57788 H 75.805193 c -3.644143,0 -6.577875,-2.93373 -6.577875,-6.57788 V 181.7759 c 0,-3.64414 2.933732,-6.57787 6.577875,-6.57787 z" />
+ <path
+ id="path30355"
+ style="display:inline;opacity:1;fill:#ff5000;fill-opacity:1;stroke:#ff5000;stroke-width:0.980725;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter31093)"
+ inkscape:label="band"
+ d="m 75.805193,175.19803 h 39.669647 c 3.64415,0 6.57788,2.93373 6.57788,6.57787 v 10.68905 c 0,3.64415 -2.93373,6.57788 -6.57788,6.57788 H 75.805193 c -3.644143,0 -6.577875,-2.93373 -6.577875,-6.57788 V 181.7759 c 0,-3.64414 2.933732,-6.57787 6.577875,-6.57787 z" />
+ <path
+ id="path31097"
+ style="display:inline;opacity:0.513416;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.78458;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:label="band shadow"
+ d="m 77.193928,176.53331 h 39.669642 c 3.64415,0 6.57788,2.93373 6.57788,6.57787 v 10.68905 c 0,3.64415 -2.93373,6.57788 -6.57788,6.57788 H 77.193928 c -3.644143,0 -6.577875,-2.93373 -6.577875,-6.57788 v -10.68905 c 0,-3.64414 2.933732,-6.57787 6.577875,-6.57787 z" />
+ <path
+ id="path30360"
+ style="display:inline;opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.78458;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:label="band"
+ d="m 75.805193,175.19803 h 39.669647 c 3.64415,0 6.57788,2.93373 6.57788,6.57787 v 10.68905 c 0,3.64415 -2.93373,6.57788 -6.57788,6.57788 H 75.805193 c -3.644143,0 -6.577875,-2.93373 -6.577875,-6.57788 V 181.7759 c 0,-3.64414 2.933732,-6.57787 6.577875,-6.57787 z" />
+ </g>
+ <g
+ id="g31222">
+ <path
+ id="rect4610-3"
+ style="display:none;opacity:1;fill:#1359ff;fill-opacity:1;stroke:none;stroke-width:8.97838;stroke-opacity:0.509804"
+ inkscape:label="watch"
+ d="m 86.073767,179.85034 h 19.132503 c 2.22214,0 4.01109,1.78895 4.01109,4.01109 v 6.51803 c 0,2.22214 -1.78895,4.01109 -4.01109,4.01109 H 86.073767 c -2.222145,0 -4.011092,-1.78895 -4.011092,-4.01109 v -6.51803 c 0,-2.22214 1.788947,-4.01109 4.011092,-4.01109 z" />
+ <path
+ id="path31154"
+ style="display:inline;opacity:1;fill:#1359ff;fill-opacity:1;stroke:#1359ff;stroke-width:3.92290143;stroke-opacity:1;stroke-dasharray:none;filter:url(#filter33109)"
+ inkscape:label="watch"
+ d="m 86.073767,179.85034 h 19.132503 c 2.22214,0 4.01109,1.78895 4.01109,4.01109 v 6.51803 c 0,2.22214 -1.78895,4.01109 -4.01109,4.01109 H 86.073767 c -2.222145,0 -4.011092,-1.78895 -4.011092,-4.01109 v -6.51803 c 0,-2.22214 1.788947,-4.01109 4.011092,-4.01109 z" />
+ <path
+ id="path31156"
+ style="display:inline;opacity:1;fill:none;fill-opacity:1;stroke:#1359ff;stroke-width:0.98072536;stroke-opacity:1;stroke-dasharray:none;filter:url(#filter33113)"
+ inkscape:label="watch"
+ d="m 86.073767,179.85034 h 19.132503 c 2.22214,0 4.01109,1.78895 4.01109,4.01109 v 6.51803 c 0,2.22214 -1.78895,4.01109 -4.01109,4.01109 H 86.073767 c -2.222145,0 -4.011092,-1.78895 -4.011092,-4.01109 v -6.51803 c 0,-2.22214 1.788947,-4.01109 4.011092,-4.01109 z" />
+ <path
+ style="display:inline;opacity:0.42;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.686508;stroke-dasharray:none;stroke-opacity:1"
+ d="m 87.439354,181.05234 h 19.132496 c 2.22214,0 4.01109,1.78895 4.01109,4.01109 v 6.51803 c 0,2.22214 -1.78895,4.01109 -4.01109,4.01109 H 87.439354 c -2.222145,0 -4.011092,-1.78895 -4.011092,-4.01109 v -6.51803 c 0,-2.22214 1.788947,-4.01109 4.011092,-4.01109 z"
+ id="path31160"
+ inkscape:label="watch shadow" />
+ <path
+ id="path31214"
+ style="display:inline;opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.686508;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:label="watch"
+ d="m 86.073767,179.85034 h 19.132503 c 2.22214,0 4.01109,1.78895 4.01109,4.01109 v 6.51803 c 0,2.22214 -1.78895,4.01109 -4.01109,4.01109 H 86.073767 c -2.222145,0 -4.011092,-1.78895 -4.011092,-4.01109 v -6.51803 c 0,-2.22214 1.788947,-4.01109 4.011092,-4.01109 z" />
+ </g>
+ </g>
+ </g>
+ </g>
+ </g>
+ <g
+ style="display:inline"
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="Icon">
+ <path
+ d="m 3.9999777,4.0333382 12.0000003,0 0,11.9333328 -12.0000003,0 z"
+ id="path4"
+ inkscape:connector-curvature="0"
+ style="fill:none" />
+ <path
+ style="fill:none"
+ inkscape:connector-curvature="0"
+ id="path9956"
+ d="m 18.999978,19.033339 12,0 0,11.933333 -12,0 z" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_no_watch.svg b/app/src/main/assets/ic_no_watch.svg
new file mode 100644
index 0000000..1257e6b
--- /dev/null
+++ b/app/src/main/assets/ic_no_watch.svg
@@ -0,0 +1,121 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
+ sodipodi:docname="ic_no_watch.svg"
+ id="svg6"
+ version="1.1"
+ width="24"
+ viewBox="0 0 24 24"
+ height="24">
+ <metadata
+ id="metadata12">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs10">
+ <inkscape:path-effect
+ simplify_just_coalesce="false"
+ simplify_individual_paths="false"
+ helper_size="0"
+ smooth_angles="360"
+ threshold="0.00108696"
+ steps="1"
+ lpeversion="1"
+ is_visible="true"
+ id="path-effect3556"
+ effect="simplify" />
+ </defs>
+ <sodipodi:namedview
+ inkscape:current-layer="svg6"
+ inkscape:window-maximized="1"
+ inkscape:window-y="38"
+ inkscape:window-x="0"
+ inkscape:cy="11.716102"
+ inkscape:cx="18.443347"
+ inkscape:zoom="32"
+ inkscape:guide-bbox="true"
+ showguides="true"
+ showgrid="false"
+ id="namedview8"
+ inkscape:window-height="1042"
+ inkscape:window-width="1920"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0"
+ guidetolerance="10"
+ gridtolerance="10"
+ objecttolerance="10"
+ borderopacity="1"
+ bordercolor="#666666"
+ pagecolor="#ffffff">
+ <sodipodi:guide
+ id="guide837"
+ orientation="0,-1"
+ position="-8.5737052,20.015936" />
+ <sodipodi:guide
+ id="guide839"
+ orientation="0,-1"
+ position="-9.7211155,4.0159363" />
+ <sodipodi:guide
+ id="guide845"
+ orientation="0,-1"
+ position="4.1752988,24" />
+ <sodipodi:guide
+ id="guide847"
+ orientation="0,-1"
+ position="7.6494024,22.007968" />
+ <sodipodi:guide
+ id="guide3420"
+ orientation="0,-1"
+ position="5.7695406,6.0152602" />
+ <sodipodi:guide
+ id="guide3422"
+ orientation="0,-1"
+ position="4.0567082,2.0036265" />
+ <sodipodi:guide
+ id="guide3550"
+ orientation="1,0"
+ position="2.032932,17.149903" />
+ <sodipodi:guide
+ id="guide3552"
+ orientation="1,0"
+ position="22.185475,15.824078" />
+ <sodipodi:guide
+ id="guide3558"
+ orientation="0,-1"
+ position="2.9610096,18.696699" />
+ <sodipodi:guide
+ id="guide3560"
+ orientation="1,0"
+ position="11.534679,13.084039" />
+ <sodipodi:guide
+ id="guide3562"
+ orientation="0,-1"
+ position="22.516932,5.0848936" />
+ </sodipodi:namedview>
+ <path
+ id="path2"
+ fill="none"
+ d="M0 0h24v24H0V0z" />
+ <path
+ d="M 8 0 L 7.0507812 5.7304688 C 6.8273015 5.9058883 6.6255018 6.1054426 6.4238281 6.3046875 L 8.1308594 7.4375 C 8.5054322 7.1202052 8.9128027 6.8403522 9.3574219 6.6210938 C 10.15599 6.2272895 11.050552 6 12 6 C 13.173766 6 14.265876 6.3435073 15.191406 6.9277344 C 16.875856 7.991018 18 9.8637657 18 12 C 18 12.628035 17.902201 13.23348 17.722656 13.802734 L 19.431641 14.935547 C 19.792399 14.025062 20 13.037464 20 12 C 20 10.148663 19.361897 8.4452049 18.302734 7.0898438 C 17.908745 6.5856742 17.462523 6.1263145 16.960938 5.7304688 L 16 0 L 8 0 z M 9.6992188 2 L 14.310547 2 L 14.720703 4.4804688 C 13.870703 4.1704687 12.96 4 12 4 C 11.05 4 10.129063 4.1707031 9.2890625 4.4707031 L 9.6992188 2 z M 5.3867188 7.5253906 C 4.515003 8.7993422 4 10.335029 4 12 C 4 14.55 5.1907813 16.809531 7.0507812 18.269531 L 8 24 L 16 24 L 16.960938 18.269531 C 17.62693 17.743937 18.203786 17.110661 18.673828 16.400391 L 17.011719 15.291016 C 15.938243 16.920962 14.094668 18 12 18 C 8.69 18 6 15.31 6 12 C 6 10.749526 6.3915691 9.5950783 7.0488281 8.6347656 L 5.3867188 7.5253906 z M 14.720703 19.519531 L 14.310547 22 L 9.6992188 22 L 9.2890625 19.529297 C 10.129062 19.829297 11.05 20 12 20 C 12.96 20 13.870703 19.829531 14.720703 19.519531 z "
+ id="path4" />
+ <path
+ id="path3564"
+ d="M 2.032932,5.303301 22.185475,18.915106"
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+</svg>
diff --git a/app/src/main/assets/ic_shoe.svg b/app/src/main/assets/ic_shoe.svg
new file mode 100644
index 0000000..000a501
--- /dev/null
+++ b/app/src/main/assets/ic_shoe.svg
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" enable-background="new 0 0 24 24" height="24" viewBox="0 0 27 24" width="27" version="1.1" id="svg1685" sodipodi:docname="ic_shoe.svg" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <metadata id="metadata1691">
+ <rdf:RDF>
+ <cc:Work rdf:about="">
+ <dc:format/>
+ <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+ <dc:title/>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs id="defs1689"/>
+ <sodipodi:namedview pagecolor="#ffffff" bordercolor="#666666" borderopacity="1" objecttolerance="10" gridtolerance="10" guidetolerance="10" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-width="1920" inkscape:window-height="1042" id="namedview1687" showgrid="false" showguides="true" inkscape:guide-bbox="true" inkscape:zoom="22.185475" inkscape:cx="11.936505" inkscape:cy="9.4419739" inkscape:window-x="0" inkscape:window-y="38" inkscape:window-maximized="1" inkscape:current-layer="g1683" inkscape:document-rotation="0" lock-margins="false" fit-margin-left="2.5" fit-margin-right="2.5" fit-margin-top="2.5" fit-margin-bottom="2.5" inkscape:pagecheckerboard="true">
+ <sodipodi:guide position="8.418941,13.042708" orientation="1,0" id="guide2254"/>
+ <sodipodi:guide position="8.914761,13.752632" orientation="0,-1" id="guide2256"/>
+ </sodipodi:namedview>
+ <g id="g1683" transform="translate(1.5,0.4999999)">
+ <rect fill="none" height="24" width="24" id="rect1679" x="0" y="0"/>
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_shoe_prints_many.svg b/app/src/main/assets/ic_shoe_prints_many.svg
new file mode 100644
index 0000000..3462b38
--- /dev/null
+++ b/app/src/main/assets/ic_shoe_prints_many.svg
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ version="1.1"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ id="svg4"
+ sodipodi:docname="ic_shoe_prints_many.svg"
+ inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <metadata
+ id="metadata10">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs8" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1042"
+ id="namedview6"
+ showgrid="true"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:zoom="16.0625"
+ inkscape:cx="20.274468"
+ inkscape:cy="17.69706"
+ inkscape:window-x="0"
+ inkscape:window-y="38"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg4"
+ inkscape:document-rotation="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid880" />
+ <sodipodi:guide
+ position="16,24"
+ orientation="0,-1"
+ id="guide882" />
+ </sodipodi:namedview>
+ <path
+ d="m 6.4801431,14.379265 c 0.3823082,1.031063 0.3416371,2.10404 -0.8052874,2.531554 C 3.3159331,17.807761 2.787209,15.452241 2.7546721,15.334884 l 3.725471,-0.955619 M 2.3886324,13.700271 5.9026138,12.80333 c -0.1545501,-0.880176 0.081342,-1.760353 0.081342,-2.799798 0,-1.4082825 -1.0818507,-4.1661681 -2.8063045,-3.7218887 -1.9684802,0.5029579 -2.2531778,2.7578856 -2.1799698,3.8476277 0.097611,1.089742 1.3340114,3.420113 1.3909509,3.571 m 9.8749386,7.494073 C 12.239171,21.320083 11.70231,23.66722 9.343387,22.778661 8.204597,22.342764 8.155792,21.269787 8.5381,20.238724 l 3.725471,0.95562 m 1.748856,-5.197232 c 0.08134,-1.089742 -0.195221,-3.353052 -2.171836,-3.85601 -1.716319,-0.461045 -2.79817,2.313606 -2.79817,3.730271 0,1.031064 0.227758,1.91124 0.08948,2.791416 l 3.497713,0.896942 c 0.06507,-0.150888 1.293341,-2.481259 1.382817,-3.562619 z"
+ id="path2"
+ style="stroke-width:0.999999" />
+ <path
+ d="M 14.730838,7.4902842 C 14.986006,8.2347895 14.958862,9.00956 14.193361,9.3182576 12.618928,9.9659169 12.266038,8.2650539 12.244321,8.1803133 L 14.730838,7.4902842 M 12.000011,7 14.345374,6.3523407 c -0.103162,-0.6355538 0.05428,-1.2711071 0.05428,-2.0216657 0,-1.0168858 -0.722068,-3.0082871 -1.873034,-2.6874837 -1.313836,0.3631733 -1.503856,1.9914011 -1.454993,2.778277 C 11.136774,5.2083444 11.961996,6.8910478 12,7 m 6.590901,5.411286 c -0.0162,0.09079 -0.374605,1.785603 -1.949038,1.143996 -0.760065,-0.314751 -0.79264,-1.089521 -0.537473,-1.834026 l 2.486519,0.69003 m 1.16726,-3.7527936 c 0.05428,-0.7868766 -0.130305,-2.4211567 -1.449564,-2.78433 -1.145536,-0.3329091 -1.867604,1.670598 -1.867604,2.6935367 0,0.7445054 0.152015,1.3800583 0.05971,2.0156119 l 2.334511,0.647659 c 0.04345,-0.108954 0.863221,-1.7916547 0.922943,-2.5724776 z"
+ id="path2-3"
+ style="stroke-width:0.999998" />
+</svg>
diff --git a/app/src/main/assets/ic_sony_wf_800n_left.svg b/app/src/main/assets/ic_sony_wf_800n_left.svg
new file mode 100644
index 0000000..d31ea35
--- /dev/null
+++ b/app/src/main/assets/ic_sony_wf_800n_left.svg
@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="12.321217mm"
+ height="12.3mm"
+ viewBox="0 0 12.321217 12.3"
+ version="1.1"
+ id="svg3988"
+ sodipodi:docname="ic_sony_wf_800n_right.svg"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview3990"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ fit-margin-left="2"
+ fit-margin-right="2"
+ fit-margin-bottom="1"
+ fit-margin-top="1"
+ inkscape:zoom="10.248373"
+ inkscape:cx="5.7082231"
+ inkscape:cy="32.639327"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1">
+ <sodipodi:guide
+ position="7.864604,2.6305704"
+ orientation="0,-1"
+ id="guide4472" />
+ <sodipodi:guide
+ position="3.8004999,2.3743318"
+ orientation="1,0"
+ id="guide14199" />
+ </sodipodi:namedview>
+ <defs
+ id="defs3985" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-81.704318,-153.92826)">
+ <path
+ id="path13615-2-5"
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 90.053204,155.41797 c 0.02931,0.0131 0.05714,0.0295 0.0848,0.0455 0.02766,0.0164 0.05496,0.0328 0.08289,0.0508 0.02799,0.0164 0.05645,0.0373 0.08642,0.0583 0.02997,0.0196 0.06139,0.0436 0.09518,0.0681 0.02207,0.0164 0.044,0.0328 0.0658,0.0495 0.02174,0.0164 0.04347,0.0344 0.06498,0.0521 0.04298,0.0357 0.08533,0.0727 0.12676,0.11136 0.04143,0.0386 0.08204,0.0789 0.121557,0.12021 0.03952,0.0413 0.07795,0.0839 0.115234,0.12741 0.03725,0.0436 0.07328,0.0881 0.10789,0.13364 0.03458,0.0455 0.06778,0.0917 0.09933,0.13855 0.03162,0.0468 0.06155,0.0943 0.08974,0.14215 0.01416,0.0229 0.02766,0.0478 0.04097,0.0721 0.01317,0.0229 0.02602,0.0482 0.0381,0.0724 0.03162,0.0622 0.06037,0.12152 0.08675,0.17851 0.02635,0.057 0.05032,0.11169 0.07203,0.16475 0.02174,0.0531 0.04113,0.10482 0.05859,0.15624 0.0086,0.0262 0.0168,0.0511 0.0247,0.0763 0.0079,0.0262 0.01515,0.0508 0.02174,0.076 0.0069,0.0262 0.01317,0.0508 0.0191,0.0763 0.0059,0.0262 0.01153,0.0514 0.0168,0.0773 0.01021,0.0521 0.0191,0.10514 0.02635,0.16017 0.0076,0.055 0.0135,0.11202 0.01844,0.17163 0.0049,0.0596 0.0089,0.12218 0.01186,0.18801 0.0022,0.0488 0.0036,0.0963 0.0046,0.14248 7.51e-4,0.0465 8.43e-4,0.0917 5.6e-5,0.13593 -7.54e-4,0.0442 -0.0024,0.0878 -0.0049,0.13069 -0.0025,0.0429 -0.0059,0.0852 -0.01021,0.12709 -0.0043,0.0419 -0.0096,0.0835 -0.01581,0.12479 -0.0063,0.0413 -0.0135,0.0825 -0.02206,0.12414 -0.0086,0.0413 -0.01811,0.0829 -0.02898,0.12479 -0.01087,0.0419 -0.02272,0.0839 -0.03586,0.12676 -0.01317,0.0429 -0.02766,0.0861 -0.04341,0.13036 -0.01581,0.0442 -0.03293,0.0891 -0.05141,0.13495 -0.01844,0.0459 -0.03847,0.093 -0.05987,0.1415 -0.02141,0.0485 -0.04426,0.0979 -0.0687,0.14936 -0.02437,0.0511 -0.05045,0.10383 -0.07809,0.15853 -0.02766,0.0544 -0.05684,0.1107 -0.0878,0.16901 -0.03096,0.0583 -0.06359,0.11857 -0.09801,0.18113 -0.03441,0.0626 -0.07061,0.12741 -0.10868,0.19488 -0.05572,0.0957 -0.111084,0.18932 -0.165984,0.28136 -0.0549,0.092 -0.109405,0.18211 -0.163481,0.27022 -0.05408,0.0884 -0.107725,0.17491 -0.160978,0.25942 -0.05325,0.0848 -0.106112,0.1677 -0.158542,0.24893 -0.05243,0.0812 -0.104464,0.16049 -0.156104,0.23812 -0.05161,0.0776 -0.10285,0.15362 -0.153634,0.22764 -0.05078,0.074 -0.101171,0.14674 -0.151131,0.21749 -0.04999,0.0708 -0.09956,0.13986 -0.148694,0.20733 -0.04914,0.0675 -0.09788,0.13331 -0.146191,0.19718 -0.04831,0.0642 -0.0963,0.12676 -0.14382,0.18736 -0.04752,0.0609 -0.09462,0.11988 -0.141317,0.17752 -0.0024,0.003 -0.0046,0.007 -0.0069,0.01 0.194966,-0.0262 0.968613,-0.71204 1.37172,-1.21583 0.923471,-1.15403 1.396852,-2.52066 1.255554,-3.62461 -0.129197,-1.00932 -0.579152,-1.83344 -1.415251,-2.59196 -0.207579,-0.18834 -0.2305,-0.16312 -0.290933,-0.14445 -0.207085,0.12512 -0.230764,0.11464 -0.09106,0.19161 z"
+ sodipodi:nodetypes="cssscssssssssssssssssssssssssssssssssssscsssssssssssccsscc" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 86.308307,159.96828 c -0.280855,-0.0131 -0.311286,0.0341 -0.517248,-0.1569 -0.193417,-0.20831 -0.23903,-0.30494 -0.241072,-0.49305 -0.0022,-0.19521 0.0063,-0.21814 0.172406,-0.61528 0.355002,-0.84882 0.780135,-1.7003 0.978905,-1.96057 0.297882,-0.39006 0.724197,-0.67506 1.20233,-0.8217 0.35981,-0.11038 0.97706,-0.0943 1.363049,0.0357 1.222855,0.41159 1.877382,1.7234 1.473514,2.95321 -0.236659,0.72069 -0.614698,1.11099 -1.05809,1.11099 z"
+ id="path3272-6-9"
+ sodipodi:nodetypes="ccsssssscc" />
+ <path
+ id="rect20090-0-9"
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ d="m 86.064192,159.50015 3.333695,0.0593 c 0.126299,0.002 0.22803,0.1035 0.22803,0.2319 0,0.12839 -0.101731,0.23419 -0.22803,0.2319 l -3.333695,-0.0593 c -0.126299,-0.002 -0.22803,-0.1035 -0.22803,-0.2319 0,-0.12839 0.101731,-0.23419 0.22803,-0.2319 z"
+ sodipodi:nodetypes="sssssss" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 85.781713,164.92823 c -0.544003,-0.11398 -1.161783,-0.52357 -1.464372,-0.97067 -0.461748,-0.6823 -0.575563,-1.39958 -0.359006,-2.26249 0.387889,-1.54563 1.513636,-4.33537 2.110287,-5.22959 0.426503,-0.63919 0.999207,-1.06208 1.729794,-1.27731 0.247791,-0.073 0.479065,-0.10252 0.845307,-0.10973 0.21242,-0.003 0.568742,0.0328 0.906948,0.15067 0.423124,0.14609 0.557855,0.19948 0.828076,0.39715 0.353286,0.25613 0.686884,0.63179 0.882416,1.01829 0.252204,0.49858 0.341154,0.81253 0.364829,1.34023 0.03501,0.78057 -0.102357,1.2234 -0.71112,2.30065 -0.873758,1.45714 -1.067488,1.7511 -2.066729,2.97611 -0.202375,0.25974 -0.548646,0.65597 -0.7695,0.88076 -0.324657,0.33039 -0.456002,0.43543 -0.685867,0.54856 -0.521335,0.25647 -1.10744,0.34297 -1.611063,0.23747 z"
+ id="path5762-6-0"
+ sodipodi:nodetypes="ssssssccssccsss" />
+ <g
+ aria-label="L"
+ id="text6720"
+ style="font-size:3.175px;font-family:Sans;-inkscape-font-specification:Sans;letter-spacing:0px;word-spacing:0px;stroke:#000000;stroke-width:0.264583px"
+ transform="translate(-1.0010411,0.02155759)">
+ <g
+ id="path14201">
+ <path
+ style="color:#000000;-inkscape-font-specification:Sans;fill:#000000;stroke:none;-inkscape-stroke:none"
+ d="m 86.637918,161.02382 h 0.313159 v 2.05104 h 1.127063 v 0.26355 h -1.440222 z"
+ id="path14206" />
+ <path
+ style="color:#000000;-inkscape-font-specification:Sans;fill:#000000;stroke:none;-inkscape-stroke:none"
+ d="m 86.505859,160.89062 v 0.13282 2.44726 h 1.705078 v -0.52734 h -1.126953 v -2.05274 z m 0.263672,0.26563 h 0.04883 v 2.05078 h -0.04883 z"
+ id="path14208" />
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/app/src/main/assets/ic_sony_wf_800n_right.svg b/app/src/main/assets/ic_sony_wf_800n_right.svg
new file mode 100644
index 0000000..c987dbe
--- /dev/null
+++ b/app/src/main/assets/ic_sony_wf_800n_right.svg
@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="12.321217mm"
+ height="12.3mm"
+ viewBox="0 0 12.321217 12.3"
+ version="1.1"
+ id="svg3988"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ sodipodi:docname="ic_sony_wf_800n_left.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview3990"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ fit-margin-left="2"
+ fit-margin-right="2"
+ fit-margin-bottom="1"
+ fit-margin-top="1"
+ inkscape:zoom="28.986776"
+ inkscape:cx="25.201147"
+ inkscape:cy="32.44583"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1">
+ <sodipodi:guide
+ position="8.0623995,2.7429643"
+ orientation="0,-1"
+ id="guide4472" />
+ <sodipodi:guide
+ position="4.9027758,5.5559637"
+ orientation="1,0"
+ id="guide14199" />
+ </sodipodi:namedview>
+ <defs
+ id="defs3985" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-81.704318,-153.92826)">
+ <g
+ aria-label="R"
+ id="text19879"
+ style="font-size:3.175px;font-family:Sans;-inkscape-font-specification:Sans;letter-spacing:0px;word-spacing:0px;stroke:#000000;stroke-width:0.264583px"
+ transform="translate(0.9003844,-0.00688411)">
+ <g
+ id="path20419">
+ <path
+ style="color:#000000;-inkscape-font-specification:Sans;fill:#000000;stroke:none;-inkscape-stroke:none"
+ d="m 88.752478,162.27504 q 0.100769,0.0341 0.195337,0.14573 0.09612,0.11162 0.192236,0.30696 l 0.31781,0.63251 h -0.336413 l -0.296106,-0.59376 q -0.114722,-0.23254 -0.223242,-0.30851 -0.106971,-0.076 -0.293006,-0.076 H 87.96803 v 0.97823 h -0.313159 v -2.31458 h 0.706933 q 0.396875,0 0.592212,0.16588 0.195337,0.16588 0.195337,0.50074 0,0.21859 -0.102319,0.36277 -0.100769,0.14418 -0.294556,0.19999 z m -0.784448,-0.97203 v 0.82165 h 0.393774 q 0.226343,0 0.341065,-0.10387 0.116272,-0.10542 0.116272,-0.30851 0,-0.20309 -0.116272,-0.3054 -0.114722,-0.10387 -0.341065,-0.10387 z"
+ id="path20424" />
+ <path
+ style="color:#000000;-inkscape-font-specification:Sans;fill:#000000;stroke:none;-inkscape-stroke:none"
+ d="m 87.523437,160.91406 v 2.57813 h 0.576172 v -0.97852 h 0.208985 c 0.106008,0 0.175,0.023 0.216797,0.0527 0.03834,0.0268 0.109953,0.1125 0.18164,0.25781 v 0.002 l 0.332031,0.66602 h 0.632813 l -0.414063,-0.82227 v -0.002 c -0.06683,-0.13558 -0.13491,-0.24575 -0.208984,-0.33203 -0.02289,-0.027 -0.05326,-0.036 -0.07813,-0.0586 0.06187,-0.0418 0.140739,-0.0643 0.183594,-0.125 0.08647,-0.12185 0.126953,-0.27308 0.126953,-0.43945 0,-0.24896 -0.07849,-0.46255 -0.242188,-0.60156 -0.163919,-0.1392 -0.39472,-0.19727 -0.677734,-0.19727 z m 0.263672,0.26367 h 0.04883 v 0.125 0.94727 0.008 0.97071 h -0.04883 z m 0.710938,0.0371 c 0.148175,0.0194 0.300592,0.0378 0.371094,0.0977 0.09675,0.0822 0.148437,0.20287 0.148437,0.40039 0,0.12509 -0.02817,0.21477 -0.07813,0.28516 V 162 c -0.03013,0.0431 -0.08781,0.0643 -0.136719,0.0918 0.0941,-0.098 0.148438,-0.22921 0.148438,-0.37891 0,-0.15997 -0.051,-0.3074 -0.160156,-0.4043 -0.07397,-0.067 -0.186918,-0.0752 -0.292969,-0.0937 z m -0.398438,0.22071 h 0.261719 c 0.130659,0 0.207561,0.0301 0.251953,0.0703 h 0.002 c 0.04492,0.0395 0.07227,0.0969 0.07227,0.20703 0,0.11015 -0.02802,0.1671 -0.07422,0.20898 -0.04439,0.0402 -0.121294,0.0703 -0.251953,0.0703 h -0.261719 z m 0.632813,0.70312 c -0.0069,0.002 -0.0083,0.008 -0.01563,0.01 l -0.0957,0.0273 v 0.1582 c -0.08301,-0.0446 -0.176683,-0.0726 -0.283203,-0.0762 h 0.02344 c 0.147723,0 0.267887,-0.0478 0.371094,-0.11914 z m -0.01172,0.26953 c 0.03608,0.0144 0.07838,0.0403 0.126953,0.0977 v 0.002 c 0.05341,0.062 0.113057,0.15579 0.173828,0.2793 l 0.222657,0.44141 h -0.04102 l -0.259766,-0.52149 c -0.06801,-0.13786 -0.140605,-0.22429 -0.222656,-0.29883 z"
+ id="path20426" />
+ </g>
+ </g>
+ <path
+ id="path13615-2-5"
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 85.676646,155.41797 c -0.02931,0.0131 -0.05714,0.0295 -0.0848,0.0455 -0.02766,0.0164 -0.05496,0.0328 -0.08289,0.0508 -0.02799,0.0164 -0.05645,0.0373 -0.08642,0.0583 -0.02997,0.0196 -0.06139,0.0436 -0.09518,0.0681 -0.02207,0.0164 -0.044,0.0328 -0.0658,0.0495 -0.02174,0.0164 -0.04347,0.0344 -0.06498,0.0521 -0.04298,0.0357 -0.08533,0.0727 -0.12676,0.11136 -0.04143,0.0386 -0.08204,0.0789 -0.121557,0.12021 -0.03952,0.0413 -0.07795,0.0839 -0.115234,0.12741 -0.03725,0.0436 -0.07328,0.0881 -0.10789,0.13364 -0.03458,0.0455 -0.06778,0.0917 -0.09933,0.13855 -0.03162,0.0468 -0.06155,0.0943 -0.08974,0.14215 -0.01416,0.0229 -0.02766,0.0478 -0.04097,0.0721 -0.01317,0.0229 -0.02602,0.0482 -0.0381,0.0724 -0.03162,0.0622 -0.06037,0.12152 -0.08675,0.17851 -0.02635,0.057 -0.05032,0.11169 -0.07203,0.16475 -0.02174,0.0531 -0.04113,0.10482 -0.05859,0.15624 -0.0086,0.0262 -0.0168,0.0511 -0.0247,0.0763 -0.0079,0.0262 -0.01515,0.0508 -0.02174,0.076 -0.0069,0.0262 -0.01317,0.0508 -0.0191,0.0763 -0.0059,0.0262 -0.01153,0.0514 -0.0168,0.0773 -0.01021,0.0521 -0.0191,0.10514 -0.02635,0.16017 -0.0076,0.055 -0.0135,0.11202 -0.01844,0.17163 -0.0049,0.0596 -0.0089,0.12218 -0.01186,0.18801 -0.0022,0.0488 -0.0036,0.0963 -0.0046,0.14248 -7.51e-4,0.0465 -8.43e-4,0.0917 -5.6e-5,0.13593 7.54e-4,0.0442 0.0024,0.0878 0.0049,0.13069 0.0025,0.0429 0.0059,0.0852 0.01021,0.12709 0.0043,0.0419 0.0096,0.0835 0.01581,0.12479 0.0063,0.0413 0.0135,0.0825 0.02206,0.12414 0.0086,0.0413 0.01811,0.0829 0.02898,0.12479 0.01087,0.0419 0.02272,0.0839 0.03586,0.12676 0.01317,0.0429 0.02766,0.0861 0.04341,0.13036 0.01581,0.0442 0.03293,0.0891 0.05141,0.13495 0.01844,0.0459 0.03847,0.093 0.05987,0.1415 0.02141,0.0485 0.04426,0.0979 0.0687,0.14936 0.02437,0.0511 0.05045,0.10383 0.07809,0.15853 0.02766,0.0544 0.05684,0.1107 0.0878,0.16901 0.03096,0.0583 0.06359,0.11857 0.09801,0.18113 0.03441,0.0626 0.07061,0.12741 0.10868,0.19488 0.05572,0.0957 0.111084,0.18932 0.165984,0.28136 0.0549,0.092 0.109405,0.18211 0.163481,0.27022 0.05408,0.0884 0.107725,0.17491 0.160978,0.25942 0.05325,0.0848 0.106112,0.1677 0.158542,0.24893 0.05243,0.0812 0.104464,0.16049 0.156104,0.23812 0.05161,0.0776 0.10285,0.15362 0.153634,0.22764 0.05078,0.074 0.101171,0.14674 0.151131,0.21749 0.04999,0.0708 0.09956,0.13986 0.148694,0.20733 0.04914,0.0675 0.09788,0.13331 0.146191,0.19718 0.04831,0.0642 0.0963,0.12676 0.14382,0.18736 0.04752,0.0609 0.09462,0.11988 0.141317,0.17752 0.0024,0.003 0.0046,0.007 0.0069,0.01 -0.194966,-0.0262 -0.968613,-0.71204 -1.37172,-1.21583 -0.923471,-1.15403 -1.396852,-2.52066 -1.255554,-3.62461 0.129197,-1.00932 0.579152,-1.83344 1.415251,-2.59196 0.207579,-0.18834 0.2305,-0.16312 0.290933,-0.14445 0.207085,0.12512 0.230764,0.11464 0.09106,0.19161 z"
+ sodipodi:nodetypes="cssscssssssssssssssssssssssssssssssssssscsssssssssssccsscc" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 89.421543,159.96828 c 0.280855,-0.0131 0.311286,0.0341 0.517248,-0.1569 0.193417,-0.20831 0.23903,-0.30494 0.241072,-0.49305 0.0022,-0.19521 -0.0063,-0.21814 -0.172406,-0.61528 -0.355002,-0.84882 -0.780135,-1.7003 -0.978905,-1.96057 -0.297882,-0.39006 -0.724197,-0.67506 -1.20233,-0.8217 -0.35981,-0.11038 -0.97706,-0.0943 -1.363049,0.0357 -1.222855,0.41159 -1.877382,1.7234 -1.473514,2.95321 0.236659,0.72069 0.614698,1.11099 1.05809,1.11099 z"
+ id="path3272-6-9"
+ sodipodi:nodetypes="ccsssssscc" />
+ <path
+ id="rect20090-0-9"
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ d="m 89.665658,159.50015 -3.333695,0.0593 c -0.126299,0.002 -0.22803,0.1035 -0.22803,0.2319 0,0.12839 0.101731,0.23419 0.22803,0.2319 l 3.333695,-0.0593 c 0.126299,-0.002 0.22803,-0.1035 0.22803,-0.2319 0,-0.12839 -0.101731,-0.23419 -0.22803,-0.2319 z"
+ sodipodi:nodetypes="sssssss" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 89.948137,164.92823 c 0.544003,-0.11398 1.161783,-0.52357 1.464372,-0.97067 0.461748,-0.6823 0.575563,-1.39958 0.359006,-2.26249 -0.387889,-1.54563 -1.513636,-4.33537 -2.110287,-5.22959 -0.426503,-0.63919 -0.999207,-1.06208 -1.729794,-1.27731 -0.247791,-0.073 -0.479065,-0.10252 -0.845307,-0.10973 -0.21242,-0.003 -0.568742,0.0328 -0.906948,0.15067 -0.423124,0.14609 -0.557855,0.19948 -0.828076,0.39715 -0.353286,0.25613 -0.686884,0.63179 -0.882416,1.01829 -0.252204,0.49858 -0.341154,0.81253 -0.364829,1.34023 -0.03501,0.78057 0.102357,1.2234 0.71112,2.30065 0.873758,1.45714 1.067488,1.7511 2.066729,2.97611 0.202375,0.25974 0.548646,0.65597 0.7695,0.88076 0.324657,0.33039 0.456002,0.43543 0.685867,0.54856 0.521335,0.25647 1.10744,0.34297 1.611063,0.23747 z"
+ id="path5762-6-0"
+ sodipodi:nodetypes="ssssssccssccsss" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/logback.xml b/app/src/main/assets/logback.xml
new file mode 100644
index 0000000..2169f3c
--- /dev/null
+++ b/app/src/main/assets/logback.xml
@@ -0,0 +1,19 @@
+<configuration debug="true">
+
+ <appender name="STDOUT" class="ch.qos.logback.classic.android.LogcatAppender">
+ <!-- encoders are by default assigned the type
+ ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
+ <tagEncoder>
+ <pattern>%logger{80}</pattern>
+ </tagEncoder>
+ <!-- include [%-20thread] to print thread -->
+ <encoder>
+ <pattern>%msg</pattern>
+ </encoder>
+ </appender>
+
+ <root level="DEBUG">
+ <appender-ref ref="STDOUT" />
+ <!-- The FILE appender is added programmatically by nodomain.freeyourgadget.gadgetbridge.Logging -->
+ </root>
+</configuration>
\ No newline at end of file
diff --git a/app/src/main/assets/migrations/devicetype.json b/app/src/main/assets/migrations/devicetype.json
new file mode 100644
index 0000000..e4eb7fc
--- /dev/null
+++ b/app/src/main/assets/migrations/devicetype.json
@@ -0,0 +1,122 @@
+{
+ "by-id": {
+ "-1": "UNKNOWN",
+ "1": "PEBBLE",
+ "10": "MIBAND",
+ "11": "MIBAND2",
+ "1001": "MIBAND2_HRX",
+ "12": "AMAZFITBIP",
+ "13": "AMAZFITCOR",
+ "14": "MIBAND3",
+ "15": "AMAZFITCOR2",
+ "16": "MIBAND4",
+ "17": "AMAZFITBIP_LITE",
+ "18": "AMAZFITGTR",
+ "19": "AMAZFITGTS",
+ "20": "AMAZFITBIPS",
+ "21": "AMAZFITGTR_LITE",
+ "22": "AMAZFITTREX",
+ "23": "MIBAND5",
+ "24": "AMAZFITBAND5",
+ "25": "AMAZFITBIPS_LITE",
+ "26": "AMAZFITGTR2",
+ "27": "AMAZFITGTS2",
+ "28": "AMAZFITBIPU",
+ "29": "AMAZFITVERGEL",
+ "30": "AMAZFITBIPUPRO",
+ "31": "AMAZFITNEO",
+ "32": "AMAZFITGTS2_MINI",
+ "33": "ZEPP_E",
+ "34": "AMAZFITGTR2E",
+ "35": "AMAZFITGTS2E",
+ "36": "AMAZFITX",
+ "37": "MIBAND6",
+ "38": "AMAZFITTREXPRO",
+ "39": "AMAZFITPOP",
+ "10040": "AMAZFITPOPPRO",
+ "10041": "MIBAND7",
+ "10042": "AMAZFITGTS3",
+ "10043": "AMAZFITGTR3",
+ "10044": "AMAZFITGTR4",
+ "10045": "AMAZFITBAND7",
+ "10046": "AMAZFITGTS4",
+ "10047": "AMAZFITGTS4MINI",
+ "10048": "AMAZFITTREX2",
+ "10049": "AMAZFITGTR3PRO",
+ "10051": "AMAZFITBIP3PRO",
+ "10050": "AMAZFITCHEETAHPRO",
+ "10052": "AMAZFITCHEETAHSQUARE",
+ "10053": "AMAZFITCHEETAHROUND",
+ "10054": "AMAZFITBIP5",
+ "10055": "AMAZFITTREXULTRA",
+ "10056": "AMAZFITGTRMINI",
+ "10057": "AMAZFITFALCON",
+ "10058": "AMAZFITBALANCE",
+ "40": "HPLUS",
+ "41": "MAKIBESF68",
+ "42": "EXRIZUK8",
+ "43": "Q8",
+ "44": "SG2",
+ "50": "NO1F1",
+ "60": "TECLASTH30",
+ "61": "Y5",
+ "70": "XWATCH",
+ "80": "ZETIME",
+ "90": "ID115",
+ "100": "WATCH9",
+ "102": "WATCHXPLUS",
+ "110": "ROIDMI",
+ "112": "ROIDMI3",
+ "120": "CASIOGB6900",
+ "121": "CASIOGBX100",
+ "122": "CASIOGWB5600",
+ "123": "CASIOGMWB5000",
+ "131": "MISCALE2",
+ "140": "BFH16",
+ "150": "MAKIBESHR3",
+ "160": "BANGLEJS",
+ "170": "FOSSILQHYBRID",
+ "180": "TLW64",
+ "190": "PINETIME_JF",
+ "200": "MIJIA_LYWSD02",
+ "210": "LEFUN",
+ "211": "BOHEMIC_SMART_BRACELET",
+ "220": "SMAQ2OSS",
+ "230": "FITPRO",
+ "250": "ITAG",
+ "251": "NUTMINI",
+ "260": "VIVOMOVE_HR",
+ "300": "VIBRATISSIMO",
+ "310": "SONY_SWR12",
+ "320": "LIVEVIEW",
+ "330": "WASPOS",
+ "350": "UM25",
+ "400": "DOMYOS_T540",
+ "410": "NOTHING_EAR1",
+ "418": "GALAXY_BUDS_PRO",
+ "419": "GALAXY_BUDS_LIVE",
+ "420": "GALAXY_BUDS",
+ "421": "GALAXY_BUDS2",
+ "422": "GALAXY_BUDS2_PRO",
+ "430": "SONY_WH_1000XM3",
+ "431": "SONY_WF_SP800N",
+ "432": "SONY_WH_1000XM4",
+ "433": "SONY_WF_1000XM3",
+ "434": "SONY_WH_1000XM2",
+ "435": "SONY_WF_1000XM4",
+ "436": "SONY_LINKBUDS_S",
+ "437": "SONY_WH_1000XM5",
+ "438": "SONY_WF_1000XM5",
+ "440": "BOSE_QC35",
+ "500": "VESC",
+ "510": "BINARY_SENSOR",
+ "520": "FLIPPER_ZERO",
+ "530": "SUPER_CARS",
+ "540": "ASTEROIDOS",
+ "550": "SOFLOW_SO6",
+ "560": "WITHINGS_STEEL_HR",
+ "570": "SONY_WENA_3",
+ "580": "FEMOMETER_VINCA2",
+ "1000": "TEST"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/assets/nothing_ear_l.svg b/app/src/main/assets/nothing_ear_l.svg
new file mode 100644
index 0000000..f80a3c8
--- /dev/null
+++ b/app/src/main/assets/nothing_ear_l.svg
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="45pt"
+ height="45pt"
+ viewBox="0 0 25 25"
+ version="1.1"
+ id="svg1584"
+ sodipodi:docname="tws_bud_l.svg"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview1586"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ fit-margin-top="5"
+ fit-margin-left="5"
+ fit-margin-right="5"
+ fit-margin-bottom="5"
+ inkscape:zoom="7.2407734"
+ inkscape:cx="32.93847"
+ inkscape:cy="47.094417"
+ inkscape:window-width="1920"
+ inkscape:window-height="1043"
+ inkscape:window-x="0"
+ inkscape:window-y="0"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1"
+ inkscape:snap-nodes="false"
+ units="pt">
+ <sodipodi:guide
+ position="60.230277,-77.001526"
+ orientation="0,-1"
+ id="guide12345" />
+ <sodipodi:guide
+ position="48.368846,-82.602765"
+ orientation="1,0"
+ id="guide12613" />
+ <sodipodi:guide
+ position="66.270257,-85.540793"
+ orientation="1,0"
+ id="guide12653" />
+ <sodipodi:guide
+ position="59.029537,-82.554635"
+ orientation="0,-1"
+ id="guide13329" />
+ </sodipodi:namedview>
+ <defs
+ id="defs1581" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-54.112633,-52.186932)">
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 66.205353,71.516731 c -0.241039,3.18099 4.357921,3.724035 4.261474,-0.840271 l -0.05402,-11.075736 c -0.0096,-1.958838 -0.926881,-4.724551 -4.068115,-4.689489 -2.488808,0.02778 -5.036384,0.642953 -5.046106,1.875752 l -0.03294,4.176733 c -0.01523,1.930747 3.744189,3.676223 4.867724,2.994344 1.009905,-0.612914 0.05948,7.676196 0.05948,7.676196"
+ id="path1690"
+ sodipodi:nodetypes="ccsssssc" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/nothing_ear_r.svg b/app/src/main/assets/nothing_ear_r.svg
new file mode 100644
index 0000000..35c9c16
--- /dev/null
+++ b/app/src/main/assets/nothing_ear_r.svg
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="45pt"
+ height="45pt"
+ viewBox="0 0 25 25"
+ version="1.1"
+ id="svg1584"
+ sodipodi:docname="tws_bud_r.svg"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview1586"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ fit-margin-top="5"
+ fit-margin-left="5"
+ fit-margin-right="5"
+ fit-margin-bottom="5"
+ inkscape:zoom="7.2407734"
+ inkscape:cx="1.4501213"
+ inkscape:cy="47.16347"
+ inkscape:window-width="1890"
+ inkscape:window-height="1038"
+ inkscape:window-x="26"
+ inkscape:window-y="23"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="layer1"
+ inkscape:snap-nodes="false"
+ units="pt">
+ <sodipodi:guide
+ position="60.230277,-77.001526"
+ orientation="0,-1"
+ id="guide12345" />
+ <sodipodi:guide
+ position="48.368846,-82.602765"
+ orientation="1,0"
+ id="guide12613" />
+ <sodipodi:guide
+ position="66.270257,-85.540793"
+ orientation="1,0"
+ id="guide12653" />
+ <sodipodi:guide
+ position="59.029537,-82.554635"
+ orientation="0,-1"
+ id="guide13329" />
+ </sodipodi:namedview>
+ <defs
+ id="defs1581" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-54.112633,-52.186932)">
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 65.874055,71.516708 c 0.241037,3.181086 -4.357905,3.724148 -4.261458,-0.840294 l 0.05402,-11.076068 c 0.0096,-1.958896 0.926875,-4.724692 4.0681,-4.689628 2.488799,0.02778 5.036366,0.642972 5.046088,1.875807 l 0.03293,4.176859 c 0.01522,1.930805 -3.744175,3.676332 -4.867707,2.994433 -1.009902,-0.612932 -0.05948,7.676426 -0.05948,7.676426"
+ id="path1690"
+ sodipodi:nodetypes="ccsssssc" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/olive_laurel.svg b/app/src/main/assets/olive_laurel.svg
new file mode 100644
index 0000000..1e66711
--- /dev/null
+++ b/app/src/main/assets/olive_laurel.svg
@@ -0,0 +1,340 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ version="1.0"
+ width="24"
+ height="23.999998"
+ id="svg36"
+ sodipodi:docname="olive_laurel.svg"
+ inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <metadata
+ id="metadata42">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs40">
+ <linearGradient
+ gradientTransform="matrix(0.22116326,0,0,0.22116326,48.742492,-29.402522)"
+ id="SVGID_82_"
+ gradientUnits="userSpaceOnUse"
+ x1="1386.7239"
+ y1="1403.7546"
+ x2="1901.9294"
+ y2="1403.7546">
+ <stop
+ offset="0"
+ style="stop-color:#FFE964"
+ id="stop2661" />
+ <stop
+ offset="0.0123"
+ style="stop-color:#F6DA5C"
+ id="stop2663" />
+ <stop
+ offset="0.0525"
+ style="stop-color:#DCAE43"
+ id="stop2665" />
+ <stop
+ offset="0.0886"
+ style="stop-color:#CA8E31"
+ id="stop2667" />
+ <stop
+ offset="0.1188"
+ style="stop-color:#BE7A26"
+ id="stop2669" />
+ <stop
+ offset="0.1394"
+ style="stop-color:#BA7322"
+ id="stop2671" />
+ <stop
+ offset="0.1711"
+ style="stop-color:#AA6118"
+ id="stop2673" />
+ <stop
+ offset="0.2182"
+ style="stop-color:#8B3E04"
+ id="stop2675" />
+ <stop
+ offset="0.2412"
+ style="stop-color:#A05B17"
+ id="stop2677" />
+ <stop
+ offset="0.2998"
+ style="stop-color:#D3A045"
+ id="stop2679" />
+ <stop
+ offset="0.3421"
+ style="stop-color:#F3CB62"
+ id="stop2681" />
+ <stop
+ offset="0.3636"
+ style="stop-color:#FFDC6D"
+ id="stop2683" />
+ <stop
+ offset="0.4545"
+ style="stop-color:#FFFFB5"
+ id="stop2685" />
+ <stop
+ offset="0.5576"
+ style="stop-color:#FCFDF6"
+ id="stop2687" />
+ <stop
+ offset="0.6545"
+ style="stop-color:#FFFF9C"
+ id="stop2689" />
+ <stop
+ offset="0.7758"
+ style="stop-color:#FDDC53"
+ id="stop2691" />
+ <stop
+ offset="0.7981"
+ style="stop-color:#F5CF4A"
+ id="stop2693" />
+ <stop
+ offset="0.8393"
+ style="stop-color:#E1AD31"
+ id="stop2695" />
+ <stop
+ offset="0.8944"
+ style="stop-color:#C0770A"
+ id="stop2697" />
+ <stop
+ offset="0.903"
+ style="stop-color:#BB6E03"
+ id="stop2699" />
+ <stop
+ offset="1"
+ style="stop-color:#5B1F03"
+ id="stop2701" />
+ </linearGradient>
+ <linearGradient
+ gradientTransform="matrix(0.22116326,0,0,0.22116326,48.742492,-29.402522)"
+ id="SVGID_81_"
+ gradientUnits="userSpaceOnUse"
+ x1="1386.7239"
+ y1="1403.7546"
+ x2="1901.9294"
+ y2="1403.7546">
+ <stop
+ offset="0"
+ style="stop-color:#2E1300"
+ id="stop2642" />
+ <stop
+ offset="0.1265"
+ style="stop-color:#734200"
+ id="stop2644" />
+ <stop
+ offset="0.3198"
+ style="stop-color:#D0C459"
+ id="stop2646" />
+ <stop
+ offset="0.4146"
+ style="stop-color:#FFFF8A"
+ id="stop2648" />
+ <stop
+ offset="0.6111"
+ style="stop-color:#FFEB5C"
+ id="stop2650" />
+ <stop
+ offset="0.8827"
+ style="stop-color:#A67C21"
+ id="stop2652" />
+ <stop
+ offset="1"
+ style="stop-color:#5C2C00"
+ id="stop2654" />
+ <stop
+ offset="1"
+ style="stop-color:#725316"
+ id="stop2656" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient4195">
+ <stop
+ offset="0"
+ stop-color="#f3ed6d"
+ id="stop4197" />
+ <stop
+ offset=".071429"
+ stop-color="#ede77c"
+ id="stop4199" />
+ <stop
+ offset=".14286"
+ stop-color="#f5ef72"
+ id="stop4201" />
+ <stop
+ offset=".21429"
+ stop-color="#f4e65c"
+ id="stop4203" />
+ <stop
+ offset=".28571"
+ stop-color="#eed756"
+ id="stop4205" />
+ <stop
+ offset=".35714"
+ stop-color="#e9c952"
+ id="stop4207" />
+ <stop
+ offset=".42857"
+ stop-color="#eac74d"
+ id="stop4209" />
+ <stop
+ offset=".5"
+ stop-color="#eac848"
+ id="stop4211" />
+ <stop
+ offset=".57143"
+ stop-color="#e6c842"
+ id="stop4213" />
+ <stop
+ offset=".64286"
+ stop-color="#e1c540"
+ id="stop4215" />
+ <stop
+ offset=".71429"
+ stop-color="#dbbd3c"
+ id="stop4217" />
+ <stop
+ offset=".78571"
+ stop-color="#d1ae39"
+ id="stop4219" />
+ <stop
+ offset=".85714"
+ stop-color="#c7a534"
+ id="stop4221" />
+ <stop
+ offset=".92857"
+ stop-color="#c4a92f"
+ id="stop4223" />
+ <stop
+ offset="1"
+ stop-color="#c1ac2d"
+ id="stop4225" />
+ </linearGradient>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Emboss Shader"
+ inkscape:menu-tooltip="Combination of satiny and emboss effect"
+ inkscape:menu="Non realistic 3D shaders"
+ height="1.5"
+ y="-0.25"
+ width="1.5"
+ x="-0.25"
+ id="filter8631-6">
+ <feGaussianBlur
+ result="result8"
+ stdDeviation="5"
+ in="SourceGraphic"
+ id="feGaussianBlur8633-0" />
+ <feComposite
+ result="result19"
+ operator="xor"
+ in2="result8"
+ id="feComposite8635-6" />
+ <feComposite
+ in2="result8"
+ result="result17"
+ operator="arithmetic"
+ k2="1"
+ id="feComposite8637-2"
+ k1="0"
+ k3="0"
+ k4="0" />
+ <feOffset
+ in="result8"
+ result="result18"
+ id="feOffset8639-6" />
+ <feDisplacementMap
+ result="result4"
+ scale="100"
+ yChannelSelector="A"
+ xChannelSelector="A"
+ in="result18"
+ in2="result17"
+ id="feDisplacementMap8641-1" />
+ <feComposite
+ k3="0.5"
+ k1="0.5"
+ in="result4"
+ operator="arithmetic"
+ result="result2"
+ in2="result4"
+ id="feComposite8643-8"
+ k2="0"
+ k4="0" />
+ <feComposite
+ operator="over"
+ in="result2"
+ result="fbSourceGraphic"
+ in2="result2"
+ id="feComposite8645-7" />
+ <feComposite
+ result="result15"
+ operator="in"
+ in2="fbSourceGraphic"
+ id="feComposite8647-9" />
+ <feComposite
+ in="result15"
+ operator="in"
+ in2="result8"
+ result="result16"
+ id="feComposite8649-2" />
+ </filter>
+ </defs>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ id="namedview38"
+ showgrid="false"
+ inkscape:zoom="32.802228"
+ inkscape:cx="9.389606"
+ inkscape:cy="13.246051"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg36"
+ inkscape:snap-global="false"
+ showguides="true"
+ inkscape:showpageshadow="2"
+ inkscape:pagecheckerboard="0"
+ inkscape:deskcolor="#d1d1d1" />
+ <g
+ id="g563"
+ transform="translate(0,-0.7181305)">
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.939045;stroke-opacity:1"
+ d="M 5.748407,0.7181305 C 4.9089718,1.1229642 3.9698914,1.5076507 3.463455,2.6058643 3.1075486,3.199615 3.0983522,3.9726613 2.8474248,4.6193276 3.3122366,4.0771565 4.031798,3.5219633 4.4418292,2.8911986 4.8992452,2.099059 5.131822,1.3893232 5.748407,0.7181305 Z m 12.503186,0 c 0.616585,0.6711927 0.84931,1.3809285 1.306726,2.1730681 0.410032,0.6307647 1.129445,1.1859579 1.594257,1.728129 C 20.901648,3.9726615 20.892452,3.199615 20.536546,2.6058643 20.030108,1.5076507 19.091028,1.1229642 18.251593,0.7181305 Z M 3.4843852,2.1327501 C 3.056552,2.2679277 2.6763044,2.5894184 2.3764212,3.0525949 1.6854215,3.9526906 1.5669308,5.1335587 1.8533152,6.5572228 1.9518452,6.9337625 1.8117472,7.286476 1.8316432,7.6379752 2.0345981,7.1212906 2.2644769,6.6463614 2.7114527,6.313744 3.7081487,5.613623 3.9822567,5.2428965 4.2372785,4.5828465 4.5371457,3.8067255 4.5928641,3.6914478 4.5994741,2.9949417 4.3103253,3.9967668 3.4374385,4.417201 2.8474253,5.0935819 2.5442051,5.4627796 2.3013381,5.8905643 2.1631121,6.3435478 L 2.1418851,6.322702 C 2.2145314,5.9218945 2.3604376,5.5617487 2.4444072,5.1707789 2.70777,4.1135646 2.6536544,2.8409742 3.4843852,2.1327505 Z m 17.0031748,0 c 0.83073,0.7082236 0.776614,1.980814 1.039978,3.0380283 0.08397,0.3909698 0.229876,0.7511156 0.302522,1.1519232 l -0.02123,0.020846 C 21.670604,5.890564 21.427738,5.4627794 21.124518,5.0935816 20.534504,4.417201 19.661618,3.9967666 19.372469,2.9949414 c 0.0066,0.6965061 0.06248,0.8117839 0.362344,1.5879049 0.255022,0.66005 0.528981,1.0307765 1.525677,1.7308974 0.446976,0.3326175 0.676856,0.8075467 0.87981,1.3242312 C 22.1602,7.2864755 22.020098,6.933762 22.11863,6.5572224 22.405014,5.1335583 22.286524,3.9526901 21.595524,3.0525945 21.29564,2.5894184 20.915392,2.2679277 20.48756,2.1327501 Z M 1.7114055,4.0266727 c -0.021664,-0.0051 -0.033502,0.015795 -0.050321,0.026221 C 1.0818043,4.9698043 0.57606596,5.6634628 0.60106728,6.8958129 0.52305348,8.4123765 1.4001886,9.7947276 1.4881507,11.225012 1.6212002,10.130098 2.309539,9.3566618 2.8790426,8.5079841 2.8039716,8.870642 2.7168274,9.2219603 2.5662776,9.5374355 1.9423623,10.710552 1.321361,12.10312 1.751485,13.6041 1.741535,13.619958 1.727506,13.640861 1.711406,13.629994 1.5385569,13.134028 1.5089834,12.566606 1.3881011,12.038181 1.0992135,10.625456 0.2185583,9.4497466 0.47400138,7.821195 0.33917854,8.1745554 0.28459434,8.5733663 0.21319058,8.9586231 0.06246353,9.9542772 -0.01780287,10.568438 0.20354158,11.482985 0.47253244,12.630121 1.6056459,13.895601 2.0630618,14.946083 l 0.062494,0.06221 C 2.05893,14.438377 2.0249322,13.82482 2.1546504,13.281307 2.35178,12.411484 2.8935868,11.561478 3.0452962,10.664929 3.1243722,9.9618435 2.9480686,9.1823532 2.8814174,8.5044012 3.0869908,8.1977979 3.277417,7.8815499 3.4198132,7.5363492 3.6077794,7.0154376 3.5289738,6.2742488 3.5853246,5.7106659 3.5096016,5.9506405 3.3792746,6.1581256 3.2318866,6.3624399 2.5416724,7.1433229 1.7514654,7.9448917 1.6012623,9.0766988 1.5760643,9.2375517 1.5680053,9.4047036 1.5303073,9.5610503 1.5007253,9.5771916 1.5006513,9.5351016 1.4881503,9.5248953 L 1.5723163,8.1215126 C 1.6013749,6.750725 1.0737461,5.2309077 1.7114055,4.0266727 Z m 20.5771885,0 c 0.637658,1.204235 0.11018,2.7240523 0.139238,4.0948399 l 0.08402,1.4033827 c -0.0125,0.010206 -0.01242,0.052296 -0.04201,0.036155 -0.0377,-0.1563467 -0.0459,-0.3234986 -0.0711,-0.4843515 C 22.24854,7.9448917 21.458332,7.1433229 20.768118,6.3624399 20.62073,6.1581256 20.490402,5.9506405 20.41468,5.7106659 c 0.05635,0.5635829 -0.02231,1.3047717 0.16566,1.8256833 0.142396,0.3452007 0.332674,0.6614487 0.538246,0.968052 -0.06665,0.6779511 -0.242806,1.4574423 -0.16373,2.1605278 0.15171,0.896549 0.693516,1.746555 0.890646,2.616378 0.129718,0.543513 0.09557,1.15707 0.02895,1.726989 l 0.06249,-0.06221 C 22.394358,13.895601 23.52762,12.630121 23.79661,11.482985 24.017956,10.568438 23.937542,9.9542772 23.78681,8.9586231 23.71541,8.5733663 23.660822,8.1745554 23.526,7.821195 c 0.255442,1.6285516 -0.625214,2.80426 -0.914102,4.216986 -0.120882,0.528425 -0.150454,1.095847 -0.323304,1.591813 -0.0161,0.01087 -0.03013,-0.01004 -0.04008,-0.02589 0.430124,-1.50098 -0.19073,-2.893548 -0.814644,-4.0666654 -0.15055,-0.3154743 -0.237844,-0.6667926 -0.312914,-1.0294505 0.569504,0.8486768 1.257844,1.6221129 1.390892,2.7170269 0.08796,-1.4302842 0.965098,-2.8126345 0.887084,-4.3291981 0.025,-1.2323501 -0.48059,-1.9260086 -1.05987,-2.8429194 -0.01682,-0.010427 -0.02881,-0.031316 -0.05047,-0.026221 z M 2.7953218,11.731838 c 0.105568,1.163826 -0.300776,2.150816 -0.2722408,3.318802 0.028535,0.705537 0.213591,1.330824 0.5113788,1.893597 l -0.05047,1.67e-4 C 2.5816814,16.287889 2.2675308,15.589342 1.7973529,14.964164 1.0675428,14.09013 0.13636965,13.193971 0.04055366,11.979067 -0.08337197,12.9936 0.08334145,13.944717 0.45025082,14.785505 0.8171601,15.626293 1.3842521,16.356687 2.0609842,16.92942 l 0.3152886,0.242177 c 0.5578784,0.453086 0.9973108,0.630876 1.5333956,1.146222 v -0.06335 C 3.4991136,17.406403 3.5518444,16.39898 3.4863148,15.370179 3.4207854,14.341381 3.2369462,13.291142 2.9839902,12.357395 Z m 18.4093562,0 -0.188668,0.625553 c -0.252956,0.933747 -0.436646,1.983986 -0.502176,3.012784 -0.06553,1.0288 -0.0128,2.036224 -0.423354,2.884287 v 0.06335 c 0.536084,-0.515346 0.97537,-0.693135 1.533248,-1.146222 l 0.315288,-0.242176 c 0.676732,-0.572732 1.243824,-1.303126 1.610734,-2.143915 0.366908,-0.840788 0.533622,-1.791905 0.409696,-2.806438 -0.09582,1.214903 -1.026988,2.111063 -1.756798,2.985097 -0.470178,0.625178 -0.78433,1.323726 -1.186638,1.980239 l -0.05047,-1.67e-4 c 0.297788,-0.562772 0.482992,-1.18806 0.511528,-1.893597 0.02854,-1.167985 -0.377958,-2.154976 -0.27239,-3.318802 z m -17.7042618,3.10757 c 0.512784,1.274538 0.2720172,2.932627 0.9564058,4.118128 0.1879668,0.391851 0.4985276,0.736447 0.7669948,1.03906 l -0.01247,0.0158 C 4.9684044,19.871069 4.7429688,19.6689 4.5277766,19.431141 3.3584174,18.235053 1.614355,18.068853 0.9179884,16.107289 1.0124627,16.827018 1.1272775,17.549345 1.5024006,18.153976 2.762261,20.176912 4.1023968,20.212088 6.2177774,20.75195 5.2496058,19.779057 5.0897276,18.328092 4.6713188,17.04293 4.3900232,16.255611 3.9663398,15.533892 3.5004162,14.839408 Z m 16.9991678,0 c -0.465924,0.694483 -0.889608,1.416203 -1.170903,2.203522 -0.418409,1.285162 -0.578286,2.736127 -1.546458,3.70902 2.188044,-0.50869 3.428255,-0.518889 4.715377,-2.597974 0.375122,-0.604631 0.489936,-1.326958 0.584412,-2.046687 -0.696368,1.961564 -2.44028,2.127764 -3.60964,3.323852 -0.215192,0.237759 -0.440777,0.439928 -0.68372,0.581254 l -0.01247,-0.0158 c 0.268467,-0.302614 0.579028,-0.647211 0.766995,-1.03906 0.684387,-1.185501 0.443621,-2.843591 0.956405,-4.11813 z M 5.1743854,17.94714 c 0.6328812,1.060311 0.803988,2.630933 1.969812,3.143075 0.1935296,0.127291 0.4394738,0.112801 0.6167734,0.252761 -0.2226552,0.04202 -0.4148502,-0.06322 -0.6249374,-0.09935 -1.487305,-0.416751 -3.3117492,0.106548 -4.4282916,-1.475855 0.4237754,1.033348 1.292776,1.863746 2.1350264,2.195543 1.2955418,0.549719 2.6526596,-0.0582 3.8743112,-0.429303 0.1754008,-0.05678 0.3896682,-0.03586 0.553091,-0.109769 C 8.5901664,21.250778 7.9448694,20.979665 7.4087194,20.394144 6.6628082,19.519452 5.9829288,18.616966 5.1743854,17.94714 Z m 13.6512286,0 c -0.808543,0.669826 -1.488274,1.572313 -2.234185,2.447002 -0.536149,0.585521 -1.181594,0.856636 -1.861598,1.030104 0.163422,0.07391 0.37769,0.05299 0.55309,0.109769 1.221652,0.3711 2.578918,0.979023 3.874459,0.429303 0.842251,-0.331797 1.711252,-1.162195 2.135026,-2.195543 -1.116542,1.582403 -2.940986,1.059104 -4.428291,1.475855 -0.210087,0.03613 -0.40243,0.141368 -0.625085,0.09935 0.177299,-0.13996 0.423392,-0.12547 0.616921,-0.252761 1.165824,-0.512142 1.336782,-2.082764 1.969663,-3.143075 z m -5.519482,3.432806 c -0.358595,-1.67e-4 -0.716568,0.05158 -1.060314,0.161885 -0.218203,0.243166 -0.442659,-0.04348 -0.673625,-0.04902 -1.479974,-0.404849 -2.755593,0.432148 -3.9832654,1.091989 -0.9148978,0.445874 -2.113123,0.370966 -2.9176088,-0.320349 0.3980548,0.621864 0.930639,1.005124 1.4804024,1.265762 1.0222318,0.509223 2.2387558,0.166717 3.0846048,-0.566269 0.7258832,-0.505406 1.350068,-1.500744 2.306328,-1.121794 -0.973146,0.560728 -1.8626668,1.346833 -2.6668912,2.357264 l 0.5038084,0.518715 c 0.5864138,-0.975892 1.3664358,-1.772359 2.1552148,-2.391141 0.171081,-0.113956 0.351482,-0.255617 0.541216,-0.285822 0.968892,0.591024 1.834296,1.601344 2.615679,2.650579 0.166696,-0.167058 0.3437,-0.348334 0.480355,-0.542166 -0.810835,-0.976338 -1.697654,-1.740377 -2.652344,-2.295216 0.158646,-0.05665 0.338675,-0.06767 0.525333,-0.06286 0.514617,-0.03894 0.865227,0.414918 1.263085,0.730437 0.9827,0.929319 2.295802,1.543139 3.553974,0.9617 0.564031,-0.250065 1.110269,-0.692547 1.51009,-1.310878 -0.777652,0.686071 -1.950695,0.799028 -2.857935,0.379633 L 15.53824,22.063479 c -0.651084,-0.431489 -1.443196,-0.68312 -2.232108,-0.683533 z"
+ id="path4708" />
+ <path
+ d="M 17.833333,6.8847962 H 16.166667 V 5.2181295 H 7.8333331 V 6.8847962 H 6.1666665 c -0.9166667,0 -1.6666667,0.7500002 -1.6666667,1.6666669 v 0.8333333 c 0,2.1250006 1.6,3.8583336 3.6583333,4.1166666 0.525,1.25 1.6499999,2.191667 3.0083339,2.466667 v 2.583333 H 7.8333331 V 20.21813 H 16.166667 V 18.551463 H 12.833333 V 15.96813 c 1.358334,-0.275 2.483334,-1.216667 3.008334,-2.466667 C 17.9,13.24313 19.5,11.509797 19.5,9.3847963 V 8.551463 C 19.5,7.6347963 18.75,6.8847961 17.833333,6.8847962 Z M 6.1666665,9.3847964 V 8.5514631 H 7.8333331 V 11.734797 C 6.8666665,11.384797 6.1666665,10.46813 6.1666665,9.3847964 Z M 12,14.384797 c -1.375,0 -2.5000003,-1.125 -2.5000003,-2.5 V 6.8847962 H 14.5 v 5.0000008 c 0,1.375 -1.125,2.5 -2.5,2.5 z m 5.833333,-5.0000006 c 0,1.0833336 -0.7,2.0000006 -1.666666,2.3500006 V 8.5514631 h 1.666666 z"
+ id="path231"
+ style="stroke-width:1" />
+ </g>
+ <path
+ fill="#fcab29"
+ d="m 11.747167,8.1749904 c -0.05033,-4.399e-4 -0.09964,-0.015657 -0.149198,-0.023479 -0.09537,-0.015123 -0.190615,-0.031544 -0.286203,-0.045109 -0.03452,-0.00489 -0.06898,-0.010235 -0.103511,-0.015123 -0.0108,-0.00149 -0.0304,-8.463e-4 -0.03557,-0.012831 -0.0082,-0.01882 0.0099,-0.048943 0.01823,-0.064631 0.02792,-0.05186 0.05914,-0.1019641 0.09025,-0.151854 0.08096,-0.1298573 0.172403,-0.2638099 0.289412,-0.3628503 0.07704,-0.065211 0.166228,-0.10395 0.264889,-0.063424 0.03981,0.016345 0.07573,0.043505 0.103989,0.076072 0.02267,0.026136 0.04376,0.056336 0.05425,0.089881 0.01388,0.044299 0.03358,0.1222042 0.01512,0.1673526 -0.01675,0.04103 -0.06211,0.071917 -0.09031,0.1053706 -0.04805,0.056978 -0.08658,0.1205239 -0.122236,0.185949 -0.0167,0.030658 -0.04724,0.074911 -0.04555,0.1115113 0.0011,0.023097 0.06944,0.040496 0.08775,0.050608 0.06446,0.035561 0.123903,0.087911 0.154334,0.1570981 0.05601,0.1273979 -0.002,0.3115031 -0.105624,0.3983506 -0.03153,0.026427 -0.07069,0.040847 -0.109604,0.051815 -0.108663,0.030643 -0.225313,9.043e-4 -0.334919,-0.00382 -0.0042,0.05487 -0.01404,0.1134208 0.0023,0.1673541 0.02982,0.098466 0.149707,0.1716557 0.250354,0.1469048 0.100226,-0.024639 0.164482,-0.1152233 0.234714,-0.1837616 0.01311,-0.012831 0.03342,-0.012831 0.04587,-0.024899 0.01542,-0.01497 0.02058,-0.049157 0.03053,-0.068419 0.01527,-0.029604 0.03944,-0.056443 0.05858,-0.083679 0.116514,-0.1659686 0.218241,-0.3426668 0.315794,-0.5206572 0.02687,-0.049035 0.05332,-0.098543 0.07798,-0.1487531 0.0085,-0.017414 0.01357,-0.050226 0.02667,-0.063485 0.01265,-0.012831 0.04007,-0.012068 0.05628,-0.019537 0.112982,-0.052242 0.230787,-0.031177 0.319695,0.058246 0.01951,0.019644 0.04054,0.039136 0.05206,0.065074 0.04954,0.111603 -0.0026,0.2458062 -0.04166,0.3502037 -0.06851,0.1833766 -0.175104,0.3537813 -0.28436,0.51446 -0.01823,0.026808 -0.02076,0.064676 -0.03918,0.089026 -0.01219,0.0161 -0.0369,0.01859 -0.0509,0.034996 -0.03281,0.038372 -0.06288,0.08623 -0.104853,0.1150858 -0.08218,0.056489 -0.222953,0.05542 -0.307516,0.00794 -0.08187,-0.046056 -0.09817,-0.1420166 -0.10237,-0.2283707 -0.0015,-0.031238 0.01095,-0.070344 -0.0042,-0.099169 m 0.03654,-1.1745797 c 0.05426,-0.011457 0.09972,-0.042512 0.155281,-0.051326 0.116885,-0.018544 0.237609,0.051906 0.281778,0.1628922 0.02238,0.056229 0.01566,0.114765 0.03182,0.1704535 m 0.06393,1.0661081 c 0.02527,0.03576 0.01172,0.1027126 0.02358,0.1456674 0.0349,0.1267258 0.146452,0.179324 0.268707,0.1528852 0.17224,-0.037242 0.296585,-0.1979021 0.388718,-0.3388311 0.02128,-0.032567 0.03788,-0.075583 0.06382,-0.1041638 0.0088,-0.00962 0.03108,-0.023494 0.04279,-0.012526 0.01809,0.016986 0.01018,0.080609 0.01018,0.1042555 0,0.1022238 8.8e-4,0.2051397 -0.0086,0.3068166 -0.01823,0.1949982 -0.02204,0.4176421 -0.128545,0.5888399 -0.03611,0.058047 -0.08985,0.1068831 -0.139897,0.1523731 -0.09874,0.08974 -0.203813,0.173106 -0.313605,0.24844 -0.04825,0.0331 -0.104453,0.06199 -0.148859,0.100039 -0.01768,0.01512 -0.01249,0.04208 -0.01249,0.0632 v 0.167349 c 0,0.01639 0.0079,0.06413 -0.0071,0.07428 -0.01496,0.01023 -0.05144,0.0032 -0.06905,0.0032 h -1.041256 c -0.01889,0 -0.05907,0.0076 -0.07515,-0.0032 -0.01644,-0.01115 -0.0071,-0.07137 -0.0071,-0.08979 0,-0.06721 0.01607,-0.174946 -0.02681,-0.229337 C 11.14962,10.16143 10.934289,9.8795567 10.759401,9.5728269 10.685771,9.4436876 10.59599,9.2999189 10.581572,9.1482421 10.553272,8.8506183 10.709484,8.5545098 10.861006,8.31147 c 0.03703,-0.059391 0.07666,-0.117866 0.118936,-0.1735514 0.0145,-0.019079 0.0302,-0.050165 0.05176,-0.061423 0.03455,-0.018025 0.09629,0.00565 0.133928,0.00565 m 1.753754,0.3068151 c 0.134422,-0.028229 0.263489,-0.095365 0.389724,0.00117 0.02144,0.016406 0.03927,0.035622 0.05505,0.057726 0.01916,0.02687 0.03173,0.055022 0.04061,0.08678 0.0097,0.034782 0.019,0.071932 0.01672,0.1084715 -0.0051,0.081098 -0.04658,0.143147 -0.07441,0.21694 -0.0099,0.026182 -0.01141,0.05626 -0.01666,0.083679 M 11.059074,8.7112989 c 0.08249,0.037211 0.174513,0.060293 0.261845,0.082518 0.03768,0.00962 0.0883,0.012526 0.121789,0.03214 m -0.04871,1.9276741 c -0.03668,0.01992 -0.107784,0.0063 -0.149198,0.0063 -0.01203,0 -0.0434,-0.0057 -0.05167,0.005 -0.0054,0.007 -0.0031,0.02079 -0.0031,0.02913 v 0.461773 c 0,0.0089 -0.0026,0.0243 0.0071,0.02913 0.02807,0.01405 0.08946,0.0018 0.120818,0.0018 h 1.394479 c 0.03432,0 0.08238,0.009 0.115249,-10e-4 0.01234,-0.0037 0.0096,-0.0198 0.0096,-0.03 v -0.461772 c 0,-0.0084 0.0023,-0.02207 -0.0031,-0.02913 -0.0093,-0.01192 -0.05007,-0.0049 -0.06386,-0.0049 -0.05383,0 -0.125044,0.01237 -0.176594,-0.0031 m -0.986487,0.125275 c 0.06109,-0.0081 0.127141,-0.0013 0.188773,-0.0013 h 0.563213 c 0.03743,0 0.07925,-0.004 0.115696,0.0064 0.02516,0.007 0.03361,0.02876 0.03771,0.0525 0.0086,0.05 0.0071,0.116965 9.98e-4,0.167354 -0.0029,0.02365 -0.0079,0.04937 -0.03262,0.0588 -0.02448,0.0093 -0.05358,0.0063 -0.07916,0.0063 H 11.6376 c -0.02641,0 -0.05745,0.0035 -0.08216,-0.0082 -0.03429,-0.01627 -0.02744,-0.07215 -0.02744,-0.103354 0,-0.04329 -0.01686,-0.134975 0.02162,-0.164875 0.01629,-0.01268 0.03827,-0.011 0.05753,-0.0136 m -0.191816,0.407786 v 0.393594 l 0.0031,0.03223 0.06386,0.0049 h 1.044335 l 0.0621,-0.0049 0.0049,-0.03223 v -0.393597"
+ id="path3081"
+ style="fill:none;stroke:#000000;stroke-width:0.15625;stroke-opacity:1" />
+</svg>
diff --git a/app/src/main/assets/shortcut_black.svg b/app/src/main/assets/shortcut_black.svg
new file mode 100644
index 0000000..0de5b30
--- /dev/null
+++ b/app/src/main/assets/shortcut_black.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><path d="M0,0h24v24H0V0z" fill="none"/></g><g><path d="M15,5l-1.41,1.41L15,7.83L17.17,10H8c-2.76,0-5,2.24-5,5v4h2v-4c0-1.65,1.35-3,3-3h9.17L15,14.17l-1.41,1.41L15,17l6-6 L15,5z"/></g></svg>
\ No newline at end of file
diff --git a/app/src/main/assets/sony_800n.svg b/app/src/main/assets/sony_800n.svg
new file mode 100644
index 0000000..9811d47
--- /dev/null
+++ b/app/src/main/assets/sony_800n.svg
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="75.157791mm"
+ height="91.070755mm"
+ viewBox="0 0 75.157791 91.070755"
+ version="1.1"
+ id="svg5"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ sodipodi:docname="sony_800n.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview7"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ inkscape:zoom="0.90583672"
+ inkscape:cx="7.1756861"
+ inkscape:cy="221.34232"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1" />
+ <defs
+ id="defs2" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-77.71272,-89.542777)">
+ <path
+ id="path13615"
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 135.66527,92.903517 c 0.27135,0.13426 0.53173,0.27201 0.7891,0.41755 0.25737,0.14554 0.51166,0.2989 0.77153,0.46353 0.25987,0.16464 0.52522,0.34072 0.80408,0.53227 0.27886,0.19155 0.57139,0.39849 0.88574,0.62477 0.20548,0.14663 0.4094,0.29769 0.61236,0.45268 0.20297,0.155 0.4046,0.31412 0.60462,0.47698 0.40003,0.32571 0.79411,0.6664 1.17977,1.02061 0.38566,0.35421 0.76333,0.72195 1.1312,1.10071 0.36786,0.37876 0.72564,0.76852 1.07228,1.16788 0.34664,0.399359 0.68209,0.808199 1.00408,1.224223 0.32198,0.41601 0.63058,0.83942 0.92449,1.26814 0.2939,0.42871 0.57269,0.86271 0.83509,1.30018 0.13119,0.21873 0.25869,0.43817 0.38137,0.65835 0.12268,0.22019 0.24077,0.44084 0.3545,0.66198 0.2934,0.57048 0.56166,1.11292 0.80719,1.634 0.24552,0.52109 0.4684,1.02093 0.67024,1.50792 0.20184,0.48699 0.38286,0.96066 0.54519,1.42886 0.0812,0.23409 0.15736,0.46691 0.22944,0.69918 0.0721,0.23227 0.1401,0.46424 0.2036,0.6966 0.0635,0.23235 0.12283,0.46534 0.17829,0.6997 0.0555,0.23435 0.10709,0.47021 0.15503,0.70848 0.0959,0.47653 0.17707,0.9624 0.24598,1.46606 0.0689,0.50366 0.12543,1.02488 0.17156,1.57096 0.0461,0.54609 0.082,1.11753 0.10956,1.72135 0.0204,0.44657 0.0346,0.88054 0.0419,1.30482 0.007,0.42429 0.008,0.8385 5.1e-4,1.24489 -0.007,0.40639 -0.022,0.80498 -0.045,1.19786 -0.023,0.39288 -0.0544,0.78 -0.0946,1.16375 -0.0402,0.38375 -0.0895,0.76407 -0.14831,1.14309 -0.0588,0.37901 -0.12693,0.75667 -0.20567,1.13533 -0.0787,0.37865 -0.16812,0.75833 -0.2682,1.14101 -0.10008,0.38269 -0.21104,0.76852 -0.33383,1.15962 -0.12279,0.39111 -0.25722,0.78775 -0.40411,1.19166 -0.14688,0.40391 -0.30617,0.81501 -0.47852,1.2361 -0.17236,0.4211 -0.35788,0.85183 -0.55707,1.2945 -0.1992,0.44266 -0.41182,0.89769 -0.63924,1.36632 -0.22742,0.46863 -0.46955,0.95107 -0.72657,1.45004 -0.25702,0.49898 -0.52901,1.01401 -0.817,1.54771 -0.288,0.5337 -0.59174,1.08599 -0.9121,1.65881 -0.32035,0.57283 -0.65722,1.16651 -1.0113,1.78284 -0.51856,0.87562 -1.03369,1.73426 -1.54461,2.57607 -0.51092,0.84181 -1.01808,1.66687 -1.52136,2.4753 -0.50327,0.80843 -1.00246,1.60008 -1.4981,2.37557 -0.49564,0.77548 -0.98736,1.5344 -1.47536,2.27738 -0.488,0.74297 -0.97226,1.46985 -1.45262,2.18074 -0.48037,0.7109 -0.95716,1.4059 -1.42989,2.08515 -0.47273,0.67925 -0.94154,1.34305 -1.40663,1.99109 -0.4651,0.64804 -0.92644,1.2803 -1.3839,1.89756 -0.45746,0.61726 -0.91081,1.21918 -1.36064,1.80609 -0.44982,0.58691 -0.89622,1.15918 -1.33842,1.71617 -0.44219,0.557 -0.8806,1.09875 -1.31516,1.62626 -0.0219,0.0266 -0.0432,0.051 -0.0651,0.0775 1.81453,-0.25165 9.01417,-6.51807 12.76563,-11.12956 8.5941,-10.56399 12.99952,-23.07404 11.68456,-33.17953 -1.20223,-9.23918 -5.38978,-16.783353 -13.17077,-23.726723 -1.93172,-1.72377 -2.14508,-1.49298 -2.70733,-1.32291 -1.92707,1.14579 -2.14761,1.048007 -0.84748,1.75253 z"
+ sodipodi:nodetypes="cssscssssssssssssssssssssssssssssssssssscsssssssssssccsscc" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 100.81415,134.55672 c -2.613864,-0.11093 -2.896844,0.31251 -4.813676,-1.43755 -1.8001,-1.90704 -2.224465,-2.79101 -2.24341,-4.51313 -0.01966,-1.78671 0.0581,-1.99561 1.60434,-5.63226 3.30378,-7.77016 7.260176,-15.56447 9.109986,-17.94715 2.77204,-3.57059 6.7396,-6.179423 11.18926,-7.521773 3.34849,-1.01016 9.09281,-0.86199 12.68493,0.32721 11.38024,3.767503 17.47147,15.775673 13.71295,27.033553 -2.20249,6.59712 -5.72057,10.16997 -9.8469,10.16997 z"
+ id="path3272"
+ sodipodi:nodetypes="ccsssssscc" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.377953;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ id="path16756"
+ sodipodi:type="arc"
+ sodipodi:cx="97.313911"
+ sodipodi:cy="141.57806"
+ sodipodi:rx="1.5739406"
+ sodipodi:ry="1.5739406"
+ sodipodi:start="0.57990659"
+ sodipodi:end="0.54828965"
+ sodipodi:arc-type="arc"
+ d="m 98.630535,142.4405 a 1.5739406,1.5739406 0 0 1 -2.168624,0.46096 1.5739406,1.5739406 0 0 1 -0.478091,-2.16491 1.5739406,1.5739406 0 0 1 2.161065,-0.49519 1.5739406,1.5739406 0 0 1 0.512255,2.15709"
+ sodipodi:open="true" />
+ <path
+ id="rect20090"
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ d="m 98.542336,130.27149 31.024354,0.54286 c 1.17552,0.0206 2.12219,0.94649 2.12219,2.12218 0,1.1757 -0.94667,2.14276 -2.12219,2.12219 l -31.024354,-0.54286 c -1.175513,-0.0206 -2.122189,-0.94649 -2.122189,-2.12219 0,-1.17569 0.946676,-2.14275 2.122189,-2.12218 z"
+ sodipodi:nodetypes="sssssss" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 95.913493,179.95992 c -5.06264,-1.04405 -10.8119,-4.79262 -13.62787,-8.88549 -4.297169,-6.24572 -5.356359,-12.81158 -3.341019,-20.71068 3.609809,-14.14857 14.086349,-39.6859 19.638949,-47.87138 3.969187,-5.851261 9.298937,-9.722411 16.097997,-11.692431 2.30613,-0.6682 4.45831,-0.93852 7.86666,-1.00422 1.97685,-0.03811 5.29289,0.29142 8.44033,1.37883 3.93773,1.33876 5.19157,1.825243 7.70631,3.635523 3.28778,2.34611 6.39237,5.783278 8.21204,9.321468 2.34718,4.56389 3.17486,7.43793 3.3952,12.26847 0.32592,7.14516 -0.95256,11.19873 -6.6179,21.05994 -8.13144,13.33839 -9.93436,16.02958 -19.23356,27.24303 -1.88331,2.37684 -5.10587,6.0049 -7.16123,8.06236 -3.02143,3.0245 -4.24368,3.98612 -6.38286,5.02178 -4.8517,2.34889 -10.30616,3.13935 -14.993047,2.1728 z"
+ id="path5762"
+ sodipodi:nodetypes="ssssssccssccsss" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/sony_800n_case.svg b/app/src/main/assets/sony_800n_case.svg
new file mode 100644
index 0000000..3fd7343
--- /dev/null
+++ b/app/src/main/assets/sony_800n_case.svg
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="12.321217mm"
+ height="12.3mm"
+ viewBox="0 0 12.321217 12.3"
+ version="1.1"
+ id="svg3988"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ sodipodi:docname="sony_800n_case.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview3990"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ fit-margin-left="2"
+ fit-margin-right="2"
+ fit-margin-bottom="1"
+ fit-margin-top="1"
+ inkscape:zoom="10.248373"
+ inkscape:cx="27.662928"
+ inkscape:cy="19.076199"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1">
+ <sodipodi:guide
+ position="14.349871,-1.5160295"
+ orientation="0,-1"
+ id="guide4472" />
+ <sodipodi:guide
+ position="-3.9933874,14.528046"
+ orientation="1,0"
+ id="guide14199" />
+ </sodipodi:namedview>
+ <defs
+ id="defs3985" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-81.704318,-153.92826)">
+ <g
+ id="g26612"
+ transform="matrix(0.10330033,0,0,0.09696175,75.13823,137.28434)"
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.99595;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
+ <path
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.99595;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 72.96681,225.65437 c 0.223576,0.58263 2.250534,1.71014 4.308817,2.39697 2.122774,0.70836 5.258828,1.34722 8.366393,1.70453 2.48165,0.28534 37.1587,1.1115 37.1587,1.1115 h 3.21119 c 0,0 34.67708,-0.82616 37.15873,-1.1115 3.10756,-0.35731 6.24361,-0.99617 8.36639,-1.70453 2.05828,-0.68683 4.08524,-1.81434 4.30882,-2.39697"
+ id="path23413"
+ sodipodi:nodetypes="cssccssc" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.99595;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 116.98218,265.2035 c -12.08548,-0.56413 -19.153432,-1.85996 -24.277133,-4.45093 -9.494531,-4.80122 -16.040643,-14.2392 -18.553206,-26.74943 -0.997929,-4.96876 -1.79383,-13.53037 -1.363114,-14.66323 1.002805,-2.63759 7.912285,-7.1158 15.890039,-10.29877 5.230526,-2.08687 12.919394,-3.81264 21.636084,-4.85622 5.58344,-0.66846 22.56789,-0.66846 28.15133,0 8.59247,1.02871 16.20658,2.72438 21.46386,4.78001 7.8805,3.08133 15.05476,7.72504 16.07321,10.40376 0.27311,0.71833 -0.11096,6.58993 -0.70631,10.79799 -1.49912,10.59604 -5.14752,18.6992 -11.07883,24.60625 -7.38953,7.3593 -14.41912,9.57903 -33.08072,10.44589 -6.13715,0.28508 -7.75734,0.28333 -14.15521,-0.0153 z"
+ id="path21760" />
+ </g>
+ </g>
+</svg>
diff --git a/app/src/main/assets/sports_badmington.svg b/app/src/main/assets/sports_badmington.svg
new file mode 100644
index 0000000..ddb6743
--- /dev/null
+++ b/app/src/main/assets/sports_badmington.svg
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ enable-background="new 0 0 24 24"
+ height="24"
+ viewBox="0 0 24 24"
+ width="24"
+ version="1.1"
+ id="svg12"
+ sodipodi:docname="sports_badmington.svg"
+ inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <metadata
+ id="metadata18">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs16" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1042"
+ id="namedview14"
+ showgrid="false"
+ inkscape:zoom="11.092738"
+ inkscape:cx="37.162384"
+ inkscape:cy="25.090418"
+ inkscape:window-x="0"
+ inkscape:window-y="38"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg12" />
+ <g
+ id="g4">
+ <rect
+ fill="none"
+ height="24"
+ width="24"
+ id="rect2" />
+ </g>
+ <path
+ d="m 19.950218,3.6501062 c -2.34,-2.34 -6.62,-1.87 -9.55,1.06 -1.6,1.6 -2.52,3.87 -2.54,5.4599998 -0.02,1.580001 0.26,3.890001 -1.35,5.500001 l -4.24,4.24 1.42,1.42 4.24,-4.24 c 1.61,-1.61 3.92,-1.33 5.5,-1.35 1.58,-0.02 3.86,-0.94 5.46,-2.54 2.92,-2.930001 3.4,-7.2100008 1.06,-9.5500008 z m -9.2,9.1900008 c -1.53,-1.53 -1.05,-4.6100008 1.06,-6.7200008 2.11,-2.11 5.18,-2.59 6.72,-1.06 1.53,1.53 1.05,4.61 -1.06,6.7200008 -2.11,2.11 -5.18,2.59 -6.72,1.06 z"
+ id="path6"
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+ <g
+ id="g912"
+ transform="translate(0.23401207,-0.05573492)"
+ style="stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none">
+ <path
+ d="m 21.377568,21.02156 c 0.09302,0 0.182529,0.03675 0.247467,0.10325 0.136896,0.1365 0.136896,0.35875 0,0.49525 -0.06494,0.06475 -0.154447,0.1015 -0.247467,0.1015 -0.09302,0 -0.182529,-0.03675 -0.247467,-0.10325 -0.136896,-0.1365 -0.136896,-0.35875 0,-0.49525 0.06494,-0.06475 0.154448,-0.1015 0.247467,-0.1015 m 0,-0.35 c -0.179019,0 -0.359793,0.06825 -0.496689,0.20475 -0.273793,0.273 -0.273793,0.71575 0,0.9905 0.136896,0.1365 0.31767,0.20475 0.496689,0.20475 0.179019,0 0.359793,-0.06825 0.496689,-0.20475 0.273793,-0.273 0.273793,-0.71575 0,-0.9905 -0.136896,-0.1365 -0.31767,-0.20475 -0.496689,-0.20475 z"
+ id="path8"
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 20.944499,21.92275 15.722053,19.209022"
+ id="path892"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 22.004584,21.06064 18.758901,15.993056"
+ id="path892-5"
+ sodipodi:nodetypes="cc" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/sports_pingpong.svg b/app/src/main/assets/sports_pingpong.svg
new file mode 100644
index 0000000..9c84021
--- /dev/null
+++ b/app/src/main/assets/sports_pingpong.svg
@@ -0,0 +1,167 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ enable-background="new 0 0 24 24"
+ height="24"
+ viewBox="0 0 24 24"
+ width="24"
+ version="1.1"
+ id="svg12"
+ sodipodi:docname="sports_pingpong.svg"
+ inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <metadata
+ id="metadata18">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs16" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1920"
+ inkscape:window-height="1042"
+ id="namedview14"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ inkscape:object-nodes="false"
+ inkscape:snap-nodes="false"
+ inkscape:snap-others="false"
+ inkscape:zoom="22.627417"
+ inkscape:cx="23.738095"
+ inkscape:cy="10.519266"
+ inkscape:window-x="0"
+ inkscape:window-y="38"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg12"
+ inkscape:document-rotation="0">
+ <sodipodi:guide
+ position="17.574052,26.877722"
+ orientation="0,-1"
+ id="guide883" />
+ <sodipodi:guide
+ position="5.3138494,18.987309"
+ orientation="0,-1"
+ id="guide885" />
+ <sodipodi:guide
+ position="16.549801,18.358566"
+ orientation="1,0"
+ id="guide887" />
+ <sodipodi:guide
+ position="3.265625,18"
+ orientation="0,-1"
+ id="guide891" />
+ <sodipodi:guide
+ position="10.949107,18.751942"
+ orientation="1,0"
+ id="guide895" />
+ <sodipodi:guide
+ position="12.650582,18.409437"
+ orientation="1,0"
+ id="guide897" />
+ <sodipodi:guide
+ position="13.542061,11.233501"
+ orientation="1,0"
+ id="guide918" />
+ <sodipodi:guide
+ position="11.468388,20.840117"
+ orientation="0,-1"
+ id="guide1057" />
+ </sodipodi:namedview>
+ <g
+ id="g4">
+ <rect
+ fill="none"
+ height="24"
+ width="24"
+ id="rect2" />
+ </g>
+ <g
+ id="g1460">
+ <path
+ id="path879"
+ style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:square;stroke-miterlimit:4;stroke-dasharray:none;paint-order:fill markers stroke"
+ d="M 8.9746094 3.6386719 A 4.9498253 4.9496808 70 0 0 7.3242188 3.9335938 A 4.9498253 4.9496808 70 0 0 4.0683594 8.875 A 4.9498253 4.9496808 70 0 0 7.8769531 13.40625 L 12.964844 11.564453 A 4.9498253 4.9496808 70 0 0 12.990234 5.6445312 A 4.9498253 4.9496808 70 0 0 8.9746094 3.6386719 z M 6.5664062 6.8300781 A 1.5 1.5 0 0 1 8.0664062 8.3300781 A 1.5 1.5 0 0 1 6.5664062 9.8300781 A 1.5 1.5 0 0 1 5.0664062 8.3300781 A 1.5 1.5 0 0 1 6.5664062 6.8300781 z " />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 9.9193244,14.187583 11.413996,18.07844 13.27163,17.402316 11.896836,13.443232"
+ id="path893"
+ sodipodi:nodetypes="cccc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 12.968016,11.552689 c -1.382892,1.73803 -0.963028,1.977724 -1.029997,2.18556"
+ id="path899"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 7.8824612,13.403679 c 2.1538728,0.450753 2.0286568,0.932477 2.2119478,1.049226"
+ id="path899-4"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 12.968016,11.552689 -5.0855548,1.85099"
+ id="path916" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 13.572397,10.488971 6.7541886,12.970596"
+ id="path920" />
+ <path
+ style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:square;stroke-miterlimit:4;stroke-dasharray:none;paint-order:fill markers stroke"
+ id="path879-7"
+ sodipodi:type="arc"
+ sodipodi:arc-type="arc"
+ sodipodi:open="true"
+ sodipodi:start="0.57990613"
+ sodipodi:end="5.706593"
+ sodipodi:ry="4.9496808"
+ sodipodi:rx="4.9498253"
+ sodipodi:cy="-18.507776"
+ sodipodi:cx="2.4408588"
+ transform="rotate(110)"
+ d="m 6.5814574,-15.795621 a 4.9498253,4.9496808 0 0 1 -5.5597185,2.029738 4.9498253,4.9496808 0 0 1 -3.5306986,-4.750094 4.9498253,4.9496808 0 0 1 3.5464206,-4.73837 4.9498253,4.9496808 0 0 1 5.5529616,2.048152" />
+ <g
+ id="g1016"
+ style="fill:none;fill-opacity:1;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none"
+ transform="translate(2.7714608,-1.6578345)">
+ <path
+ style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 10.885327,15.160729 -1.3593007,3.936457 1.8576347,0.676124 1.500166,-3.928054"
+ id="path893-2"
+ sodipodi:nodetypes="cccc" />
+ <path
+ style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 14.911147,15.097075 c -2.176542,0.442503 -2.00898,0.896002 -2.193875,1.012167"
+ id="path899-2"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 9.8255917,13.246085 c 1.3602233,1.729779 0.9546563,2.018314 1.0200213,2.225566"
+ id="path899-4-6"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 14.911147,15.097075 9.8255917,13.246085"
+ id="path916-1" />
+ </g>
+ </g>
+</svg>
diff --git a/app/src/main/assets/tws_bud.svg b/app/src/main/assets/tws_bud.svg
new file mode 100644
index 0000000..1cd983e
--- /dev/null
+++ b/app/src/main/assets/tws_bud.svg
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="34.999996mm"
+ height="67.499672mm"
+ viewBox="0 0 34.999996 67.499672"
+ version="1.1"
+ id="svg1584"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ sodipodi:docname="tws_bud.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview1586"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ showguides="true"
+ inkscape:guide-bbox="true"
+ fit-margin-top="5"
+ fit-margin-left="5"
+ fit-margin-right="5"
+ fit-margin-bottom="5"
+ inkscape:zoom="1.7965552"
+ inkscape:cx="20.873281"
+ inkscape:cy="96.017091"
+ inkscape:window-width="1920"
+ inkscape:window-height="1035"
+ inkscape:window-x="0"
+ inkscape:window-y="45"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1">
+ <sodipodi:guide
+ position="56.822358,-42.871949"
+ orientation="0,-1"
+ id="guide12345" />
+ <sodipodi:guide
+ position="44.960927,-48.473189"
+ orientation="1,0"
+ id="guide12613" />
+ <sodipodi:guide
+ position="62.862338,-51.411219"
+ orientation="1,0"
+ id="guide12653" />
+ <sodipodi:guide
+ position="55.621618,-48.425059"
+ orientation="0,-1"
+ id="guide13329" />
+ </sodipodi:namedview>
+ <defs
+ id="defs1581" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-57.520553,-52.18693)">
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 66.393093,113.75676 c 0,0 -2.756884,-1.09093 -2.872459,-3.68865 l 0.142728,-39.164751 c 0.01936,-5.31142 3.361305,-12.810634 11.661174,-12.715563 6.576003,0.07532 11.083155,1.743367 11.108851,5.086109 l 0.08706,11.325221 c 0.04024,5.235233 -11.549838,8.121118 -14.518479,6.272204 -2.668401,-1.661919 -2.100534,7.911615 -2.100534,7.911615"
+ id="path1690"
+ sodipodi:nodetypes="ccsssssc" />
+ <path
+ id="rect9302"
+ style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;paint-order:fill markers stroke;stop-color:#000000"
+ d="m 72.801341,91.173713 h 2.007465 c 0,0 0,-1.120076 0,-1.680114 h 5.186597 v 1.665678 l 2.076559,0.01443 c 1.462007,0.01017 2.63906,1.218552 2.63906,2.732177 v 15.226926 c 0,1.51363 -1.177022,2.73218 -2.63906,2.73218 h -9.270621 c -1.462039,0 -2.63906,-1.21855 -2.63906,-2.73218 V 93.905881 c 0,-1.513625 1.177021,-2.732177 2.63906,-2.732177 z"
+ sodipodi:nodetypes="sccccssssssss" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/tws_case.svg b/app/src/main/assets/tws_case.svg
new file mode 100644
index 0000000..dffa098
--- /dev/null
+++ b/app/src/main/assets/tws_case.svg
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="45"
+ height="45"
+ viewBox="0 0 25 25"
+ version="1.1"
+ id="svg5"
+ inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+ sodipodi:docname="tws_case.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview7"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:document-units="mm"
+ showgrid="false"
+ inkscape:zoom="2.5407127"
+ inkscape:cx="-124.17776"
+ inkscape:cy="59.235348"
+ inkscape:window-width="1890"
+ inkscape:window-height="1038"
+ inkscape:window-x="26"
+ inkscape:window-y="23"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="layer1"
+ fit-margin-top="5"
+ fit-margin-left="5"
+ fit-margin-right="5"
+ fit-margin-bottom="5"
+ units="px"
+ viewbox-height="25" />
+ <defs
+ id="defs2" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-35.169857,-38.996456)">
+ <rect
+ style="fill:none;stroke:#000000;stroke-width:2.66666667;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke;stop-color:#000000"
+ id="rect846"
+ width="20.062387"
+ height="19"
+ x="37.638664"
+ y="41.996456"
+ ry="5.7287159" />
+ <path
+ style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.66666667;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="M 37.583681,47.610107 H 57.756032"
+ id="path1465" />
+ </g>
+</svg>
diff --git a/app/src/main/assets/womp/app.js b/app/src/main/assets/womp/app.js
new file mode 100644
index 0000000..666aebf
--- /dev/null
+++ b/app/src/main/assets/womp/app.js
@@ -0,0 +1,70 @@
+window.__cbs = {};
+window.__wompCb = function (id, ok, body) {
+ var fn = window.__cbs[id];
+ delete window.__cbs[id];
+ if (!fn) return;
+ var data = body;
+ if (typeof body === "string") {
+ try { data = JSON.parse(body); } catch (e) { data = body; }
+ }
+ fn(ok, data);
+};
+function callNative(name, a, b, c) {
+ return new Promise(function (resolve, reject) {
+ var id = "cb_" + Date.now() + "_" + Math.random().toString(16).slice(2);
+ window.__cbs[id] = function (ok, data) {
+ if (ok === false) reject(data);
+ else resolve(data);
+ };
+ if (name === "request") Womp.request(a, b || "{}", id);
+ else if (name === "scan") Womp.scan(id);
+ else if (name === "getLocation") Womp.getLocation(id);
+ });
+}
+function api(path, body) {
+ var payload = body || {};
+ try {
+ var sorter = JSON.parse(Womp.getSorter() || "{}");
+ if (sorter.sorterId && payload.sorterId == null) payload.sorterId = sorter.sorterId;
+ } catch (e) {}
+ return callNative("request", path, JSON.stringify(payload)).then(function (res) {
+ if (res && typeof res === "object" && res.code && res.code !== 1000) {
+ var err = new Error(res.message || "璇锋眰澶辫触");
+ err.code = res.code;
+ throw err;
+ }
+ return res && res.data !== undefined ? res.data : res;
+ });
+}
+function errMsg(e, fallback) {
+ if (!e) return fallback || "鎿嶄綔澶辫触";
+ if (typeof e === "string") return e;
+ if (e.message) return e.message;
+ return fallback || "鎿嶄綔澶辫触";
+}
+function loginErrMsg(e) {
+ var msg = errMsg(e, "鐧诲綍澶辫触锛岃绋嶅悗閲嶈瘯");
+ if (msg.length > 80 || /<html/i.test(msg)) return "鐧诲綍澶辫触锛岃绋嶅悗閲嶈瘯";
+ if (/璐﹀彿鎴栧瘑鐮侀敊璇瘄鐢ㄦ埛涓嶅瓨鍦▅unauthorized|password/i.test(msg)) {
+ return "鎵嬫満鍙锋垨瀵嗙爜閿欒锛岃閲嶆柊杈撳叆";
+ }
+ return msg;
+}
+function scan() { return callNative("scan"); }
+function getLocation() { return callNative("getLocation"); }
+function toast(msg) { Womp.toast(String(msg || "")); }
+function go(page) { location.href = page; }
+function qs(k) {
+ var m = location.search.match(new RegExp("[?&]" + k + "=([^&]*)"));
+ return m ? decodeURIComponent(m[1]) : "";
+}
+function $(id) { return document.getElementById(id); }
+function html(el, s) { el.innerHTML = s; }
+function money(n) { return Number(n || 0).toFixed(2); }
+function userHtml(u, extra) {
+ if (!u) return "<div class='muted'>鏆傛棤鐢ㄦ埛淇℃伅</div>";
+ return "<div><b>" + (u.nickName || "-") + "</b> 路 " + (u.role || "") + "</div>" +
+ "<div class='muted'>鎵嬫満 " + (u.phone || "-") + " 路 绉垎 " + (u.carbonBalance == null ? "-" : u.carbonBalance) + "</div>" +
+ (u.collegeName || u.className ? "<div class='muted'>" + (u.collegeName || "") + " " + (u.className || "") + "</div>" : "") +
+ (extra ? "<div class='muted'>" + extra + "</div>" : "");
+}
diff --git a/app/src/main/assets/womp/appointments.html b/app/src/main/assets/womp/appointments.html
new file mode 100644
index 0000000..e21acd2
--- /dev/null
+++ b/app/src/main/assets/womp/appointments.html
@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
+ <title>棰勭害</title>
+ <link rel="stylesheet" href="css.css"/>
+</head>
+<body>
+<div class="wrap">
+ <div class="page-head">
+ <h1>棰勭害璁㈠崟</h1>
+ <a class="back-link" href="home.html">杩斿洖宸ヤ綔鍙�</a>
+ </div>
+ <div class="card">
+ <input id="kw" placeholder="濮撳悕/鐢佃瘽/棰勭害鍙�"/>
+ <button class="btn ghost" onclick="search()">鏌ヨ</button>
+ <div id="searchList"></div>
+ </div>
+ <div class="card">
+ <div class="sec-title">鏂板棰勭害锛堝緟棰嗗彇锛�</div>
+ <div id="newList" class="muted">鍔犺浇涓��</div>
+ </div>
+ <div class="card">
+ <div class="sec-title">鎴戠殑鏈畬鎴愰绾�</div>
+ <div id="mineList" class="muted">鍔犺浇涓��</div>
+ </div>
+</div>
+<script src="app.js"></script>
+<script>
+function rowHtml(a, action) {
+ return "<div class='item'><b>" + (a.contactName || "") + "</b> " + (a.contactPhone || "") +
+ "<div class='muted'>" + (a.appointmentNo || "") + " 路 " + (a.status || "") + "</div>" +
+ "<div class='muted'>" + (a.address || "") + "</div>" +
+ "<div class='muted'>" + (a.appointmentDate || "") + " " + (a.appointmentSlot || "") + "</div>" +
+ action + "</div>";
+}
+function renderList(el, list, empty, btnText, fn) {
+ if (!list.length) { $(el).innerHTML = "<div class='muted'>" + empty + "</div>"; return; }
+ $(el).innerHTML = list.map(function (a) {
+ var action = btnText ? "<button class='btn sm' onclick='" + fn + "(" + a.id + ")'>" + btnText + "</button>" : "";
+ return rowHtml(a, action);
+ }).join("");
+}
+function loadNew() {
+ api("/app/shop/sorter/appointmentPage", { page: 1, size: 50, pool: "new" }).then(function (res) {
+ renderList("newList", (res && res.list) || [], "鏆傛棤鏂板棰勭害", "棰嗗彇鍒版垜鍚嶄笅", "claimApt");
+ }).catch(function (e) { $("newList").innerHTML = "<div class='err'>" + (e.message || "鍔犺浇澶辫触") + "</div>"; });
+}
+function loadMine() {
+ api("/app/shop/sorter/appointmentPage", { page: 1, size: 50, pool: "mine" }).then(function (res) {
+ renderList("mineList", (res && res.list) || [], "鏆傛棤鏈畬鎴愰绾�", "", "");
+ }).catch(function (e) { $("mineList").innerHTML = "<div class='err'>" + (e.message || "鍔犺浇澶辫触") + "</div>"; });
+}
+function search() {
+ api("/app/shop/sorter/appointmentPage", { page: 1, size: 50, keyWord: $("kw").value }).then(function (res) {
+ var list = (res && res.list) || [];
+ if (!list.length) { $("searchList").innerHTML = "<div class='muted'>娌℃湁鍖归厤缁撴灉</div>"; return; }
+ $("searchList").innerHTML = list.map(function (a) { return rowHtml(a, ""); }).join("");
+ }).catch(function (e) { toast(e.message); });
+}
+function claimApt(id) {
+ api("/app/shop/sorter/claimAppointment", { id: id }).then(function () {
+ toast("宸查鍙�");
+ loadNew();
+ loadMine();
+ }).catch(function (e) { toast(e.message); });
+}
+loadNew();
+loadMine();
+</script>
+</body>
+</html>
diff --git a/app/src/main/assets/womp/checkin.html b/app/src/main/assets/womp/checkin.html
new file mode 100644
index 0000000..876ae64
--- /dev/null
+++ b/app/src/main/assets/womp/checkin.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
+ <title>鎵撳崱</title>
+ <link rel="stylesheet" href="css.css"/>
+</head>
+<body>
+<div class="wrap">
+ <div class="page-head">
+ <h1>鎵爜鎵撳崱</h1>
+ <a class="back-link" href="home.html">杩斿洖宸ヤ綔鍙�</a>
+ </div>
+ <div class="card">
+ <div id="msg" class="muted">鐐瑰嚮寮�濮嬪悗鎸佺画鎵爜锛屾壂鍒板洖鏀剁爜鍗虫墦鍗°�傛壂鐮侀〉鍙偣銆屽仠姝� / 杩斿洖銆嶇粨鏉熴��</div>
+ <div id="result"></div>
+ <button class="btn" id="btn" onclick="toggle()">寮�濮嬫壂鐮�</button>
+ </div>
+</div>
+<script src="app.js"></script>
+<script>
+var running = false, timer = null;
+function toggle() {
+ running = !running;
+ $("btn").textContent = running ? "鍋滄鎵爜" : "寮�濮嬫壂鐮�";
+ if (running) loop();
+ else if (timer) { clearTimeout(timer); timer = null; }
+}
+function loop() {
+ if (!running) return;
+ scan().then(function (code) {
+ if (!running) return;
+ if (!code) { running = false; $("btn").textContent = "寮�濮嬫壂鐮�"; return; }
+ return api("/app/shop/sorter/checkUser", { unionid: code }).then(function (user) {
+ return api("/app/shop/sorter/checkIn", { unionid: user.unionid }).then(function (r) {
+ $("result").innerHTML = "<div class='ok'>鎵撳崱鎴愬姛 路 " + (user.nickName || "") + " +" + r.score + "鍒�</div>";
+ toast("鎵撳崱鎴愬姛");
+ timer = setTimeout(function () { $("result").innerHTML = ""; loop(); }, 3000);
+ });
+ });
+ }).catch(function (e) {
+ $("result").innerHTML = "<div class='err'>" + (e.message || "鎵撳崱澶辫触") + "</div>";
+ toast(e.message || "鎵撳崱澶辫触");
+ timer = setTimeout(function () { if (running) loop(); }, 1800);
+ });
+}
+</script>
+</body>
+</html>
diff --git a/app/src/main/assets/womp/css.css b/app/src/main/assets/womp/css.css
new file mode 100644
index 0000000..1defe67
--- /dev/null
+++ b/app/src/main/assets/womp/css.css
@@ -0,0 +1,34 @@
+:root{--bg:#f4f7f4;--card:#fff;--primary:#2e7d32;--danger:#c62828;--text:#1b1b1b;--muted:#667066}
+*{box-sizing:border-box}
+body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--text)}
+.wrap{padding:12px 16px 32px}
+h1{font-size:18px;margin:0}
+.card{background:var(--card);border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:0 1px 4px rgba(0,0,0,.06)}
+label{display:block;font-size:13px;color:var(--muted);margin:10px 0 4px}
+.req:after{content:" *";color:var(--danger)}
+input,select,textarea{width:100%;padding:10px 12px;border:1px solid #d7ddd7;border-radius:8px;font-size:16px;background:#fff}
+.btn{display:block;width:100%;border:0;border-radius:10px;padding:12px;font-size:16px;margin-top:12px;background:var(--primary);color:#fff}
+.btn.ghost{background:#e8f5e9;color:var(--primary)}
+.btn.danger{background:var(--danger)}
+.btn.sm{width:auto;display:inline-block;padding:6px 12px;font-size:13px;margin:0;border-radius:8px}
+.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
+.tile{background:var(--card);border-radius:12px;padding:18px 12px;text-align:center;box-shadow:0 1px 4px rgba(0,0,0,.06);font-size:15px}
+.item{padding:10px 0;border-bottom:1px solid #eee}
+.item:last-child{border-bottom:0}
+.muted{color:var(--muted);font-size:13px}
+.ok{color:var(--primary);font-size:18px;text-align:center;padding:24px 0}
+.err{color:var(--danger);text-align:center;padding:8px}
+.row{display:flex;gap:8px;align-items:center}
+.row input{flex:1}
+.page-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:4px 0 12px}
+.back-link{font-size:13px;color:var(--primary);text-decoration:none;white-space:nowrap}
+.who-bar{display:flex;align-items:center;gap:8px;background:var(--card);border-radius:12px;padding:12px;margin-bottom:12px;box-shadow:0 1px 4px rgba(0,0,0,.06)}
+.who-center{flex:1;text-align:center}
+.who-name{font-size:17px;font-weight:700}
+.sec{margin:16px 0 8px}
+.sec-title{font-size:14px;font-weight:700;color:var(--primary);padding:4px 2px 8px;border-bottom:2px solid #c8e6c9;margin-bottom:10px}
+.chip-wrap{display:flex;flex-wrap:wrap;gap:8px}
+.chip{border:1px solid #c8e6c9;background:#fff;border-radius:20px;padding:8px 12px;font-size:13px}
+.chip.on{background:var(--primary);color:#fff;border-color:var(--primary)}
+.preview{background:#f1f8f2;border-radius:8px;padding:8px 10px;margin-top:8px;font-size:13px}
+.totals{background:#e8f5e9;border-radius:8px;padding:10px;margin-top:10px;font-size:14px}
diff --git a/app/src/main/assets/womp/home.html b/app/src/main/assets/womp/home.html
new file mode 100644
index 0000000..8250ff3
--- /dev/null
+++ b/app/src/main/assets/womp/home.html
@@ -0,0 +1,100 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
+ <title>宸ヤ綔鍙�</title>
+ <link rel="stylesheet" href="css.css"/>
+</head>
+<body>
+<div class="wrap">
+ <div class="who-bar">
+ <div class="who-center" id="who"></div>
+ <button class="btn sm ghost" onclick="Womp.logout()">閫�鍑虹櫥褰�</button>
+ </div>
+
+ <div class="sec">
+ <div class="sec-title">宸ヤ綔绛惧埌</div>
+ <div class="muted" id="attendStatus" style="margin:-4px 2px 8px">褰撳墠锛氭湭绛惧埌</div>
+ <div class="grid">
+ <div class="tile" onclick="attend('in')">涓婄彮绛惧埌</div>
+ <div class="tile" onclick="attend('out')">涓嬬彮绛鹃��</div>
+ </div>
+ <div id="attendMsg"></div>
+ </div>
+
+ <div class="sec">
+ <div class="sec-title">鍥炴敹浣滀笟</div>
+ <div class="grid">
+ <div class="tile" onclick="go('checkin.html')">鎵爜鎵撳崱</div>
+ <div class="tile" onclick="go('recycle.html')">鐜板満鍥炴敹</div>
+ <div class="tile" onclick="go('appointments.html')">棰勭害璁㈠崟</div>
+ <div class="tile" onclick="go('recycle.html?mode=apt')">棰勭害鍥炴敹</div>
+ </div>
+ </div>
+
+ <div class="sec">
+ <div class="sec-title">璁㈠崟鍙戣揣</div>
+ <div class="grid">
+ <div class="tile" onclick="go('orders.html')">璁㈠崟鍙戣揣</div>
+ </div>
+ </div>
+
+ <div class="sec">
+ <div class="sec-title">閿�鍞笌璁¢噺</div>
+ <div class="grid">
+ <div class="tile" onclick="go('wastesale.html')">鍙洖鏀剁墿閿�鍞�</div>
+ <div class="tile" onclick="go('siteweight.html')">鍘嬬缉绔欒閲�</div>
+ </div>
+ </div>
+</div>
+<script src="app.js"></script>
+<script>
+try {
+ var s = JSON.parse(Womp.getSorter() || "{}");
+ $("who").innerHTML = "<div class='who-name'>" + (s.businessName || "鍒嗘嫞鍛�") + "</div>" +
+ "<div class='muted'>璐﹀彿 " + (s.sorterId || "-") + " 路 鎵嬫満 " + (s.phone || "-") + "</div>";
+} catch (e) {}
+function refreshAttend() {
+ var on = false;
+ try { on = !!Womp.isCheckedIn(); } catch (e) {}
+ $("attendStatus").textContent = on ? "褰撳墠锛氬凡涓婄彮绛惧埌" : "褰撳墠锛氭湭绛惧埌";
+}
+refreshAttend();
+var attendBusy = false;
+function attend(type) {
+ if (attendBusy) return;
+ attendBusy = true;
+ var checkIn = type === "in";
+ $("attendMsg").innerHTML = "<div class='muted'>姝e湪鑾峰彇瀹氫綅鈥�</div>";
+ toast("姝e湪鑾峰彇瀹氫綅鈥�");
+ getLocation().then(function (loc) {
+ if (!loc || loc.longitude == null || loc.latitude == null) {
+ throw new Error("鏃犳硶鑾峰彇瀹氫綅锛岃寮�鍚疓PS鍚庨噸璇�");
+ }
+ var path = checkIn ? "/app/push/sorterattendance/checkIn" : "/app/push/sorterattendance/checkOut";
+ return api(path, { longitude: loc.longitude, latitude: loc.latitude });
+ }).then(function (data) {
+ try { Womp.setCheckedIn(checkIn); } catch (e) {}
+ var msg = (data && data.message) || (checkIn ? "绛惧埌鎴愬姛" : "绛鹃��鎴愬姛");
+ $("attendMsg").innerHTML = "<div class='ok' style='padding:12px 0;font-size:16px'>" + msg + "</div>";
+ toast(msg);
+ refreshAttend();
+ }).catch(function (e) {
+ var msg = errMsg(e, checkIn ? "绛惧埌澶辫触" : "绛鹃��澶辫触");
+ if (/浠婃棩宸茬鍒�/.test(msg)) {
+ try { Womp.setCheckedIn(true); } catch (x) {}
+ refreshAttend();
+ } else if (/浠婃棩宸茬閫�|璇峰厛涓婄彮绛惧埌/.test(msg)) {
+ try { Womp.setCheckedIn(false); } catch (x) {}
+ refreshAttend();
+ }
+ $("attendMsg").innerHTML = "<div class='err'>" + msg + "</div>";
+ toast(msg);
+ }).then(function () {
+ attendBusy = false;
+ });
+}
+</script>
+</body>
+</html>
diff --git a/app/src/main/assets/womp/login.html b/app/src/main/assets/womp/login.html
new file mode 100644
index 0000000..df551b1
--- /dev/null
+++ b/app/src/main/assets/womp/login.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
+ <title>鐧诲綍</title>
+ <link rel="stylesheet" href="css.css"/>
+</head>
+<body>
+<div class="wrap">
+ <h1>鍒嗘嫞鍛樼櫥褰�</h1>
+ <div class="card">
+ <label>鎵嬫満鍙�</label>
+ <input id="phone" type="tel" placeholder="璇疯緭鍏ユ墜鏈哄彿" autocomplete="username"/>
+ <label>瀵嗙爜</label>
+ <input id="password" type="password" placeholder="璇疯緭鍏ュ瘑鐮�" autocomplete="current-password"/>
+ <div id="err" class="err"></div>
+ <button class="btn" id="loginBtn" onclick="login()">鐧诲綍</button>
+ <p class="muted">鎵嬫満鍙峰拰瀵嗙爜浼氫繚瀛樺湪鏈満锛屼笅娆℃墦寮�鑷姩濉叆銆傜櫥褰曞悗 7 澶╁唴鏃犻渶閲嶆柊鐧诲綍銆�</p>
+ </div>
+</div>
+<script src="app.js"></script>
+<script>
+function fillSaved() {
+ try {
+ $("phone").value = Womp.getSavedPhone() || "";
+ $("password").value = Womp.getSavedPassword() || "";
+ } catch (e) {}
+}
+fillSaved();
+function login() {
+ var phone = $("phone").value.trim();
+ var password = $("password").value;
+ var btn = $("loginBtn");
+ $("err").textContent = "";
+ if (!phone || !password) { $("err").textContent = "璇疯緭鍏ユ墜鏈哄彿鍜屽瘑鐮�"; return; }
+ try { Womp.saveCredentials(phone, password); } catch (e) {}
+ btn.disabled = true;
+ btn.textContent = "鐧诲綍涓��";
+ api("/app/user/login/sorterPassword", { phone: phone, password: password }).then(function (data) {
+ data.phone = phone;
+ data.password = password;
+ Womp.loginSuccess(JSON.stringify(data));
+ }).catch(function (e) {
+ $("err").textContent = loginErrMsg(e);
+ }).then(function () {
+ btn.disabled = false;
+ btn.textContent = "鐧诲綍";
+ });
+}
+</script>
+</body>
+</html>
diff --git a/app/src/main/assets/womp/orders.html b/app/src/main/assets/womp/orders.html
new file mode 100644
index 0000000..08ff4bf
--- /dev/null
+++ b/app/src/main/assets/womp/orders.html
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
+ <title>璁㈠崟鍙戣揣</title>
+ <link rel="stylesheet" href="css.css"/>
+</head>
+<body>
+<div class="wrap">
+ <div class="page-head">
+ <h1>璁㈠崟鍙戣揣</h1>
+ <a class="back-link" href="home.html">杩斿洖宸ヤ綔鍙�</a>
+ </div>
+ <div class="card">
+ <select id="status" onchange="load()">
+ <option value="1">寰呭彂璐�</option>
+ <option value="">鍏ㄩ儴</option>
+ <option value="2">寰呮敹璐�</option>
+ <option value="3">寰呰瘎浠�</option>
+ <option value="4">宸插畬鎴�</option>
+ </select>
+ <div id="list"></div>
+ </div>
+</div>
+<script src="app.js"></script>
+<script>
+var names = ["寰呬粯娆�","寰呭彂璐�","寰呮敹璐�","寰呰瘎浠�","浜ゆ槗瀹屾垚","閫�娆句腑","宸查��娆�","宸插叧闂�"];
+function load() {
+ api("/app/order/sorter/orderPage", { page: 1, size: 50, status: $("status").value }).then(function (res) {
+ var list = (res && res.list) || [];
+ if (!list.length) { $("list").innerHTML = "<div class='muted'>鏆傛棤璁㈠崟</div>"; return; }
+ $("list").innerHTML = list.map(function (o) {
+ var goods = (o.goodsList || []).map(function (g) { return (g.title || g.goodsInfo && g.goodsInfo.title || "") + " x" + (g.count || 1); }).join("锛�");
+ var addr = o.address ? ((o.address.contact || "") + " " + (o.address.phone || "") + "<br>" + (o.address.address || o.address.detail || "")) : "";
+ return "<div class='item'><b>" + (o.nickName || "") + "</b> " + (o.orderNum || "") +
+ "<div class='muted'>" + (names[o.status] || o.status) + " 路 锟�" + o.price + "</div>" +
+ "<div class='muted'>" + goods + "</div><div class='muted'>" + addr + "</div>" +
+ (o.status == 1 ? "<button class='btn' onclick='deliver(" + o.id + ")'>鐜板満鍙戞斁</button>" : "") +
+ "</div>";
+ }).join("");
+ }).catch(function (e) { toast(e.message); });
+}
+function deliver(id) {
+ api("/app/order/sorter/deliver", { orderId: id }).then(function () {
+ toast("宸茬幇鍦哄彂鏀�"); load();
+ }).catch(function (e) { toast(e.message); });
+}
+load();
+</script>
+</body>
+</html>
diff --git a/app/src/main/assets/womp/recycle.html b/app/src/main/assets/womp/recycle.html
new file mode 100644
index 0000000..17846d0
--- /dev/null
+++ b/app/src/main/assets/womp/recycle.html
@@ -0,0 +1,152 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
+ <title>鍥炴敹娓呭崟</title>
+ <link rel="stylesheet" href="css.css"/>
+</head>
+<body>
+<div class="wrap">
+ <div class="page-head">
+ <h1 id="title">鐜板満鍥炴敹</h1>
+ <a class="back-link" href="home.html">杩斿洖宸ヤ綔鍙�</a>
+ </div>
+ <div class="card">
+ <div id="userBox" class="muted">璇峰厛鎵弿</div>
+ <button class="btn" id="scanBtn" onclick="doScan()">鎵弿鍥炴敹鐮�</button>
+ </div>
+ <div class="card">
+ <div class="sec-title">閫夋嫨鍝佺被</div>
+ <div id="cats" class="muted">姝e湪鍔犺浇鍝佺被鈥�</div>
+ <label>璁¢噺(kg/浠�)</label>
+ <input id="weight" type="number" step="0.01" oninput="preview()"/>
+ <div id="preview" class="preview">璇烽�夋嫨鍝佺被骞跺~鍐欒閲�</div>
+ <button class="btn ghost" onclick="addItem()">娣诲姞涓�鏉�</button>
+ <div id="list"></div>
+ <div id="totals" class="totals" style="display:none"></div>
+ <button class="btn" onclick="save()">淇濆瓨璁㈠崟</button>
+ </div>
+</div>
+<script src="app.js"></script>
+<script>
+var mode = qs("mode");
+var user = null, appointmentId = null, items = [], catalog = [], selectedId = "";
+if (mode === "apt") {
+ $("title").textContent = "棰勭害鍥炴敹";
+ $("userBox").textContent = "璇锋壂鎻忛绾﹀崟浜岀淮鐮�";
+ $("scanBtn").textContent = "鎵弿棰勭害鍗�";
+}
+api("/app/shop/sorter/itemList", {}).then(function (list) {
+ catalog = list || [];
+ renderCats();
+}).catch(function (e) {
+ $("cats").innerHTML = "<div class='err'>" + (e.message || "鍝佺被鍔犺浇澶辫触") + "</div>";
+});
+
+function renderCats() {
+ if (!catalog.length) { $("cats").innerHTML = "<div class='err'>鏆傛棤鍥炴敹鍝佺被锛岃鍦ㄥ悗鍙扮淮鎶�</div>"; return; }
+ var groups = {};
+ catalog.forEach(function (i) {
+ var g = i.category || "鍏朵粬";
+ if (!groups[g]) groups[g] = [];
+ groups[g].push(i);
+ });
+ $("cats").innerHTML = Object.keys(groups).map(function (g) {
+ return "<div class='muted' style='margin:8px 0 4px'>" + g + "</div><div class='chip-wrap'>" +
+ groups[g].map(function (i) {
+ return "<div class='chip" + (selectedId == i.itemId ? " on" : "") + "' onclick='pick(\"" + i.itemId + "\")'>" +
+ i.itemName + " 锟�" + money(i.price) + "/" + (i.unit || "kg") + "</div>";
+ }).join("") + "</div>";
+ }).join("");
+}
+function pick(id) { selectedId = String(id); renderCats(); preview(); }
+function currentCat() {
+ return catalog.filter(function (i) { return String(i.itemId) === String(selectedId); })[0];
+}
+function preview() {
+ var cat = currentCat();
+ var w = Number($("weight").value);
+ if (!cat) { $("preview").textContent = "璇烽�夋嫨鍝佺被骞跺~鍐欒閲�"; return; }
+ if (!w) {
+ $("preview").innerHTML = "宸查�� <b>" + cat.itemName + "</b>锛屽崟浠� 锟�" + money(cat.price) + "/" + (cat.unit || "kg") +
+ "锛岀⒊绉垎 " + money(cat.carbonPoint) + "/" + (cat.unit || "kg");
+ return;
+ }
+ $("preview").innerHTML = cat.itemName + " 脳 " + w + (cat.unit || "") +
+ "銆�鍗曚环 锟�" + money(cat.price) + "銆�灏忚 锟�" + money(w * cat.price) +
+ "銆�纰崇Н鍒� " + money(w * cat.carbonPoint);
+}
+function doScan() {
+ if (mode === "apt") scanApt();
+ else scanUser();
+}
+function scanUser() {
+ scan().then(function (code) {
+ if (!code) return;
+ return api("/app/shop/sorter/checkUser", { unionid: code });
+ }).then(function (u) {
+ if (!u) return;
+ user = u;
+ $("userBox").innerHTML = userHtml(u, "鍥炴敹鐮佸凡璇嗗埆");
+ }).catch(function (e) { toast(e.message || "涓嶆槸骞冲彴鐢ㄦ埛"); });
+}
+function scanApt() {
+ scan().then(function (code) {
+ if (!code) return;
+ return api("/app/shop/sorter/scanAppointment", { appointmentNo: code });
+ }).then(function (apt) {
+ if (!apt) return;
+ appointmentId = apt.id;
+ if (!apt.campusUser) { toast("棰勭害鍗曠己灏戠敤鎴蜂俊鎭�"); return; }
+ user = apt.campusUser;
+ $("userBox").innerHTML = userHtml(user, "棰勭害鍙� " + (apt.appointmentNo || "") + " 路 " + (apt.contactName || "") + " " + (apt.contactPhone || ""));
+ }).catch(function (e) { toast(e.message || "棰勭害鍗曟棤鏁�"); });
+}
+function addItem() {
+ var cat = currentCat();
+ var w = Number($("weight").value);
+ if (!cat || !w) { toast("璇烽�夋嫨鍝佺被骞跺~鍐欒閲�"); return; }
+ items.push({
+ itemId: cat.itemId,
+ itemName: cat.itemName,
+ unit: cat.unit || "kg",
+ weight: w,
+ unitPrice: Number(cat.price || 0),
+ amount: Number((w * Number(cat.price || 0)).toFixed(2)),
+ carbonPoint: Number((w * Number(cat.carbonPoint || 0)).toFixed(2))
+ });
+ render();
+ $("weight").value = "";
+ preview();
+}
+function render() {
+ $("list").innerHTML = items.map(function (i, idx) {
+ return "<div class='item'><b>" + i.itemName + "</b> " + i.weight + (i.unit || "") +
+ "銆�锟�" + money(i.unitPrice) + "銆�灏忚 锟�" + money(i.amount) +
+ "銆�纰崇Н鍒� " + money(i.carbonPoint) +
+ " <span class='muted' onclick='del(" + idx + ")'>鍒犻櫎</span></div>";
+ }).join("");
+ var tw = 0, ta = 0, tc = 0;
+ items.forEach(function (i) { tw += Number(i.weight); ta += Number(i.amount); tc += Number(i.carbonPoint); });
+ if (!items.length) { $("totals").style.display = "none"; return; }
+ $("totals").style.display = "block";
+ $("totals").innerHTML = "鍚堣璁¢噺 " + money(tw) + "銆�鎬婚噾棰� 锟�" + money(ta) + "銆�鎬荤⒊绉垎 " + money(tc);
+}
+function del(i) { items.splice(i, 1); render(); }
+function save() {
+ if (!user) { toast(mode === "apt" ? "璇峰厛鎵弿棰勭害鍗�" : "璇峰厛鎵弿鐢ㄦ埛"); return; }
+ if (mode === "apt" && !appointmentId) { toast("璇峰厛鎵弿棰勭害鍗�"); return; }
+ if (!items.length) { toast("璇锋坊鍔犳竻鍗�"); return; }
+ api("/app/shop/sorter/saveOrder", {
+ unionid: user.unionid,
+ appointmentId: appointmentId,
+ items: items
+ }).then(function (r) {
+ toast("宸蹭繚瀛� " + r.orderNo);
+ items = []; render();
+ }).catch(function (e) { toast(e.message || "淇濆瓨澶辫触"); });
+}
+</script>
+</body>
+</html>
diff --git a/app/src/main/assets/womp/siteweight.html b/app/src/main/assets/womp/siteweight.html
new file mode 100644
index 0000000..550a852
--- /dev/null
+++ b/app/src/main/assets/womp/siteweight.html
@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
+ <title>鍘嬬缉绔欒閲�</title>
+ <link rel="stylesheet" href="css.css"/>
+</head>
+<body>
+<div class="wrap">
+ <div class="page-head">
+ <h1>鍘嬬缉绔欒閲�</h1>
+ <a class="back-link" href="home.html">杩斿洖宸ヤ綔鍙�</a>
+ </div>
+ <div class="card">
+ <label class="req">鍘嬬缉绔�</label>
+ <select id="businessId"></select>
+ <label>鍨冨溇绫诲瀷</label>
+ <input value="骞插瀮鍦� SW64" disabled/>
+ <label class="req">閲嶉噺(鍚�/鎴栭瀹氳浇鑽�)</label>
+ <input id="weight" type="number" step="0.01"/>
+ <label>澶囨敞</label>
+ <textarea id="remark" rows="3"></textarea>
+ <button class="btn" onclick="save()">涓婃姤</button>
+ </div>
+ <div class="card" id="list"></div>
+</div>
+<script src="app.js"></script>
+<script>
+api("/app/basicdata/options/sites", { siteType: "鍘嬬缉绔�" }).then(function (rows) {
+ if (!(rows || []).length) {
+ $("businessId").innerHTML = "<option value=''>鏆傛棤鏈儴闂ㄥ帇缂╃珯</option>";
+ toast("鏈壘鍒版湰閮ㄩ棬鍘嬬缉绔�");
+ return;
+ }
+ $("businessId").innerHTML = (rows || []).map(function (r) {
+ return "<option value='" + r.businessId + "'>" + r.businessName + "</option>";
+ }).join("");
+}).catch(function (e) { toast(e.message); });
+
+function save() {
+ if (!$("businessId").value) { toast("璇烽�夋嫨鍘嬬缉绔�"); return; }
+ api("/app/push/sorter/siteweightAdd", {
+ businessId: $("businessId").value,
+ deviceNo: "浜哄伐濉姤",
+ garbageType: "SW64",
+ weight: Number($("weight").value),
+ remark: $("remark").value
+ }).then(function () {
+ toast("宸蹭笂鎶�"); $("weight").value = ""; load();
+ }).catch(function (e) { toast(e.message); });
+}
+function load() {
+ api("/app/push/sorter/siteweightPage", { page: 1, size: 30 }).then(function (res) {
+ var list = (res && res.list) || [];
+ $("list").innerHTML = list.length ? list.map(function (r) {
+ return "<div class='item'>" + (r.businessName || r.businessId) + " " + r.weight +
+ "<div class='muted'>" + (r.uploadTime || "") + " 路 " + (r.deviceNo || "") + "</div></div>";
+ }).join("") : "<div class='muted'>鏆傛棤璁板綍</div>";
+ }).catch(function (e) { toast(e.message); });
+}
+load();
+</script>
+</body>
+</html>
diff --git a/app/src/main/assets/womp/wastesale.html b/app/src/main/assets/womp/wastesale.html
new file mode 100644
index 0000000..8263576
--- /dev/null
+++ b/app/src/main/assets/womp/wastesale.html
@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
+ <title>鍙洖鏀剁墿閿�鍞�</title>
+ <link rel="stylesheet" href="css.css"/>
+</head>
+<body>
+<div class="wrap">
+ <div class="page-head">
+ <h1>鍙洖鏀剁墿閿�鍞�</h1>
+ <a class="back-link" href="home.html">杩斿洖宸ヤ綔鍙�</a>
+ </div>
+ <div class="card">
+ <label>鏀堕泦鐐�</label>
+ <select id="siteId"></select>
+ <label class="req">鍨冨溇绉嶇被</label>
+ <select id="wasteCode"></select>
+ <label class="req">鏀惰喘鏂�</label>
+ <select id="buyerCode"></select>
+ <label class="req">閲嶉噺(kg)</label>
+ <input id="weight" type="number" step="0.01" oninput="calc()"/>
+ <label>鍗曚环</label>
+ <input id="unitPrice" type="number" step="0.01" oninput="calc()"/>
+ <div id="subtotal" class="preview">灏忚閲戦 锟�0.00</div>
+ <button class="btn" onclick="save()">淇濆瓨</button>
+ </div>
+ <div class="sec-title">鎴戠殑閿�鍞褰�</div>
+ <div class="card" id="list"></div>
+</div>
+<script src="app.js"></script>
+<script>
+function fill(sel, rows, v, l, placeholder) {
+ var opts = placeholder ? "<option value=''>" + placeholder + "</option>" : "";
+ opts += (rows || []).map(function (r) {
+ return "<option value='" + r[v] + "'>" + r[l] + "</option>";
+ }).join("");
+ $(sel).innerHTML = opts;
+}
+function calc() {
+ var n = Number($("weight").value || 0) * Number($("unitPrice").value || 0);
+ $("subtotal").textContent = "灏忚閲戦 锟�" + money(n);
+}
+Promise.all([
+ api("/app/basicdata/options/sites", { siteType: "鏀堕泦鐐�" }),
+ api("/app/basicdata/options/wastetypes", {}),
+ api("/app/basicdata/options/buyers", {})
+]).then(function (arr) {
+ fill("siteId", arr[0], "businessId", "businessName", "鍙笉閫�");
+ fill("wasteCode", arr[1], "code", "name", "璇烽�夋嫨");
+ fill("buyerCode", arr[2], "code", "name", "璇烽�夋嫨");
+ if (!(arr[2] || []).length) toast("鏆傛棤鏀惰喘鏂癸紝璇峰湪鍚庡彴缁存姢");
+ $("wasteCode").onchange = function () {
+ var w = (arr[1] || []).filter(function (i) { return i.code == $("wasteCode").value; })[0];
+ if (w) $("unitPrice").value = w.price;
+ calc();
+ };
+}).catch(function (e) { toast(e.message); });
+
+function save() {
+ if (!$("buyerCode").value) { toast("璇烽�夋嫨鏀惰喘鏂�"); return; }
+ if (!$("wasteCode").value) { toast("璇烽�夋嫨鍨冨溇绉嶇被"); return; }
+ if (!Number($("weight").value)) { toast("璇峰~鍐欓噸閲�"); return; }
+ api("/app/operation/wastesale/saleAdd", {
+ siteId: $("siteId").value || null,
+ wasteCode: $("wasteCode").value,
+ buyerCode: $("buyerCode").value,
+ weight: Number($("weight").value),
+ unitPrice: Number($("unitPrice").value)
+ }).then(function () {
+ toast("宸蹭繚瀛�"); $("weight").value = ""; calc(); load();
+ }).catch(function (e) { toast(e.message); });
+}
+function load() {
+ api("/app/operation/wastesale/salePage", { page: 1, size: 30 }).then(function (res) {
+ var list = (res && res.list) || [];
+ $("list").innerHTML = list.length ? list.map(function (r) {
+ return "<div class='item'>" + (r.wasteName || r.wasteCode) + " " + r.weight + "kg 路 锟�" + r.totalPrice +
+ "<div class='muted'>" + (r.siteName || "") + " 路 " + (r.buyerName || "") + " 路 " + (r.dateTime || "") + "</div></div>";
+ }).join("") : "<div class='muted'>鏆傛棤璁板綍</div>";
+ }).catch(function (e) { toast(e.message); });
+}
+load();
+</script>
+</body>
+</html>
diff --git a/app/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java b/app/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java
new file mode 100644
index 0000000..9566b18
--- /dev/null
+++ b/app/src/main/java/com/google/gson/typeadapters/RuntimeTypeAdapterFactory.java
@@ -0,0 +1,338 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * From 0eb681b, adapted for Gadgetbridge to support null labels.
+ */
+
+package com.google.gson.typeadapters;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Adapts values whose runtime type may differ from their declaration type. This is necessary when a
+ * field's type is not the same type that GSON should create when deserializing that field. For
+ * example, consider these types:
+ *
+ * <pre>{@code
+ * abstract class Shape {
+ * int x;
+ * int y;
+ * }
+ * class Circle extends Shape {
+ * int radius;
+ * }
+ * class Rectangle extends Shape {
+ * int width;
+ * int height;
+ * }
+ * class Diamond extends Shape {
+ * int width;
+ * int height;
+ * }
+ * class Drawing {
+ * Shape bottomShape;
+ * Shape topShape;
+ * }
+ * }</pre>
+ *
+ * <p>Without additional type information, the serialized JSON is ambiguous. Is the bottom shape in
+ * this drawing a rectangle or a diamond?
+ *
+ * <pre>{@code
+ * {
+ * "bottomShape": {
+ * "width": 10,
+ * "height": 5,
+ * "x": 0,
+ * "y": 0
+ * },
+ * "topShape": {
+ * "radius": 2,
+ * "x": 4,
+ * "y": 1
+ * }
+ * }
+ * }</pre>
+ *
+ * This class addresses this problem by adding type information to the serialized JSON and honoring
+ * that type information when the JSON is deserialized:
+ *
+ * <pre>{@code
+ * {
+ * "bottomShape": {
+ * "type": "Diamond",
+ * "width": 10,
+ * "height": 5,
+ * "x": 0,
+ * "y": 0
+ * },
+ * "topShape": {
+ * "type": "Circle",
+ * "radius": 2,
+ * "x": 4,
+ * "y": 1
+ * }
+ * }
+ * }</pre>
+ *
+ * Both the type field name ({@code "type"}) and the type labels ({@code "Rectangle"}) are
+ * configurable.
+ *
+ * <h2>Registering Types</h2>
+ *
+ * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field name to the
+ * {@link #of} factory method. If you don't supply an explicit type field name, {@code "type"} will
+ * be used.
+ *
+ * <pre>{@code
+ * RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory
+ * = RuntimeTypeAdapterFactory.of(Shape.class, "type");
+ * }</pre>
+ *
+ * Next register all of your subtypes. Every subtype must be explicitly registered. This protects
+ * your application from injection attacks. If you don't supply an explicit type label, the type's
+ * simple name will be used.
+ *
+ * <pre>{@code
+ * shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
+ * shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
+ * shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
+ * }</pre>
+ *
+ * Finally, register the type adapter factory in your application's GSON builder:
+ *
+ * <pre>{@code
+ * Gson gson = new GsonBuilder()
+ * .registerTypeAdapterFactory(shapeAdapterFactory)
+ * .create();
+ * }</pre>
+ *
+ * Like {@code GsonBuilder}, this API supports chaining:
+ *
+ * <pre>{@code
+ * RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
+ * .registerSubtype(Rectangle.class)
+ * .registerSubtype(Circle.class)
+ * .registerSubtype(Diamond.class);
+ * }</pre>
+ *
+ * <h2>Serialization and deserialization</h2>
+ *
+ * In order to serialize and deserialize a polymorphic object, you must specify the base type
+ * explicitly.
+ *
+ * <pre>{@code
+ * Diamond diamond = new Diamond();
+ * String json = gson.toJson(diamond, Shape.class);
+ * }</pre>
+ *
+ * And then:
+ *
+ * <pre>{@code
+ * Shape shape = gson.fromJson(json, Shape.class);
+ * }</pre>
+ */
+public final class RuntimeTypeAdapterFactory<T> implements TypeAdapterFactory {
+ private final Class<?> baseType;
+ private final String typeFieldName;
+ private final Map<String, Class<?>> labelToSubtype = new LinkedHashMap<>();
+ private final Map<Class<?>, String> subtypeToLabel = new LinkedHashMap<>();
+ private final boolean maintainType;
+ private boolean recognizeSubtypes;
+
+ private RuntimeTypeAdapterFactory(Class<?> baseType, String typeFieldName, boolean maintainType) {
+ if (typeFieldName == null || baseType == null) {
+ throw new NullPointerException();
+ }
+ this.baseType = baseType;
+ this.typeFieldName = typeFieldName;
+ this.maintainType = maintainType;
+ }
+
+ /**
+ * Creates a new runtime type adapter for {@code baseType} using {@code typeFieldName} as the type
+ * field name. Type field names are case sensitive.
+ *
+ * @param maintainType true if the type field should be included in deserialized objects
+ */
+ public static <T> RuntimeTypeAdapterFactory<T> of(
+ Class<T> baseType, String typeFieldName, boolean maintainType) {
+ return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, maintainType);
+ }
+
+ /**
+ * Creates a new runtime type adapter for {@code baseType} using {@code typeFieldName} as the type
+ * field name. Type field names are case sensitive.
+ */
+ public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName) {
+ return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, false);
+ }
+
+ /**
+ * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as the type field
+ * name.
+ */
+ public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType) {
+ return new RuntimeTypeAdapterFactory<>(baseType, "type", false);
+ }
+
+ /**
+ * Ensures that this factory will handle not just the given {@code baseType}, but any subtype of
+ * that type.
+ */
+ @CanIgnoreReturnValue
+ public RuntimeTypeAdapterFactory<T> recognizeSubtypes() {
+ this.recognizeSubtypes = true;
+ return this;
+ }
+
+ /**
+ * Registers {@code type} identified by {@code label}. Labels are case sensitive.
+ *
+ * @throws IllegalArgumentException if either {@code type} or {@code label} have already been
+ * registered on this type adapter.
+ */
+ @CanIgnoreReturnValue
+ public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type, String label) {
+ if (type == null) {
+ throw new NullPointerException();
+ }
+ if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) {
+ throw new IllegalArgumentException("types and labels must be unique");
+ }
+ labelToSubtype.put(label, type);
+ subtypeToLabel.put(type, label);
+ return this;
+ }
+
+ /**
+ * Registers {@code type} identified by its {@link Class#getSimpleName simple name}. Labels are
+ * case sensitive.
+ *
+ * @throws IllegalArgumentException if either {@code type} or its simple name have already been
+ * registered on this type adapter.
+ */
+ @CanIgnoreReturnValue
+ public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type) {
+ return registerSubtype(type, type.getSimpleName());
+ }
+
+ @Override
+ public <R> TypeAdapter<R> create(Gson gson, TypeToken<R> type) {
+ if (type == null) {
+ return null;
+ }
+ Class<?> rawType = type.getRawType();
+ boolean handle =
+ recognizeSubtypes ? baseType.isAssignableFrom(rawType) : baseType.equals(rawType);
+ if (!handle) {
+ return null;
+ }
+
+ final TypeAdapter<JsonElement> jsonElementAdapter = gson.getAdapter(JsonElement.class);
+ final Map<String, TypeAdapter<?>> labelToDelegate = new LinkedHashMap<>();
+ final Map<Class<?>, TypeAdapter<?>> subtypeToDelegate = new LinkedHashMap<>();
+ for (Map.Entry<String, Class<?>> entry : labelToSubtype.entrySet()) {
+ TypeAdapter<?> delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue()));
+ labelToDelegate.put(entry.getKey(), delegate);
+ subtypeToDelegate.put(entry.getValue(), delegate);
+ }
+
+ return new TypeAdapter<R>() {
+ @Override
+ public R read(JsonReader in) throws IOException {
+ JsonElement jsonElement = jsonElementAdapter.read(in);
+ JsonElement labelJsonElement;
+ if (maintainType) {
+ labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName);
+ } else {
+ labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName);
+ }
+
+ if (labelJsonElement == null && !labelToDelegate.containsKey(null)) {
+ throw new JsonParseException(
+ "cannot deserialize "
+ + baseType
+ + " because it does not define a field named "
+ + typeFieldName);
+ }
+ String label = labelJsonElement != null ? labelJsonElement.getAsString() : null;
+ @SuppressWarnings("unchecked") // registration requires that subtype extends T
+ TypeAdapter<R> delegate = (TypeAdapter<R>) labelToDelegate.get(label);
+ if (delegate == null) {
+ throw new JsonParseException(
+ "cannot deserialize "
+ + baseType
+ + " subtype named "
+ + label
+ + "; did you forget to register a subtype?");
+ }
+ return delegate.fromJsonTree(jsonElement);
+ }
+
+ @Override
+ public void write(JsonWriter out, R value) throws IOException {
+ Class<?> srcType = value.getClass();
+ String label = subtypeToLabel.get(srcType);
+ @SuppressWarnings("unchecked") // registration requires that subtype extends T
+ TypeAdapter<R> delegate = (TypeAdapter<R>) subtypeToDelegate.get(srcType);
+ if (delegate == null) {
+ throw new JsonParseException(
+ "cannot serialize " + srcType.getName() + "; did you forget to register a subtype?");
+ }
+ JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject();
+
+ if (maintainType) {
+ jsonElementAdapter.write(out, jsonObject);
+ return;
+ }
+
+ JsonObject clone = new JsonObject();
+
+ if (jsonObject.has(typeFieldName)) {
+ throw new JsonParseException(
+ "cannot serialize "
+ + srcType.getName()
+ + " because it already defines a field named "
+ + typeFieldName);
+ }
+ if (label != null) {
+ clone.add(typeFieldName, new JsonPrimitive(label));
+ } else {
+ clone.add(typeFieldName, JsonNull.INSTANCE);
+ }
+
+ for (Map.Entry<String, JsonElement> e : jsonObject.entrySet()) {
+ clone.add(e.getKey(), e.getValue());
+ }
+ jsonElementAdapter.write(out, clone);
+ }
+ }.nullSafe();
+ }
+}
diff --git a/app/src/main/java/com/mobeta/android/dslv/CheckableLinearLayout.java b/app/src/main/java/com/mobeta/android/dslv/CheckableLinearLayout.java
new file mode 100644
index 0000000..1c3c716
--- /dev/null
+++ b/app/src/main/java/com/mobeta/android/dslv/CheckableLinearLayout.java
@@ -0,0 +1,38 @@
+package com.mobeta.android.dslv;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.Checkable;
+import android.widget.LinearLayout;
+
+public class CheckableLinearLayout extends LinearLayout implements Checkable {
+
+ private static final int CHECKABLE_CHILD_INDEX = 0;
+ private Checkable child;
+
+ public CheckableLinearLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ child = (Checkable) getChildAt(CHECKABLE_CHILD_INDEX);
+ }
+
+ @Override
+ public boolean isChecked() {
+ return child.isChecked();
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ child.setChecked(checked);
+ }
+
+ @Override
+ public void toggle() {
+ child.toggle();
+ }
+
+}
diff --git a/app/src/main/java/com/mobeta/android/dslv/DragSortController.java b/app/src/main/java/com/mobeta/android/dslv/DragSortController.java
new file mode 100644
index 0000000..06f91cd
--- /dev/null
+++ b/app/src/main/java/com/mobeta/android/dslv/DragSortController.java
@@ -0,0 +1,474 @@
+package com.mobeta.android.dslv;
+
+import android.graphics.Point;
+import android.view.GestureDetector;
+import android.view.HapticFeedbackConstants;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.widget.AdapterView;
+
+/**
+ * Class that starts and stops item drags on a {@link DragSortListView}
+ * based on touch gestures. This class also inherits from
+ * {@link SimpleFloatViewManager}, which provides basic float View
+ * creation.
+ *
+ * An instance of this class is meant to be passed to the methods
+ * {@link DragSortListView#setTouchListener()} and
+ * {@link DragSortListView#setFloatViewManager()} of your
+ * {@link DragSortListView} instance.
+ */
+public class DragSortController extends SimpleFloatViewManager implements View.OnTouchListener,
+ GestureDetector.OnGestureListener {
+
+ /**
+ * Drag init mode enum.
+ */
+ public static final int ON_DOWN = 0;
+ public static final int ON_DRAG = 1;
+ public static final int ON_LONG_PRESS = 2;
+
+ private int mDragInitMode = ON_DOWN;
+
+ private boolean mSortEnabled = true;
+
+ /**
+ * Remove mode enum.
+ */
+ public static final int CLICK_REMOVE = 0;
+ public static final int FLING_REMOVE = 1;
+
+ /**
+ * The current remove mode.
+ */
+ private int mRemoveMode;
+
+ private boolean mRemoveEnabled = false;
+ private boolean mIsRemoving = false;
+
+ private GestureDetector mDetector;
+
+ private GestureDetector mFlingRemoveDetector;
+
+ private int mTouchSlop;
+
+ public static final int MISS = -1;
+
+ private int mHitPos = MISS;
+ private int mFlingHitPos = MISS;
+
+ private int mClickRemoveHitPos = MISS;
+
+ private int[] mTempLoc = new int[2];
+
+ private int mItemX;
+ private int mItemY;
+
+ private int mCurrX;
+ private int mCurrY;
+
+ private boolean mDragging = false;
+
+ private float mFlingSpeed = 500f;
+
+ private int mDragHandleId;
+
+ private int mClickRemoveId;
+
+ private int mFlingHandleId;
+ private boolean mCanDrag;
+
+ private DragSortListView mDslv;
+ private int mPositionX;
+
+ /**
+ * Calls {@link #DragSortController(DragSortListView, int)} with a
+ * 0 drag handle id, FLING_RIGHT_REMOVE remove mode,
+ * and ON_DOWN drag init. By default, sorting is enabled, and
+ * removal is disabled.
+ *
+ * @param dslv The DSLV instance
+ */
+ public DragSortController(DragSortListView dslv) {
+ this(dslv, 0, ON_DOWN, FLING_REMOVE);
+ }
+
+ public DragSortController(DragSortListView dslv, int dragHandleId, int dragInitMode,
+ int removeMode) {
+
+ this(dslv, dragHandleId, dragInitMode, removeMode, 0);
+ }
+
+ public DragSortController(DragSortListView dslv, int dragHandleId, int dragInitMode,
+ int removeMode, int clickRemoveId) {
+
+ this(dslv, dragHandleId, dragInitMode, removeMode, clickRemoveId, 0);
+ }
+
+ /**
+ * By default, sorting is enabled, and removal is disabled.
+ *
+ * @param dslv The DSLV instance
+ * @param dragHandleId The resource id of the View that represents
+ * the drag handle in a list item.
+ */
+ public DragSortController(DragSortListView dslv, int dragHandleId, int dragInitMode,
+ int removeMode, int clickRemoveId, int flingHandleId) {
+
+ super(dslv);
+ mDslv = dslv;
+ mDetector = new GestureDetector(dslv.getContext(), this);
+ mFlingRemoveDetector = new GestureDetector(dslv.getContext(), mFlingRemoveListener);
+ mFlingRemoveDetector.setIsLongpressEnabled(false);
+ mTouchSlop = ViewConfiguration.get(dslv.getContext()).getScaledTouchSlop();
+ mDragHandleId = dragHandleId;
+ mClickRemoveId = clickRemoveId;
+ mFlingHandleId = flingHandleId;
+ setRemoveMode(removeMode);
+ setDragInitMode(dragInitMode);
+ }
+
+
+ public int getDragInitMode() {
+ return mDragInitMode;
+ }
+
+ /**
+ * Set how a drag is initiated. Needs to be one of
+ * {@link ON_DOWN}, {@link ON_DRAG}, or {@link ON_LONG_PRESS}.
+ *
+ * @param mode The drag init mode.
+ */
+ public void setDragInitMode(int mode) {
+ mDragInitMode = mode;
+ }
+
+ /**
+ * Enable/Disable list item sorting. Disabling is useful if only item
+ * removal is desired. Prevents drags in the vertical direction.
+ *
+ * @param enabled Set <code>true</code> to enable list
+ * item sorting.
+ */
+ public void setSortEnabled(boolean enabled) {
+ mSortEnabled = enabled;
+ }
+
+ public boolean isSortEnabled() {
+ return mSortEnabled;
+ }
+
+ /**
+ * One of {@link CLICK_REMOVE}, {@link FLING_RIGHT_REMOVE},
+ * {@link FLING_LEFT_REMOVE},
+ * {@link SLIDE_RIGHT_REMOVE}, or {@link SLIDE_LEFT_REMOVE}.
+ */
+ public void setRemoveMode(int mode) {
+ mRemoveMode = mode;
+ }
+
+ public int getRemoveMode() {
+ return mRemoveMode;
+ }
+
+ /**
+ * Enable/Disable item removal without affecting remove mode.
+ */
+ public void setRemoveEnabled(boolean enabled) {
+ mRemoveEnabled = enabled;
+ }
+
+ public boolean isRemoveEnabled() {
+ return mRemoveEnabled;
+ }
+
+ /**
+ * Set the resource id for the View that represents the drag
+ * handle in a list item.
+ *
+ * @param id An android resource id.
+ */
+ public void setDragHandleId(int id) {
+ mDragHandleId = id;
+ }
+
+ /**
+ * Set the resource id for the View that represents the fling
+ * handle in a list item.
+ *
+ * @param id An android resource id.
+ */
+ public void setFlingHandleId(int id) {
+ mFlingHandleId = id;
+ }
+
+ /**
+ * Set the resource id for the View that represents click
+ * removal button.
+ *
+ * @param id An android resource id.
+ */
+ public void setClickRemoveId(int id) {
+ mClickRemoveId = id;
+ }
+
+ /**
+ * Sets flags to restrict certain motions of the floating View
+ * based on DragSortController settings (such as remove mode).
+ * Starts the drag on the DragSortListView.
+ *
+ * @param position The list item position (includes headers).
+ * @param deltaX Touch x-coord minus left edge of floating View.
+ * @param deltaY Touch y-coord minus top edge of floating View.
+ *
+ * @return True if drag started, false otherwise.
+ */
+ public boolean startDrag(int position, int deltaX, int deltaY) {
+
+ int dragFlags = 0;
+ if (mSortEnabled && !mIsRemoving) {
+ dragFlags |= DragSortListView.DRAG_POS_Y | DragSortListView.DRAG_NEG_Y;
+ }
+ if (mRemoveEnabled && mIsRemoving) {
+ dragFlags |= DragSortListView.DRAG_POS_X;
+ dragFlags |= DragSortListView.DRAG_NEG_X;
+ }
+
+ mDragging = mDslv.startDrag(position - mDslv.getHeaderViewsCount(), dragFlags, deltaX,
+ deltaY);
+ return mDragging;
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent ev) {
+ if (!mDslv.isDragEnabled() || mDslv.listViewIntercepted()) {
+ return false;
+ }
+
+ mDetector.onTouchEvent(ev);
+ if (mRemoveEnabled && mDragging && mRemoveMode == FLING_REMOVE) {
+ mFlingRemoveDetector.onTouchEvent(ev);
+ }
+
+ int action = ev.getAction() & MotionEvent.ACTION_MASK;
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ mCurrX = (int) ev.getX();
+ mCurrY = (int) ev.getY();
+ break;
+ case MotionEvent.ACTION_UP:
+ if (mRemoveEnabled && mIsRemoving) {
+ int x = mPositionX >= 0 ? mPositionX : -mPositionX;
+ int removePoint = mDslv.getWidth() / 2;
+ if (x > removePoint) {
+ mDslv.stopDragWithVelocity(true, 0);
+ }
+ }
+ case MotionEvent.ACTION_CANCEL:
+ mIsRemoving = false;
+ mDragging = false;
+ break;
+ }
+
+ return false;
+ }
+
+ /**
+ * Overrides to provide fading when slide removal is enabled.
+ */
+ @Override
+ public void onDragFloatView(View floatView, Point position, Point touch) {
+
+ if (mRemoveEnabled && mIsRemoving) {
+ mPositionX = position.x;
+ }
+ }
+
+ /**
+ * Get the position to start dragging based on the ACTION_DOWN
+ * MotionEvent. This function simply calls
+ * {@link #dragHandleHitPosition(MotionEvent)}. Override
+ * to change drag handle behavior;
+ * this function is called internally when an ACTION_DOWN
+ * event is detected.
+ *
+ * @param ev The ACTION_DOWN MotionEvent.
+ *
+ * @return The list position to drag if a drag-init gesture is
+ * detected; MISS if unsuccessful.
+ */
+ public int startDragPosition(MotionEvent ev) {
+ return dragHandleHitPosition(ev);
+ }
+
+ public int startFlingPosition(MotionEvent ev) {
+ return mRemoveMode == FLING_REMOVE ? flingHandleHitPosition(ev) : MISS;
+ }
+
+ /**
+ * Checks for the touch of an item's drag handle (specified by
+ * {@link #setDragHandleId(int)}), and returns that item's position
+ * if a drag handle touch was detected.
+ *
+ * @param ev The ACTION_DOWN MotionEvent.
+ * @return The list position of the item whose drag handle was
+ * touched; MISS if unsuccessful.
+ */
+ public int dragHandleHitPosition(MotionEvent ev) {
+ return viewIdHitPosition(ev, mDragHandleId);
+ }
+
+ public int flingHandleHitPosition(MotionEvent ev) {
+ return viewIdHitPosition(ev, mFlingHandleId);
+ }
+
+ public int viewIdHitPosition(MotionEvent ev, int id) {
+ final int x = (int) ev.getX();
+ final int y = (int) ev.getY();
+
+ int touchPos = mDslv.pointToPosition(x, y); // includes headers/footers
+
+ final int numHeaders = mDslv.getHeaderViewsCount();
+ final int numFooters = mDslv.getFooterViewsCount();
+ final int count = mDslv.getCount();
+
+ // We're only interested if the touch was on an
+ // item that's not a header or footer.
+ if (touchPos != AdapterView.INVALID_POSITION && touchPos >= numHeaders
+ && touchPos < (count - numFooters)) {
+ final View item = mDslv.getChildAt(touchPos - mDslv.getFirstVisiblePosition());
+ final int rawX = (int) ev.getRawX();
+ final int rawY = (int) ev.getRawY();
+
+ View dragBox = id == 0 ? item : (View) item.findViewById(id);
+ if (dragBox != null) {
+ dragBox.getLocationOnScreen(mTempLoc);
+
+ if (rawX > mTempLoc[0] && rawY > mTempLoc[1] &&
+ rawX < mTempLoc[0] + dragBox.getWidth() &&
+ rawY < mTempLoc[1] + dragBox.getHeight()) {
+
+ mItemX = item.getLeft();
+ mItemY = item.getTop();
+
+ return touchPos;
+ }
+ }
+ }
+
+ return MISS;
+ }
+
+ @Override
+ public boolean onDown(MotionEvent ev) {
+ if (mRemoveEnabled && mRemoveMode == CLICK_REMOVE) {
+ mClickRemoveHitPos = viewIdHitPosition(ev, mClickRemoveId);
+ }
+
+ mHitPos = startDragPosition(ev);
+ if (mHitPos != MISS && mDragInitMode == ON_DOWN) {
+ startDrag(mHitPos, (int) ev.getX() - mItemX, (int) ev.getY() - mItemY);
+ }
+
+ mIsRemoving = false;
+ mCanDrag = true;
+ mPositionX = 0;
+ mFlingHitPos = startFlingPosition(ev);
+
+ return true;
+ }
+
+ @Override
+ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
+
+ if (e1 == null || e2 == null) {
+ return false;
+ }
+
+ final int x1 = (int) e1.getX();
+ final int y1 = (int) e1.getY();
+ final int x2 = (int) e2.getX();
+ final int y2 = (int) e2.getY();
+ final int deltaX = x2 - mItemX;
+ final int deltaY = y2 - mItemY;
+
+ if (mCanDrag && !mDragging && (mHitPos != MISS || mFlingHitPos != MISS)) {
+ if (mHitPos != MISS) {
+ if (mDragInitMode == ON_DRAG && Math.abs(y2 - y1) > mTouchSlop && mSortEnabled) {
+ startDrag(mHitPos, deltaX, deltaY);
+ }
+ else if (mDragInitMode != ON_DOWN && Math.abs(x2 - x1) > mTouchSlop
+ && mRemoveEnabled)
+ {
+ mIsRemoving = true;
+ startDrag(mFlingHitPos, deltaX, deltaY);
+ }
+ } else if (mFlingHitPos != MISS) {
+ if (Math.abs(x2 - x1) > mTouchSlop && mRemoveEnabled) {
+ mIsRemoving = true;
+ startDrag(mFlingHitPos, deltaX, deltaY);
+ } else if (Math.abs(y2 - y1) > mTouchSlop) {
+ mCanDrag = false; // if started to scroll the list then
+ // don't allow sorting nor fling-removing
+ }
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public void onLongPress(MotionEvent e) {
+ if (mHitPos != MISS && mDragInitMode == ON_LONG_PRESS) {
+ mDslv.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+ startDrag(mHitPos, mCurrX - mItemX, mCurrY - mItemY);
+ }
+ }
+
+ // complete the OnGestureListener interface
+ @Override
+ public final boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+ return false;
+ }
+
+ // complete the OnGestureListener interface
+ @Override
+ public boolean onSingleTapUp(MotionEvent ev) {
+ if (mRemoveEnabled && mRemoveMode == CLICK_REMOVE) {
+ if (mClickRemoveHitPos != MISS) {
+ mDslv.removeItem(mClickRemoveHitPos - mDslv.getHeaderViewsCount());
+ }
+ }
+ return true;
+ }
+
+ // complete the OnGestureListener interface
+ @Override
+ public void onShowPress(MotionEvent ev) {
+ // do nothing
+ }
+
+ private GestureDetector.OnGestureListener mFlingRemoveListener =
+ new GestureDetector.SimpleOnGestureListener() {
+ @Override
+ public final boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
+ float velocityY) {
+ if (mRemoveEnabled && mIsRemoving) {
+ int w = mDslv.getWidth();
+ int minPos = w / 5;
+ if (velocityX > mFlingSpeed) {
+ if (mPositionX > -minPos) {
+ mDslv.stopDragWithVelocity(true, velocityX);
+ }
+ } else if (velocityX < -mFlingSpeed) {
+ if (mPositionX < minPos) {
+ mDslv.stopDragWithVelocity(true, velocityX);
+ }
+ }
+ mIsRemoving = false;
+ }
+ return false;
+ }
+ };
+
+}
diff --git a/app/src/main/java/com/mobeta/android/dslv/DragSortCursorAdapter.java b/app/src/main/java/com/mobeta/android/dslv/DragSortCursorAdapter.java
new file mode 100644
index 0000000..41454c7
--- /dev/null
+++ b/app/src/main/java/com/mobeta/android/dslv/DragSortCursorAdapter.java
@@ -0,0 +1,233 @@
+package com.mobeta.android.dslv;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import android.content.Context;
+import android.database.Cursor;
+import androidx.cursoradapter.widget.CursorAdapter;
+import android.util.SparseIntArray;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ListAdapter;
+
+
+/**
+ * A subclass of {@link android.widget.CursorAdapter} that provides
+ * reordering of the elements in the Cursor based on completed
+ * drag-sort operations. The reordering is a simple mapping of
+ * list positions into Cursor positions (the Cursor is unchanged).
+ * To persist changes made by drag-sorts, one can retrieve the
+ * mapping with the {@link #getCursorPositions()} method, which
+ * returns the reordered list of Cursor positions.
+ *
+ * An instance of this class is passed
+ * to {@link DragSortListView#setAdapter(ListAdapter)} and, since
+ * this class implements the {@link DragSortListView.DragSortListener}
+ * interface, it is automatically set as the DragSortListener for
+ * the DragSortListView instance.
+ */
+public abstract class DragSortCursorAdapter extends CursorAdapter implements
+ DragSortListView.DragSortListener {
+
+ private static final int REMOVED = -1;
+
+ /**
+ * Key is ListView position, value is Cursor position
+ */
+ private SparseIntArray mListMapping = new SparseIntArray();
+
+ private List<Integer> mRemovedCursorPositions = new ArrayList<Integer>();
+
+ @Deprecated
+ public DragSortCursorAdapter(Context context, Cursor c) {
+ super(context, c);
+ }
+
+ public DragSortCursorAdapter(Context context, Cursor c, boolean autoRequery) {
+ super(context, c, autoRequery);
+ }
+
+ public DragSortCursorAdapter(Context context, Cursor c, int flags) {
+ super(context, c, flags);
+ }
+
+ /**
+ * Swaps Cursor and clears list-Cursor mapping.
+ *
+ * @see android.widget.CursorAdapter#swapCursor(android.database.Cursor)
+ */
+ @Override
+ public Cursor swapCursor(Cursor newCursor) {
+ Cursor old = super.swapCursor(newCursor);
+ resetMappings();
+ return old;
+ }
+
+ /**
+ * Resets list-cursor mapping.
+ */
+ public void reset() {
+ resetMappings();
+ notifyDataSetChanged();
+ }
+
+ private void resetMappings() {
+ mListMapping.clear();
+ mRemovedCursorPositions.clear();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return super.getItem(mListMapping.get(position, position));
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return super.getItemId(mListMapping.get(position, position));
+ }
+
+ @Override
+ public View getDropDownView(int position, View convertView, ViewGroup parent) {
+ return super.getDropDownView(mListMapping.get(position, position), convertView, parent);
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ return super.getView(mListMapping.get(position, position), convertView, parent);
+ }
+
+ /**
+ * On drop, this updates the mapping between Cursor positions
+ * and ListView positions. The Cursor is unchanged. Retrieve
+ * the current mapping with {@link getCursorPositions()}.
+ *
+ * @see DragSortListView.DropListener#drop(int, int)
+ */
+ @Override
+ public void drop(int from, int to) {
+ if (from != to) {
+ int cursorFrom = mListMapping.get(from, from);
+
+ if (from > to) {
+ for (int i = from; i > to; i--) {
+ mListMapping.put(i, mListMapping.get(i - 1, i - 1));
+ }
+ } else {
+ for (int i = from; i < to; i++) {
+ mListMapping.put(i, mListMapping.get(i + 1, i + 1));
+ }
+ }
+ mListMapping.put(to, cursorFrom);
+
+ cleanMapping();
+ notifyDataSetChanged();
+ }
+ }
+
+ /**
+ * On remove, this updates the mapping between Cursor positions
+ * and ListView positions. The Cursor is unchanged. Retrieve
+ * the current mapping with {@link getCursorPositions()}.
+ *
+ * @see DragSortListView.RemoveListener#remove(int)
+ */
+ @Override
+ public void remove(int which) {
+ int cursorPos = mListMapping.get(which, which);
+ if (!mRemovedCursorPositions.contains(cursorPos)) {
+ mRemovedCursorPositions.add(cursorPos);
+ }
+
+ int newCount = getCount();
+ for (int i = which; i < newCount; i++) {
+ mListMapping.put(i, mListMapping.get(i + 1, i + 1));
+ }
+
+ mListMapping.delete(newCount);
+
+ cleanMapping();
+ notifyDataSetChanged();
+ }
+
+ /**
+ * Does nothing. Just completes DragSortListener interface.
+ */
+ @Override
+ public void drag(int from, int to) {
+ // do nothing
+ }
+
+ /**
+ * Remove unnecessary mappings from sparse array.
+ */
+ private void cleanMapping() {
+ List<Integer> toRemove = new ArrayList<Integer>();
+
+ final int size = mListMapping.size();
+ for (int i = 0; i < size; i++) {
+ if (mListMapping.keyAt(i) == mListMapping.valueAt(i)) {
+ toRemove.add(mListMapping.keyAt(i));
+ }
+ }
+
+ for (int position : toRemove) {
+ mListMapping.delete(position);
+ }
+ }
+
+ @Override
+ public int getCount() {
+ return super.getCount() - mRemovedCursorPositions.size();
+ }
+
+ /**
+ * Get the Cursor position mapped to by the provided list position
+ * (given all previously handled drag-sort
+ * operations).
+ *
+ * @param position List position
+ *
+ * @return The mapped-to Cursor position
+ */
+ public int getCursorPosition(int position) {
+ return mListMapping.get(position, position);
+ }
+
+ /**
+ * Get the current order of Cursor positions presented by the
+ * list.
+ */
+ public List<Integer> getCursorPositions() {
+ List<Integer> result = new ArrayList<Integer>();
+
+ for (int i = 0; i < getCount(); i++) {
+ result.add(mListMapping.get(i, i));
+ }
+
+ return result;
+ }
+
+ /**
+ * Get the list position mapped to by the provided Cursor position.
+ * If the provided Cursor position has been removed by a drag-sort,
+ * this returns {@link #REMOVED}.
+ *
+ * @param cursorPosition A Cursor position
+ * @return The mapped-to list position or REMOVED
+ */
+ public int getListPosition(int cursorPosition) {
+ if (mRemovedCursorPositions.contains(cursorPosition)) {
+ return REMOVED;
+ }
+
+ int index = mListMapping.indexOfValue(cursorPosition);
+ if (index < 0) {
+ return cursorPosition;
+ } else {
+ return mListMapping.keyAt(index);
+ }
+ }
+
+
+}
diff --git a/app/src/main/java/com/mobeta/android/dslv/DragSortItemView.java b/app/src/main/java/com/mobeta/android/dslv/DragSortItemView.java
new file mode 100644
index 0000000..c8f0359
--- /dev/null
+++ b/app/src/main/java/com/mobeta/android/dslv/DragSortItemView.java
@@ -0,0 +1,95 @@
+package com.mobeta.android.dslv;
+
+import android.content.Context;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AbsListView;
+
+/**
+ * Lightweight ViewGroup that wraps list items obtained from user's
+ * ListAdapter. ItemView expects a single child that has a definite
+ * height (i.e. the child's layout height is not MATCH_PARENT).
+ * The width of
+ * ItemView will always match the width of its child (that is,
+ * the width MeasureSpec given to ItemView is passed directly
+ * to the child, and the ItemView measured width is set to the
+ * child's measured width). The height of ItemView can be anything;
+ * the
+ *
+ *
+ * The purpose of this class is to optimize slide
+ * shuffle animations.
+ */
+public class DragSortItemView extends ViewGroup {
+
+ private int mGravity = Gravity.TOP;
+
+ public DragSortItemView(Context context) {
+ super(context);
+
+ // always init with standard ListView layout params
+ setLayoutParams(new AbsListView.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+
+ //setClipChildren(true);
+ }
+
+ public void setGravity(int gravity) {
+ mGravity = gravity;
+ }
+
+ public int getGravity() {
+ return mGravity;
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ final View child = getChildAt(0);
+
+ if (child == null) {
+ return;
+ }
+
+ if (mGravity == Gravity.TOP) {
+ child.layout(0, 0, getMeasuredWidth(), child.getMeasuredHeight());
+ } else {
+ child.layout(0, getMeasuredHeight() - child.getMeasuredHeight(),
+ getMeasuredWidth(), getMeasuredHeight());
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int height = MeasureSpec.getSize(heightMeasureSpec);
+ int width = MeasureSpec.getSize(widthMeasureSpec);
+
+ int heightMode = MeasureSpec.getMode(heightMeasureSpec);
+
+ final View child = getChildAt(0);
+ if (child == null) {
+ setMeasuredDimension(0, width);
+ return;
+ }
+
+ if (child.isLayoutRequested()) {
+ // Always let child be as tall as it wants.
+ measureChild(child, widthMeasureSpec,
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ }
+
+ if (heightMode == MeasureSpec.UNSPECIFIED) {
+ ViewGroup.LayoutParams lp = getLayoutParams();
+
+ if (lp.height > 0) {
+ height = lp.height;
+ } else {
+ height = child.getMeasuredHeight();
+ }
+ }
+
+ setMeasuredDimension(width, height);
+ }
+
+}
diff --git a/app/src/main/java/com/mobeta/android/dslv/DragSortItemViewCheckable.java b/app/src/main/java/com/mobeta/android/dslv/DragSortItemViewCheckable.java
new file mode 100644
index 0000000..9f19c0a
--- /dev/null
+++ b/app/src/main/java/com/mobeta/android/dslv/DragSortItemViewCheckable.java
@@ -0,0 +1,53 @@
+package com.mobeta.android.dslv;
+
+import android.content.Context;
+import android.view.View;
+import android.widget.Checkable;
+
+/**
+ * Lightweight ViewGroup that wraps list items obtained from user's
+ * ListAdapter. ItemView expects a single child that has a definite
+ * height (i.e. the child's layout height is not MATCH_PARENT).
+ * The width of
+ * ItemView will always match the width of its child (that is,
+ * the width MeasureSpec given to ItemView is passed directly
+ * to the child, and the ItemView measured width is set to the
+ * child's measured width). The height of ItemView can be anything;
+ * the
+ *
+ *
+ * The purpose of this class is to optimize slide
+ * shuffle animations.
+ */
+public class DragSortItemViewCheckable extends DragSortItemView implements Checkable {
+
+ public DragSortItemViewCheckable(Context context) {
+ super(context);
+ }
+
+ @Override
+ public boolean isChecked() {
+ View child = getChildAt(0);
+ if (child instanceof Checkable) {
+ return ((Checkable) child).isChecked();
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ View child = getChildAt(0);
+ if (child instanceof Checkable) {
+ ((Checkable) child).setChecked(checked);
+ }
+ }
+
+ @Override
+ public void toggle() {
+ View child = getChildAt(0);
+ if (child instanceof Checkable) {
+ ((Checkable) child).toggle();
+ }
+ }
+}
diff --git a/app/src/main/java/com/mobeta/android/dslv/DragSortListPreference.java b/app/src/main/java/com/mobeta/android/dslv/DragSortListPreference.java
new file mode 100644
index 0000000..ae2f757
--- /dev/null
+++ b/app/src/main/java/com/mobeta/android/dslv/DragSortListPreference.java
@@ -0,0 +1,185 @@
+/*
+ * Sortable Preference ListView. Allows for sorting items in a view,
+ * and selecting which ones to use.
+ *
+ * Example Usage (In a preference file)
+ *
+ * <com.mobeta.android.demodslv.SortableListPreference
+ * android:defaultValue="@array/pref_name_defaults"
+ * android:entries="@array/pref_name_titles"
+ * android:entryValues="@array/pref_name_values"
+ * android:key="name_order"
+ * android:persistent="true"
+ * android:title="@string/pref_name_selection" />
+ *
+ * Original Source: https://github.com/kd7uiy/drag-sort-listview
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013 The Making of a Ham, http://www.kd7uiy.com
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ * Code snippets copied from the following sources:
+ * https://gist.github.com/cardil/4754571
+ *
+ *
+ */
+
+package com.mobeta.android.dslv;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+
+import androidx.preference.ListPreference;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+
+public class DragSortListPreference extends ListPreference {
+ public HashMap<CharSequence, Boolean> getEntryChecked() {
+ return entryChecked;
+ }
+
+ private final HashMap<CharSequence, Boolean> entryChecked;
+
+ public DragSortListPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setDialogLayoutResource(R.layout.sort_list_array_dialog_preference);
+ entryChecked = new HashMap<>();
+ }
+
+ public static CharSequence[] decodeValue(String input) {
+ if (input == null) {
+ return null;
+ }
+ if (input.equals("")) {
+ return new CharSequence[0];
+ }
+ return input.split(",");
+ }
+
+
+ void setValueAndEvent(String value) {
+ if (callChangeListener(decodeValue(value))) {
+ setValue(value);
+ }
+ }
+
+ @Override
+ protected Object onGetDefaultValue(TypedArray typedArray, int index) {
+ return typedArray.getTextArray(index);
+ }
+
+ @Override
+ protected void onSetInitialValue(boolean restoreValue,
+ Object rawDefaultValue) {
+ String value;
+ CharSequence[] defaultValue;
+ if (rawDefaultValue == null) {
+ defaultValue = new CharSequence[0];
+ } else {
+ defaultValue = (CharSequence[]) rawDefaultValue;
+ }
+ List<CharSequence> joined = Arrays.asList(defaultValue);
+ String joinedDefaultValue = join(joined);
+ if (restoreValue) {
+ value = getPersistedString(joinedDefaultValue);
+ } else {
+ value = joinedDefaultValue;
+ }
+
+ setValueAndEvent(value);
+ }
+
+
+ public int getValueIndex(CharSequence item) {
+ CharSequence[] entryValues = getEntryValues();
+ for (int i = 0; i < entryValues.length; i++) {
+ if (entryValues[i].equals(item)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ CharSequence[] restoreEntries() {
+
+ ArrayList<CharSequence> orderedList = new ArrayList<>();
+
+ // Initially populated with all of the values in the determined list.
+ CharSequence[] values = decodeValue(getValue());
+ for (CharSequence value : values) {
+ orderedList.add(value);
+ entryChecked.put(value, true);
+ }
+
+ // This loop sets the default states, and adds to the name list if not
+ // on the list.
+ for (CharSequence value : getEntryValues()) {
+ if (!orderedList.contains(value)) {
+ orderedList.add(value);
+ entryChecked.put(value, false);
+ }
+ }
+
+ return orderedList.toArray(new CharSequence[0]);
+ }
+
+ public int getValueTitleIndex(CharSequence item) {
+ CharSequence[] entries = getEntries();
+ for (int i = 0; i < entries.length; i++) {
+ if (entries[i].equals(item)) {
+ return i;
+ }
+ }
+ throw new IllegalStateException(item + " not found in value title list");
+ }
+
+
+ /**
+ * Joins array of object to single string by separator
+ * <p>
+ * Credits to kurellajunior on this post
+ * http://snippets.dzone.com/posts/show/91
+ *
+ * @param iterable any kind of iterable ex.: <code>["a", "b", "c"]</code>
+ * @return joined string ex.: <code>"a,b,c"</code>
+ */
+ protected static String join(Iterable<?> iterable) {
+ Iterator<?> oIter;
+ if (iterable == null || (!(oIter = iterable.iterator()).hasNext()))
+ return "";
+ StringBuilder oBuilder = new StringBuilder(String.valueOf(oIter.next()));
+ while (oIter.hasNext())
+ oBuilder.append(",").append(oIter.next());
+ return oBuilder.toString();
+ }
+
+ void persistStringValue(String value) {
+ persistString(value);
+ }
+}
diff --git a/app/src/main/java/com/mobeta/android/dslv/DragSortListPreferenceFragment.java b/app/src/main/java/com/mobeta/android/dslv/DragSortListPreferenceFragment.java
new file mode 100644
index 0000000..21f82bd
--- /dev/null
+++ b/app/src/main/java/com/mobeta/android/dslv/DragSortListPreferenceFragment.java
@@ -0,0 +1,147 @@
+/*
+ * Sortable Preference ListView. Allows for sorting items in a view,
+ * and selecting which ones to use.
+ *
+ * Example Usage (In a preference file)
+ *
+ * <com.mobeta.android.demodslv.SortableListPreference
+ * android:defaultValue="@array/pref_name_defaults"
+ * android:entries="@array/pref_name_titles"
+ * android:entryValues="@array/pref_name_values"
+ * android:key="name_order"
+ * android:persistent="true"
+ * android:title="@string/pref_name_selection" />
+ *
+ * Original Source: https://github.com/kd7uiy/drag-sort-listview
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013 The Making of a Ham, http://www.kd7uiy.com
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ * Code snippets copied from the following sources:
+ * https://gist.github.com/cardil/4754571
+ *
+ *
+ */
+
+package com.mobeta.android.dslv;
+
+
+import android.view.View;
+import android.widget.ArrayAdapter;
+
+import androidx.annotation.NonNull;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.dialogs.MaterialListPreferenceDialogFragment;
+
+
+public class DragSortListPreferenceFragment extends MaterialListPreferenceDialogFragment implements ListPreference.TargetFragment {
+ protected DragSortListView mListView;
+ protected ArrayAdapter<CharSequence> mAdapter;
+
+
+ @Override
+ protected void onBindDialogView(View view) {
+ super.onBindDialogView(view);
+
+ mListView = (DragSortListView) view.findViewById(android.R.id.list);
+ mAdapter = new ArrayAdapter<>(mListView.getContext(),
+ R.layout.list_item_checkable, R.id.text);
+ mListView.setAdapter(mAdapter);
+ // This will drop the item in the new location
+ mListView.setDropListener(new DragSortListView.DropListener() {
+ @Override
+ public void drop(int from, int to) {
+ CharSequence item = mAdapter.getItem(from);
+ mAdapter.remove(item);
+ mAdapter.insert(item, to);
+ // Updates checked states
+ mListView.moveCheckState(from, to);
+ }
+ });
+
+
+ DragSortListPreference dslp = ((DragSortListPreference)getPreference());
+ CharSequence[] entries = dslp.getEntries();
+ CharSequence[] entryValues = dslp.getEntryValues();
+ if (entries == null || entryValues == null
+ || entries.length != entryValues.length) {
+ throw new IllegalStateException(
+ "SortableListPreference requires an entries array and an entryValues "
+ + "array which are both the same length");
+ }
+
+ CharSequence[] restoredValues = ((DragSortListPreference)getPreference()).restoreEntries();
+ int i = 0;
+ for (CharSequence value : restoredValues) {
+ int index = dslp.getValueIndex(value);
+ if (index >=0) {
+ mAdapter.add(entries[index]);
+ Boolean checked = dslp.getEntryChecked().get(value);
+ if (checked != null && checked.equals(true)) {
+ mListView.setItemChecked(i, true);
+ }
+ }
+ i++;
+ }
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(MaterialAlertDialogBuilder builder) {
+ // must be empty
+ }
+
+ public void onDialogClosed(boolean positiveResult) {
+ DragSortListPreference dslp = ((DragSortListPreference)getPreference());
+
+ List<CharSequence> values = new ArrayList<>();
+
+ CharSequence[] entryValues = dslp.getEntryValues();
+ if (positiveResult && entryValues != null) {
+ for (int i = 0; i < entryValues.length; i++) {
+ String val = (String) mAdapter.getItem(i);
+ boolean isChecked = mListView.isItemChecked(i);
+ if (isChecked) {
+ values.add(entryValues[dslp.getValueTitleIndex(val)]);
+ }
+ }
+
+ String value = DragSortListPreference.join(values);
+ dslp.setValueAndEvent(value);
+ dslp.persistStringValue(value);
+ }
+
+ }
+
+ @Override
+ public Preference findPreference(@NonNull CharSequence key) {
+ return getPreference();
+ }
+
+}
diff --git a/app/src/main/java/com/mobeta/android/dslv/DragSortListView.java b/app/src/main/java/com/mobeta/android/dslv/DragSortListView.java
new file mode 100644
index 0000000..606de84
--- /dev/null
+++ b/app/src/main/java/com/mobeta/android/dslv/DragSortListView.java
@@ -0,0 +1,2999 @@
+/*
+ * DragSortListView.
+ *
+ * A subclass of the Android ListView component that enables drag
+ * and drop re-ordering of list items.
+ *
+ * Copyright 2012 Carl Bauer
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.mobeta.android.dslv;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.database.DataSetObserver;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Point;
+import android.graphics.drawable.Drawable;
+import android.os.Environment;
+import android.os.SystemClock;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.SparseBooleanArray;
+import android.util.SparseIntArray;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AbsListView;
+import android.widget.BaseAdapter;
+import android.widget.Checkable;
+import android.widget.ListAdapter;
+import android.widget.ListView;
+import android.widget.WrapperListAdapter;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+/**
+ * ListView subclass that mediates drag and drop resorting of items.
+ *
+ *
+ * @author heycosmo
+ *
+ */
+public class DragSortListView extends ListView {
+
+
+ /**
+ * The View that floats above the ListView and represents
+ * the dragged item.
+ */
+ private View mFloatView;
+
+ /**
+ * The float View location. First based on touch location
+ * and given deltaX and deltaY. Then restricted by callback
+ * to FloatViewManager.onDragFloatView(). Finally restricted
+ * by bounds of DSLV.
+ */
+ private Point mFloatLoc = new Point();
+
+ private Point mTouchLoc = new Point();
+
+ /**
+ * The middle (in the y-direction) of the floating View.
+ */
+ private int mFloatViewMid;
+
+ /**
+ * Flag to make sure float View isn't measured twice
+ */
+ private boolean mFloatViewOnMeasured = false;
+
+ /**
+ * Watch the Adapter for data changes. Cancel a drag if
+ * coincident with a change.
+ */
+ private DataSetObserver mObserver;
+
+ /**
+ * Transparency for the floating View (XML attribute).
+ */
+ private float mFloatAlpha = 1.0f;
+ private float mCurrFloatAlpha = 1.0f;
+
+ /**
+ * While drag-sorting, the current position of the floating
+ * View. If dropped, the dragged item will land in this position.
+ */
+ private int mFloatPos;
+
+ /**
+ * The first expanded ListView position that helps represent
+ * the drop slot tracking the floating View.
+ */
+ private int mFirstExpPos;
+
+ /**
+ * The second expanded ListView position that helps represent
+ * the drop slot tracking the floating View. This can equal
+ * mFirstExpPos if there is no slide shuffle occurring; otherwise
+ * it is equal to mFirstExpPos + 1.
+ */
+ private int mSecondExpPos;
+
+ /**
+ * Flag set if slide shuffling is enabled.
+ */
+ private boolean mAnimate = false;
+
+ /**
+ * The user dragged from this position.
+ */
+ private int mSrcPos;
+
+ /**
+ * Offset (in x) within the dragged item at which the user
+ * picked it up (or first touched down with the digitalis).
+ */
+ private int mDragDeltaX;
+
+ /**
+ * Offset (in y) within the dragged item at which the user
+ * picked it up (or first touched down with the digitalis).
+ */
+ private int mDragDeltaY;
+
+
+ /**
+ * The difference (in x) between screen coordinates and coordinates
+ * in this view.
+ */
+ //private int mOffsetX;
+
+ /**
+ * The difference (in y) between screen coordinates and coordinates
+ * in this view.
+ */
+ //private int mOffsetY;
+
+ /**
+ * A listener that receives callbacks whenever the floating View
+ * hovers over a new position.
+ */
+ private DragListener mDragListener;
+
+ /**
+ * A listener that receives a callback when the floating View
+ * is dropped.
+ */
+ private DropListener mDropListener;
+
+ /**
+ * A listener that receives a callback when the floating View
+ * (or more precisely the originally dragged item) is removed
+ * by one of the provided gestures.
+ */
+ private RemoveListener mRemoveListener;
+
+ /**
+ * Enable/Disable item dragging
+ *
+ * @attr name dslv:drag_enabled
+ */
+ private boolean mDragEnabled = true;
+
+ /**
+ * Drag state enum.
+ */
+ private final static int IDLE = 0;
+ private final static int REMOVING = 1;
+ private final static int DROPPING = 2;
+ private final static int STOPPED = 3;
+ private final static int DRAGGING = 4;
+
+ private int mDragState = IDLE;
+
+ /**
+ * Height in pixels to which the originally dragged item
+ * is collapsed during a drag-sort. Currently, this value
+ * must be greater than zero.
+ */
+ private int mItemHeightCollapsed = 1;
+
+ /**
+ * Height of the floating View. Stored for the purpose of
+ * providing the tracking drop slot.
+ */
+ private int mFloatViewHeight;
+
+ /**
+ * Convenience member. See above.
+ */
+ private int mFloatViewHeightHalf;
+
+ /**
+ * Save the given width spec for use in measuring children
+ */
+ private int mWidthMeasureSpec = 0;
+
+ /**
+ * Sample Views ultimately used for calculating the height
+ * of ListView items that are off-screen.
+ */
+ private View[] mSampleViewTypes = new View[1];
+
+ /**
+ * Drag-scroll encapsulator!
+ */
+ private DragScroller mDragScroller;
+
+ /**
+ * Determines the start of the upward drag-scroll region
+ * at the top of the ListView. Specified by a fraction
+ * of the ListView height, thus screen resolution agnostic.
+ */
+ private float mDragUpScrollStartFrac = 1.0f / 3.0f;
+
+ /**
+ * Determines the start of the downward drag-scroll region
+ * at the bottom of the ListView. Specified by a fraction
+ * of the ListView height, thus screen resolution agnostic.
+ */
+ private float mDragDownScrollStartFrac = 1.0f / 3.0f;
+
+ /**
+ * The following are calculated from the above fracs.
+ */
+ private int mUpScrollStartY;
+ private int mDownScrollStartY;
+ private float mDownScrollStartYF;
+ private float mUpScrollStartYF;
+
+ /**
+ * Calculated from above above and current ListView height.
+ */
+ private float mDragUpScrollHeight;
+
+ /**
+ * Calculated from above above and current ListView height.
+ */
+ private float mDragDownScrollHeight;
+
+ /**
+ * Maximum drag-scroll speed in pixels per ms. Only used with
+ * default linear drag-scroll profile.
+ */
+ private float mMaxScrollSpeed = 0.5f;
+
+ /**
+ * Defines the scroll speed during a drag-scroll. User can
+ * provide their own; this default is a simple linear profile
+ * where scroll speed increases linearly as the floating View
+ * nears the top/bottom of the ListView.
+ */
+ private DragScrollProfile mScrollProfile = new DragScrollProfile() {
+ @Override
+ public float getSpeed(float w, long t) {
+ return mMaxScrollSpeed * w;
+ }
+ };
+
+ /**
+ * Current touch x.
+ */
+ private int mX;
+
+ /**
+ * Current touch y.
+ */
+ private int mY;
+
+ /**
+ * Last touch x.
+ */
+ //private int mLastX;
+
+ /**
+ * Last touch y.
+ */
+ private int mLastY;
+
+ /**
+ * The touch y-coord at which drag started
+ */
+ //private int mDragStartY;
+
+ /**
+ * Drag flag bit. Floating View can move in the positive
+ * x direction.
+ */
+ public final static int DRAG_POS_X = 0x1;
+
+ /**
+ * Drag flag bit. Floating View can move in the negative
+ * x direction.
+ */
+ public final static int DRAG_NEG_X = 0x2;
+
+ /**
+ * Drag flag bit. Floating View can move in the positive
+ * y direction. This is subtle. What this actually means is
+ * that, if enabled, the floating View can be dragged below its starting
+ * position. Remove in favor of upper-bounding item position?
+ */
+ public final static int DRAG_POS_Y = 0x4;
+
+ /**
+ * Drag flag bit. Floating View can move in the negative
+ * y direction. This is subtle. What this actually means is
+ * that the floating View can be dragged above its starting
+ * position. Remove in favor of lower-bounding item position?
+ */
+ public final static int DRAG_NEG_Y = 0x8;
+
+ /**
+ * Flags that determine limits on the motion of the
+ * floating View. See flags above.
+ */
+ private int mDragFlags = 0;
+
+ /**
+ * Last call to an on*TouchEvent was a call to
+ * onInterceptTouchEvent.
+ */
+ private boolean mLastCallWasIntercept = false;
+
+ /**
+ * A touch event is in progress.
+ */
+ private boolean mInTouchEvent = false;
+
+ /**
+ * Let the user customize the floating View.
+ */
+ private FloatViewManager mFloatViewManager = null;
+
+ /**
+ * Given to ListView to cancel its action when a drag-sort
+ * begins.
+ */
+ private MotionEvent mCancelEvent;
+
+ /**
+ * Enum telling where to cancel the ListView action when a
+ * drag-sort begins
+ */
+ private static final int NO_CANCEL = 0;
+ private static final int ON_TOUCH_EVENT = 1;
+ private static final int ON_INTERCEPT_TOUCH_EVENT = 2;
+
+ /**
+ * Where to cancel the ListView action when a
+ * drag-sort begins
+ */
+ private int mCancelMethod = NO_CANCEL;
+
+ /**
+ * Determines when a slide shuffle animation starts. That is,
+ * defines how close to the edge of the drop slot the floating
+ * View must be to initiate the slide.
+ */
+ private float mSlideRegionFrac = 0.25f;
+
+ /**
+ * Number between 0 and 1 indicating the relative location of
+ * a sliding item (only used if drag-sort animations
+ * are turned on). Nearly 1 means the item is
+ * at the top of the slide region (nearly full blank item
+ * is directly below).
+ */
+ private float mSlideFrac = 0.0f;
+
+ /**
+ * Wraps the user-provided ListAdapter. This is used to wrap each
+ * item View given by the user inside another View (currenly
+ * a RelativeLayout) which
+ * expands and collapses to simulate the item shuffling.
+ */
+ private AdapterWrapper mAdapterWrapper;
+
+ /**
+ * Turn on custom debugger.
+ */
+ private boolean mTrackDragSort = false;
+
+ /**
+ * Debugging class.
+ */
+ private DragSortTracker mDragSortTracker;
+
+ /**
+ * Needed for adjusting item heights from within layoutChildren
+ */
+ private boolean mBlockLayoutRequests = false;
+
+ /**
+ * Set to true when a down event happens during drag sort;
+ * for example, when drag finish animations are
+ * playing.
+ */
+ private boolean mIgnoreTouchEvent = false;
+
+ /**
+ * Caches DragSortItemView child heights. Sometimes DSLV has to
+ * know the height of an offscreen item. Since ListView virtualizes
+ * these, DSLV must get the item from the ListAdapter to obtain
+ * its height. That process can be expensive, but often the same
+ * offscreen item will be requested many times in a row. Once an
+ * offscreen item height is calculated, we cache it in this guy.
+ * Actually, we cache the height of the child of the
+ * DragSortItemView since the item height changes often during a
+ * drag-sort.
+ */
+ private static final int sCacheSize = 3;
+ private HeightCache mChildHeightCache = new HeightCache(sCacheSize);
+
+ private RemoveAnimator mRemoveAnimator;
+
+ private LiftAnimator mLiftAnimator;
+
+ private DropAnimator mDropAnimator;
+
+ private boolean mUseRemoveVelocity;
+ private float mRemoveVelocityX = 0;
+
+ public DragSortListView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ int defaultDuration = 150;
+ int removeAnimDuration = defaultDuration; // ms
+ int dropAnimDuration = defaultDuration; // ms
+
+ if (attrs != null) {
+ TypedArray a = getContext().obtainStyledAttributes(attrs,
+ R.styleable.DragSortListView, 0, 0);
+
+ mItemHeightCollapsed = Math.max(1, a.getDimensionPixelSize(
+ R.styleable.DragSortListView_collapsed_height, 1));
+
+ mTrackDragSort = a.getBoolean(
+ R.styleable.DragSortListView_track_drag_sort, false);
+
+ if (mTrackDragSort) {
+ mDragSortTracker = new DragSortTracker();
+ }
+
+ // alpha between 0 and 255, 0=transparent, 255=opaque
+ mFloatAlpha = a.getFloat(R.styleable.DragSortListView_float_alpha, mFloatAlpha);
+ mCurrFloatAlpha = mFloatAlpha;
+
+ mDragEnabled = a.getBoolean(R.styleable.DragSortListView_drag_enabled, mDragEnabled);
+
+ mSlideRegionFrac = Math.max(0.0f,
+ Math.min(1.0f, 1.0f - a.getFloat(
+ R.styleable.DragSortListView_slide_shuffle_speed,
+ 0.75f)));
+
+ mAnimate = mSlideRegionFrac > 0.0f;
+
+ float frac = a.getFloat(
+ R.styleable.DragSortListView_drag_scroll_start,
+ mDragUpScrollStartFrac);
+
+ setDragScrollStart(frac);
+
+ mMaxScrollSpeed = a.getFloat(
+ R.styleable.DragSortListView_max_drag_scroll_speed,
+ mMaxScrollSpeed);
+
+ removeAnimDuration = a.getInt(
+ R.styleable.DragSortListView_remove_animation_duration,
+ removeAnimDuration);
+
+ dropAnimDuration = a.getInt(
+ R.styleable.DragSortListView_drop_animation_duration,
+ dropAnimDuration);
+
+ boolean useDefault = a.getBoolean(
+ R.styleable.DragSortListView_use_default_controller,
+ true);
+
+ if (useDefault) {
+ boolean removeEnabled = a.getBoolean(
+ R.styleable.DragSortListView_remove_enabled,
+ false);
+ int removeMode = a.getInt(
+ R.styleable.DragSortListView_remove_mode,
+ DragSortController.FLING_REMOVE);
+ boolean sortEnabled = a.getBoolean(
+ R.styleable.DragSortListView_sort_enabled,
+ true);
+ int dragInitMode = a.getInt(
+ R.styleable.DragSortListView_drag_start_mode,
+ DragSortController.ON_DOWN);
+ int dragHandleId = a.getResourceId(
+ R.styleable.DragSortListView_drag_handle_id,
+ 0);
+ int flingHandleId = a.getResourceId(
+ R.styleable.DragSortListView_fling_handle_id,
+ 0);
+ int clickRemoveId = a.getResourceId(
+ R.styleable.DragSortListView_click_remove_id,
+ 0);
+ int bgColor = a.getColor(
+ R.styleable.DragSortListView_float_background_color,
+ Color.BLACK);
+
+ DragSortController controller = new DragSortController(
+ this, dragHandleId, dragInitMode, removeMode,
+ clickRemoveId, flingHandleId);
+ controller.setRemoveEnabled(removeEnabled);
+ controller.setSortEnabled(sortEnabled);
+ controller.setBackgroundColor(bgColor);
+
+ mFloatViewManager = controller;
+ setOnTouchListener(controller);
+ }
+
+ a.recycle();
+ }
+
+ mDragScroller = new DragScroller();
+
+ float smoothness = 0.5f;
+ if (removeAnimDuration > 0) {
+ mRemoveAnimator = new RemoveAnimator(smoothness, removeAnimDuration);
+ }
+ // mLiftAnimator = new LiftAnimator(smoothness, 100);
+ if (dropAnimDuration > 0) {
+ mDropAnimator = new DropAnimator(smoothness, dropAnimDuration);
+ }
+
+ mCancelEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0f, 0f, 0, 0f,
+ 0f, 0, 0);
+
+ // construct the dataset observer
+ mObserver = new DataSetObserver() {
+ private void cancel() {
+ if (mDragState == DRAGGING) {
+ cancelDrag();
+ }
+ }
+
+ @Override
+ public void onChanged() {
+ cancel();
+ }
+
+ @Override
+ public void onInvalidated() {
+ cancel();
+ }
+ };
+ }
+
+ /**
+ * Usually called from a FloatViewManager. The float alpha
+ * will be reset to the xml-defined value every time a drag
+ * is stopped.
+ */
+ public void setFloatAlpha(float alpha) {
+ mCurrFloatAlpha = alpha;
+ }
+
+ public float getFloatAlpha() {
+ return mCurrFloatAlpha;
+ }
+
+ /**
+ * Set maximum drag scroll speed in positions/second. Only applies
+ * if using default ScrollSpeedProfile.
+ *
+ * @param max Maximum scroll speed.
+ */
+ public void setMaxScrollSpeed(float max) {
+ mMaxScrollSpeed = max;
+ }
+
+ /**
+ * For each DragSortListView Listener interface implemented by
+ * <code>adapter</code>, this method calls the appropriate
+ * set*Listener method with <code>adapter</code> as the argument.
+ *
+ * @param adapter The ListAdapter providing data to back
+ * DragSortListView.
+ *
+ * @see android.widget.ListView#setAdapter(android.widget.ListAdapter)
+ */
+ @Override
+ public void setAdapter(ListAdapter adapter) {
+ if (adapter != null) {
+ mAdapterWrapper = new AdapterWrapper(adapter);
+ adapter.registerDataSetObserver(mObserver);
+
+ if (adapter instanceof DropListener) {
+ setDropListener((DropListener) adapter);
+ }
+ if (adapter instanceof DragListener) {
+ setDragListener((DragListener) adapter);
+ }
+ if (adapter instanceof RemoveListener) {
+ setRemoveListener((RemoveListener) adapter);
+ }
+ } else {
+ mAdapterWrapper = null;
+ }
+
+ super.setAdapter(mAdapterWrapper);
+ }
+
+ /**
+ * As opposed to {@link ListView#getAdapter()}, which returns
+ * a heavily wrapped ListAdapter (DragSortListView wraps the
+ * input ListAdapter {\emph and} ListView wraps the wrapped one).
+ *
+ * @return The ListAdapter set as the argument of {@link setAdapter()}
+ */
+ public ListAdapter getInputAdapter() {
+ if (mAdapterWrapper == null) {
+ return null;
+ } else {
+ return mAdapterWrapper.getWrappedAdapter();
+ }
+ }
+
+ private class AdapterWrapper extends BaseAdapter implements WrapperListAdapter {
+ private ListAdapter mAdapter;
+
+ public AdapterWrapper(ListAdapter adapter) {
+ mAdapter = adapter;
+ }
+
+ @Override
+ public ListAdapter getWrappedAdapter() {
+ return mAdapter;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return mAdapter.getItemId(position);
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return mAdapter.getItem(position);
+ }
+
+ @Override
+ public int getCount() {
+ return mAdapter.getCount();
+ }
+
+ @Override
+ public boolean areAllItemsEnabled() {
+ return mAdapter.areAllItemsEnabled();
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ return mAdapter.isEnabled(position);
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return mAdapter.getItemViewType(position);
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return mAdapter.getViewTypeCount();
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return mAdapter.hasStableIds();
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return mAdapter.isEmpty();
+ }
+
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ DragSortItemView v;
+ View child;
+ if (convertView != null) {
+ v = (DragSortItemView) convertView;
+ View oldChild = v.getChildAt(0);
+
+ child = mAdapter.getView(position, oldChild, DragSortListView.this);
+ if (child != oldChild) {
+ // shouldn't get here if user is reusing convertViews
+ // properly
+ if (oldChild != null) {
+ v.removeViewAt(0);
+ }
+ v.addView(child);
+ }
+ } else {
+ child = mAdapter.getView(position, null, DragSortListView.this);
+ if (child instanceof Checkable) {
+ v = new DragSortItemViewCheckable(getContext());
+ } else {
+ v = new DragSortItemView(getContext());
+ }
+ v.setLayoutParams(new AbsListView.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+ v.addView(child);
+ }
+
+ // Set the correct item height given drag state; passed
+ // View needs to be measured if measurement is required.
+ adjustItem(position + getHeaderViewsCount(), v, true);
+
+ return v;
+ }
+
+ @Override
+ public void registerDataSetObserver(DataSetObserver observer) {
+ mAdapter.registerDataSetObserver(observer);
+ }
+
+ @Override
+ public void unregisterDataSetObserver(DataSetObserver observer) {
+ mAdapter.unregisterDataSetObserver(observer);
+ }
+ }
+
+ private void drawDivider(int expPosition, Canvas canvas) {
+ final Drawable divider = getDivider();
+ final int dividerHeight = getDividerHeight();
+
+ if (divider != null && dividerHeight != 0) {
+ final ViewGroup expItem = (ViewGroup) getChildAt(expPosition
+ - getFirstVisiblePosition());
+ if (expItem != null) {
+ final int left = getPaddingLeft();
+ final int right = getWidth() - getPaddingRight();
+ final int top;
+ final int bottom;
+
+ final int childHeight = expItem.getChildAt(0).getHeight();
+
+ if (expPosition > mSrcPos) {
+ top = expItem.getTop() + childHeight;
+ bottom = top + dividerHeight;
+ } else {
+ bottom = expItem.getBottom() - childHeight;
+ top = bottom - dividerHeight;
+ }
+
+ // Have to clip to support ColorDrawable on <= Gingerbread
+ canvas.save();
+ canvas.clipRect(left, top, right, bottom);
+ divider.setBounds(left, top, right, bottom);
+ divider.draw(canvas);
+ canvas.restore();
+ }
+ }
+ }
+
+ @Override
+ protected void dispatchDraw(Canvas canvas) {
+ super.dispatchDraw(canvas);
+
+ if (mDragState != IDLE) {
+ // draw the divider over the expanded item
+ if (mFirstExpPos != mSrcPos) {
+ drawDivider(mFirstExpPos, canvas);
+ }
+ if (mSecondExpPos != mFirstExpPos && mSecondExpPos != mSrcPos) {
+ drawDivider(mSecondExpPos, canvas);
+ }
+ }
+
+ if (mFloatView != null) {
+ // draw the float view over everything
+ final int floatViewWidth = mFloatView.getWidth();
+ final int floatViewHeight = mFloatView.getHeight();
+
+ int x = mFloatLoc.x;
+
+ final int listViewWidth = getWidth();
+ if (x < 0)
+ x = -x;
+ float alphaMod;
+ if (x < listViewWidth) {
+ alphaMod = ((float) (listViewWidth - x)) / ((float) listViewWidth);
+ alphaMod *= alphaMod;
+ } else {
+ alphaMod = 0;
+ }
+
+ final int alpha = (int) (255f * mCurrFloatAlpha * alphaMod);
+
+ canvas.save();
+ canvas.translate(mFloatLoc.x, mFloatLoc.y);
+ canvas.clipRect(0, 0, floatViewWidth, floatViewHeight);
+
+ canvas.saveLayerAlpha(0, 0, floatViewWidth, floatViewHeight, alpha,
+ Canvas.ALL_SAVE_FLAG);
+
+ mFloatView.draw(canvas);
+ canvas.restore();
+ canvas.restore();
+ }
+ }
+
+ private int getItemHeight(int position) {
+ View v = getChildAt(position - getFirstVisiblePosition());
+
+ if (v != null) {
+ // item is onscreen, just get the height of the View
+ return v.getHeight();
+ } else {
+ // item is offscreen. get child height and calculate
+ // item height based on current shuffle state
+ return calcItemHeight(position, getChildHeight(position));
+ }
+ }
+
+ private class HeightCache {
+
+ private SparseIntArray mMap;
+ private List<Integer> mOrder;
+ private int mMaxSize;
+
+ public HeightCache(int size) {
+ mMap = new SparseIntArray(size);
+ mOrder = new ArrayList<Integer>(size);
+ mMaxSize = size;
+ }
+
+ /**
+ * Add item height at position if doesn't already exist.
+ */
+ public void add(int position, int height) {
+ int currentHeight = mMap.get(position, -1);
+ if (currentHeight != height) {
+ if (currentHeight == -1 && mMap.size() == mMaxSize) {
+ // remove oldest entry
+ mMap.delete(mOrder.remove(0));
+ } else {
+ // move position to newest slot
+ mOrder.remove((Integer) position);
+ }
+ mMap.put(position, height);
+ mOrder.add(position);
+ }
+ }
+
+ public int get(int position) {
+ return mMap.get(position, -1);
+ }
+
+ public void clear() {
+ mMap.clear();
+ mOrder.clear();
+ }
+
+ }
+
+ /**
+ * Get the shuffle edge for item at position when top of
+ * item is at y-coord top. Assumes that current item heights
+ * are consistent with current float view location and
+ * thus expanded positions and slide fraction. i.e. Should not be
+ * called between update of expanded positions/slide fraction
+ * and layoutChildren.
+ *
+ * @param position
+ * @param top
+ * @param height Height of item at position. If -1, this function
+ * calculates this height.
+ *
+ * @return Shuffle line between position-1 and position (for
+ * the given view of the list; that is, for when top of item at
+ * position has y-coord of given `top`). If
+ * floating View (treated as horizontal line) is dropped
+ * immediately above this line, it lands in position-1. If
+ * dropped immediately below this line, it lands in position.
+ */
+ private int getShuffleEdge(int position, int top) {
+ final int numHeaders = getHeaderViewsCount();
+ final int numFooters = getFooterViewsCount();
+
+ // shuffle edges are defined between items that can be
+ // dragged; there are N-1 of them if there are N draggable
+ // items.
+
+ if (position <= numHeaders || (position >= getCount() - numFooters)) {
+ return top;
+ }
+
+ int divHeight = getDividerHeight();
+
+ int edge;
+
+ int maxBlankHeight = mFloatViewHeight - mItemHeightCollapsed;
+ int childHeight = getChildHeight(position);
+ int itemHeight = getItemHeight(position);
+
+ // first calculate top of item given that floating View is
+ // centered over src position
+ int otop = top;
+ if (mSecondExpPos <= mSrcPos) {
+ // items are expanded on and/or above the source position
+
+ if (position == mSecondExpPos && mFirstExpPos != mSecondExpPos) {
+ if (position == mSrcPos) {
+ otop = top + itemHeight - mFloatViewHeight;
+ } else {
+ int blankHeight = itemHeight - childHeight;
+ otop = top + blankHeight - maxBlankHeight;
+ }
+ } else if (position > mSecondExpPos && position <= mSrcPos) {
+ otop = top - maxBlankHeight;
+ }
+
+ } else {
+ // items are expanded on and/or below the source position
+
+ if (position > mSrcPos && position <= mFirstExpPos) {
+ otop = top + maxBlankHeight;
+ } else if (position == mSecondExpPos && mFirstExpPos != mSecondExpPos) {
+ int blankHeight = itemHeight - childHeight;
+ otop = top + blankHeight;
+ }
+ }
+
+ // otop is set
+ if (position <= mSrcPos) {
+ edge = otop + (mFloatViewHeight - divHeight - getChildHeight(position - 1)) / 2;
+ } else {
+ edge = otop + (childHeight - divHeight - mFloatViewHeight) / 2;
+ }
+
+ return edge;
+ }
+
+ private boolean updatePositions() {
+ final int first = getFirstVisiblePosition();
+ int startPos = mFirstExpPos;
+ View startView = getChildAt(startPos - first);
+
+ if (startView == null) {
+ startPos = first + getChildCount() / 2;
+ startView = getChildAt(startPos - first);
+ }
+ int startTop = startView.getTop();
+
+ int itemHeight = startView.getHeight();
+
+ int edge = getShuffleEdge(startPos, startTop);
+ int lastEdge = edge;
+
+ int divHeight = getDividerHeight();
+
+ // Log.d("mobeta", "float mid="+mFloatViewMid);
+
+ int itemPos = startPos;
+ int itemTop = startTop;
+ if (mFloatViewMid < edge) {
+ // scanning up for float position
+ while (itemPos >= 0) {
+ itemPos--;
+ itemHeight = getItemHeight(itemPos);
+
+ if (itemPos == 0) {
+ edge = itemTop - divHeight - itemHeight;
+ break;
+ }
+
+ itemTop -= itemHeight + divHeight;
+ edge = getShuffleEdge(itemPos, itemTop);
+
+ if (mFloatViewMid >= edge) {
+ break;
+ }
+
+ lastEdge = edge;
+ }
+ } else {
+ // scanning down for float position
+ final int count = getCount();
+ while (itemPos < count) {
+ if (itemPos == count - 1) {
+ edge = itemTop + divHeight + itemHeight;
+ break;
+ }
+
+ itemTop += divHeight + itemHeight;
+ itemHeight = getItemHeight(itemPos + 1);
+ edge = getShuffleEdge(itemPos + 1, itemTop);
+
+ // test for hit
+ if (mFloatViewMid < edge) {
+ break;
+ }
+
+ lastEdge = edge;
+ itemPos++;
+ }
+ }
+
+ final int numHeaders = getHeaderViewsCount();
+ final int numFooters = getFooterViewsCount();
+
+ boolean updated = false;
+
+ int oldFirstExpPos = mFirstExpPos;
+ int oldSecondExpPos = mSecondExpPos;
+ float oldSlideFrac = mSlideFrac;
+
+ if (mAnimate) {
+ int edgeToEdge = Math.abs(edge - lastEdge);
+
+ int edgeTop, edgeBottom;
+ if (mFloatViewMid < edge) {
+ edgeTop = lastEdge;
+ edgeBottom = edge;
+ } else {
+ edgeTop = edge;
+ edgeBottom = lastEdge;
+ }
+
+ int slideRgnHeight = (int) (0.5f * mSlideRegionFrac * edgeToEdge);
+ float slideRgnHeightF = (float) slideRgnHeight;
+ int slideEdgeTop = edgeTop + slideRgnHeight;
+ int slideEdgeBottom = edgeBottom - slideRgnHeight;
+
+ // Three regions
+ if (mFloatViewMid < slideEdgeTop) {
+ mFirstExpPos = itemPos - 1;
+ mSecondExpPos = itemPos;
+ mSlideFrac = 0.5f * ((float) (slideEdgeTop - mFloatViewMid)) / slideRgnHeightF;
+ } else if (mFloatViewMid < slideEdgeBottom) {
+ mFirstExpPos = itemPos;
+ mSecondExpPos = itemPos;
+ } else {
+ mFirstExpPos = itemPos;
+ mSecondExpPos = itemPos + 1;
+ mSlideFrac = 0.5f * (1.0f + ((float) (edgeBottom - mFloatViewMid))
+ / slideRgnHeightF);
+ }
+
+ } else {
+ mFirstExpPos = itemPos;
+ mSecondExpPos = itemPos;
+ }
+
+ // correct for headers and footers
+ if (mFirstExpPos < numHeaders) {
+ itemPos = numHeaders;
+ mFirstExpPos = itemPos;
+ mSecondExpPos = itemPos;
+ } else if (mSecondExpPos >= getCount() - numFooters) {
+ itemPos = getCount() - numFooters - 1;
+ mFirstExpPos = itemPos;
+ mSecondExpPos = itemPos;
+ }
+
+ if (mFirstExpPos != oldFirstExpPos || mSecondExpPos != oldSecondExpPos
+ || mSlideFrac != oldSlideFrac) {
+ updated = true;
+ }
+
+ if (itemPos != mFloatPos) {
+ if (mDragListener != null) {
+ mDragListener.drag(mFloatPos - numHeaders, itemPos - numHeaders);
+ }
+
+ mFloatPos = itemPos;
+ updated = true;
+ }
+
+ return updated;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (mTrackDragSort) {
+ mDragSortTracker.appendState();
+ }
+ }
+
+ private class SmoothAnimator implements Runnable {
+ protected long mStartTime;
+
+ private float mDurationF;
+
+ private float mAlpha;
+ private float mA, mB, mC, mD;
+
+ private boolean mCanceled;
+
+ public SmoothAnimator(float smoothness, int duration) {
+ mAlpha = smoothness;
+ mDurationF = (float) duration;
+ mA = mD = 1f / (2f * mAlpha * (1f - mAlpha));
+ mB = mAlpha / (2f * (mAlpha - 1f));
+ mC = 1f / (1f - mAlpha);
+ }
+
+ public float transform(float frac) {
+ if (frac < mAlpha) {
+ return mA * frac * frac;
+ } else if (frac < 1f - mAlpha) {
+ return mB + mC * frac;
+ } else {
+ return 1f - mD * (frac - 1f) * (frac - 1f);
+ }
+ }
+
+ public void start() {
+ mStartTime = SystemClock.uptimeMillis();
+ mCanceled = false;
+ onStart();
+ post(this);
+ }
+
+ public void cancel() {
+ mCanceled = true;
+ }
+
+ public void onStart() {
+ // stub
+ }
+
+ public void onUpdate(float frac, float smoothFrac) {
+ // stub
+ }
+
+ public void onStop() {
+ // stub
+ }
+
+ @Override
+ public void run() {
+ if (mCanceled) {
+ return;
+ }
+
+ float fraction = ((float) (SystemClock.uptimeMillis() - mStartTime)) / mDurationF;
+
+ if (fraction >= 1f) {
+ onUpdate(1f, 1f);
+ onStop();
+ } else {
+ onUpdate(fraction, transform(fraction));
+ post(this);
+ }
+ }
+ }
+
+ /**
+ * Centers floating View under touch point.
+ */
+ private class LiftAnimator extends SmoothAnimator {
+
+ private float mInitDragDeltaY;
+ private float mFinalDragDeltaY;
+
+ public LiftAnimator(float smoothness, int duration) {
+ super(smoothness, duration);
+ }
+
+ @Override
+ public void onStart() {
+ mInitDragDeltaY = mDragDeltaY;
+ mFinalDragDeltaY = mFloatViewHeightHalf;
+ }
+
+ @Override
+ public void onUpdate(float frac, float smoothFrac) {
+ if (mDragState != DRAGGING) {
+ cancel();
+ } else {
+ mDragDeltaY = (int) (smoothFrac * mFinalDragDeltaY + (1f - smoothFrac)
+ * mInitDragDeltaY);
+ mFloatLoc.y = mY - mDragDeltaY;
+ doDragFloatView(true);
+ }
+ }
+ }
+
+ /**
+ * Centers floating View over drop slot before destroying.
+ */
+ private class DropAnimator extends SmoothAnimator {
+
+ private int mDropPos;
+ private int srcPos;
+ private float mInitDeltaY;
+ private float mInitDeltaX;
+
+ public DropAnimator(float smoothness, int duration) {
+ super(smoothness, duration);
+ }
+
+ @Override
+ public void onStart() {
+ mDropPos = mFloatPos;
+ srcPos = mSrcPos;
+ mDragState = DROPPING;
+ mInitDeltaY = mFloatLoc.y - getTargetY();
+ mInitDeltaX = mFloatLoc.x - getPaddingLeft();
+ }
+
+ private int getTargetY() {
+ final int first = getFirstVisiblePosition();
+ final int otherAdjust = (mItemHeightCollapsed + getDividerHeight()) / 2;
+ View v = getChildAt(mDropPos - first);
+ int targetY = -1;
+ if (v != null) {
+ if (mDropPos == srcPos) {
+ targetY = v.getTop();
+ } else if (mDropPos < srcPos) {
+ // expanded down
+ targetY = v.getTop() - otherAdjust;
+ } else {
+ // expanded up
+ targetY = v.getBottom() + otherAdjust - mFloatViewHeight;
+ }
+ } else {
+ // drop position is not on screen?? no animation
+ cancel();
+ }
+
+ return targetY;
+ }
+
+ @Override
+ public void onUpdate(float frac, float smoothFrac) {
+ final int targetY = getTargetY();
+ final int targetX = getPaddingLeft();
+ final float deltaY = mFloatLoc.y - targetY;
+ final float deltaX = mFloatLoc.x - targetX;
+ final float f = 1f - smoothFrac;
+ if (f < Math.abs(deltaY / mInitDeltaY) || f < Math.abs(deltaX / mInitDeltaX)) {
+ mFloatLoc.y = targetY + (int) (mInitDeltaY * f);
+ mFloatLoc.x = getPaddingLeft() + (int) (mInitDeltaX * f);
+ doDragFloatView(true);
+ }
+ }
+
+ @Override
+ public void onStop() {
+ dropFloatView();
+ }
+
+ }
+
+ /**
+ * Collapses expanded items.
+ */
+ private class RemoveAnimator extends SmoothAnimator {
+
+ private float mFloatLocX;
+ private float mFirstStartBlank;
+ private float mSecondStartBlank;
+
+ private int mFirstChildHeight = -1;
+ private int mSecondChildHeight = -1;
+
+ private int mFirstPos;
+ private int mSecondPos;
+
+ public RemoveAnimator(float smoothness, int duration) {
+ super(smoothness, duration);
+ }
+
+ @Override
+ public void onStart() {
+ mFirstChildHeight = -1;
+ mSecondChildHeight = -1;
+ mFirstPos = mFirstExpPos;
+ mSecondPos = mSecondExpPos;
+ mDragState = REMOVING;
+
+ mFloatLocX = mFloatLoc.x;
+ if (mUseRemoveVelocity) {
+ float minVelocity = 2f * getWidth();
+ if (mRemoveVelocityX == 0) {
+ mRemoveVelocityX = (mFloatLocX < 0 ? -1 : 1) * minVelocity;
+ } else {
+ minVelocity *= 2;
+ if (mRemoveVelocityX < 0 && mRemoveVelocityX > -minVelocity)
+ mRemoveVelocityX = -minVelocity;
+ else if (mRemoveVelocityX > 0 && mRemoveVelocityX < minVelocity)
+ mRemoveVelocityX = minVelocity;
+ }
+ } else {
+ destroyFloatView();
+ }
+ }
+
+ @Override
+ public void onUpdate(float frac, float smoothFrac) {
+ float f = 1f - smoothFrac;
+
+ final int firstVis = getFirstVisiblePosition();
+ View item = getChildAt(mFirstPos - firstVis);
+ ViewGroup.LayoutParams lp;
+ int blank;
+
+ if (mUseRemoveVelocity) {
+ float dt = (float) (SystemClock.uptimeMillis() - mStartTime) / 1000;
+ if (dt == 0)
+ return;
+ float dx = mRemoveVelocityX * dt;
+ int w = getWidth();
+ mRemoveVelocityX += (mRemoveVelocityX > 0 ? 1 : -1) * dt * w;
+ mFloatLocX += dx;
+ mFloatLoc.x = (int) mFloatLocX;
+ if (mFloatLocX < w && mFloatLocX > -w) {
+ mStartTime = SystemClock.uptimeMillis();
+ doDragFloatView(true);
+ return;
+ }
+ }
+
+ if (item != null) {
+ if (mFirstChildHeight == -1) {
+ mFirstChildHeight = getChildHeight(mFirstPos, item, false);
+ mFirstStartBlank = (float) (item.getHeight() - mFirstChildHeight);
+ }
+ blank = Math.max((int) (f * mFirstStartBlank), 1);
+ lp = item.getLayoutParams();
+ lp.height = mFirstChildHeight + blank;
+ item.setLayoutParams(lp);
+ }
+ if (mSecondPos != mFirstPos) {
+ item = getChildAt(mSecondPos - firstVis);
+ if (item != null) {
+ if (mSecondChildHeight == -1) {
+ mSecondChildHeight = getChildHeight(mSecondPos, item, false);
+ mSecondStartBlank = (float) (item.getHeight() - mSecondChildHeight);
+ }
+ blank = Math.max((int) (f * mSecondStartBlank), 1);
+ lp = item.getLayoutParams();
+ lp.height = mSecondChildHeight + blank;
+ item.setLayoutParams(lp);
+ }
+ }
+ }
+
+ @Override
+ public void onStop() {
+ doRemoveItem();
+ }
+ }
+
+ public void removeItem(int which) {
+ mUseRemoveVelocity = false;
+ removeItem(which, 0);
+ }
+
+ /**
+ * Removes an item from the list and animates the removal.
+ *
+ * @param which Position to remove (NOTE: headers/footers ignored!
+ * this is a position in your input ListAdapter).
+ * @param velocityX
+ */
+ public void removeItem(int which, float velocityX) {
+ if (mDragState == IDLE || mDragState == DRAGGING) {
+ if (mDragState == IDLE) {
+ // called from outside drag-sort
+ mSrcPos = getHeaderViewsCount() + which;
+ mFirstExpPos = mSrcPos;
+ mSecondExpPos = mSrcPos;
+ mFloatPos = mSrcPos;
+ View v = getChildAt(mSrcPos - getFirstVisiblePosition());
+ if (v != null) {
+ v.setVisibility(View.INVISIBLE);
+ }
+ }
+
+ mDragState = REMOVING;
+ mRemoveVelocityX = velocityX;
+
+ if (mInTouchEvent) {
+ switch (mCancelMethod) {
+ case ON_TOUCH_EVENT:
+ super.onTouchEvent(mCancelEvent);
+ break;
+ case ON_INTERCEPT_TOUCH_EVENT:
+ super.onInterceptTouchEvent(mCancelEvent);
+ break;
+ }
+ }
+
+ if (mRemoveAnimator != null) {
+ mRemoveAnimator.start();
+ } else {
+ doRemoveItem(which);
+ }
+ }
+ }
+
+ /**
+ * Move an item, bypassing the drag-sort process. Simply calls
+ * through to {@link DropListener#drop(int, int)}.
+ *
+ * @param from Position to move (NOTE: headers/footers ignored!
+ * this is a position in your input ListAdapter).
+ * @param to Target position (NOTE: headers/footers ignored!
+ * this is a position in your input ListAdapter).
+ */
+ public void moveItem(int from, int to) {
+ if (mDropListener != null) {
+ final int count = getInputAdapter().getCount();
+ if (from >= 0 && from < count && to >= 0 && to < count) {
+ mDropListener.drop(from, to);
+ }
+ }
+ }
+
+ /**
+ * Cancel a drag. Calls {@link #stopDrag(boolean, boolean)} with
+ * <code>true</code> as the first argument.
+ */
+ public void cancelDrag() {
+ if (mDragState == DRAGGING) {
+ mDragScroller.stopScrolling(true);
+ destroyFloatView();
+ clearPositions();
+ adjustAllItems();
+
+ if (mInTouchEvent) {
+ mDragState = STOPPED;
+ } else {
+ mDragState = IDLE;
+ }
+ }
+ }
+
+ private void clearPositions() {
+ mSrcPos = -1;
+ mFirstExpPos = -1;
+ mSecondExpPos = -1;
+ mFloatPos = -1;
+ }
+
+ private void dropFloatView() {
+ // must set to avoid cancelDrag being called from the DataSetObserver
+ mDragState = DROPPING;
+
+ if (mDropListener != null && mFloatPos >= 0 && mFloatPos < getCount()) {
+ final int numHeaders = getHeaderViewsCount();
+ mDropListener.drop(mSrcPos - numHeaders, mFloatPos - numHeaders);
+ }
+
+ destroyFloatView();
+
+ adjustOnReorder();
+ clearPositions();
+ adjustAllItems();
+
+ // now the drag is done
+ if (mInTouchEvent) {
+ mDragState = STOPPED;
+ } else {
+ mDragState = IDLE;
+ }
+ }
+
+ private void doRemoveItem() {
+ doRemoveItem(mSrcPos - getHeaderViewsCount());
+ }
+
+ /**
+ * Removes dragged item from the list. Calls RemoveListener.
+ */
+ private void doRemoveItem(int which) {
+ // must set to avoid cancelDrag being called from the DataSetObserver
+ mDragState = REMOVING;
+
+ // end it
+ if (mRemoveListener != null) {
+ mRemoveListener.remove(which);
+ }
+
+ destroyFloatView();
+
+ adjustOnReorder();
+ clearPositions();
+
+ // now the drag is done
+ if (mInTouchEvent) {
+ mDragState = STOPPED;
+ } else {
+ mDragState = IDLE;
+ }
+ }
+
+ private void adjustOnReorder() {
+ final int firstPos = getFirstVisiblePosition();
+ if (mSrcPos < firstPos) {
+ // collapsed src item is off screen;
+ // adjust the scroll after item heights have been fixed
+ View v = getChildAt(0);
+ int top = 0;
+ if (v != null) {
+ top = v.getTop();
+ }
+ setSelectionFromTop(firstPos - 1, top - getPaddingTop());
+ }
+ }
+
+ /**
+ * Stop a drag in progress. Pass <code>true</code> if you would
+ * like to remove the dragged item from the list.
+ *
+ * @param remove Remove the dragged item from the list. Calls
+ * a registered RemoveListener, if one exists. Otherwise, calls
+ * the DropListener, if one exists.
+ *
+ * @return True if the stop was successful. False if there is
+ * no floating View.
+ */
+ public boolean stopDrag(boolean remove) {
+ mUseRemoveVelocity = false;
+ return stopDrag(remove, 0);
+ }
+
+ public boolean stopDragWithVelocity(boolean remove, float velocityX) {
+ mUseRemoveVelocity = true;
+ return stopDrag(remove, velocityX);
+ }
+
+ public boolean stopDrag(boolean remove, float velocityX) {
+ if (mFloatView != null) {
+ mDragScroller.stopScrolling(true);
+
+ if (remove) {
+ removeItem(mSrcPos - getHeaderViewsCount(), velocityX);
+ } else {
+ if (mDropAnimator != null) {
+ mDropAnimator.start();
+ } else {
+ dropFloatView();
+ }
+ }
+
+ if (mTrackDragSort) {
+ mDragSortTracker.stopTracking();
+ }
+
+ return true;
+ } else {
+ // stop failed
+ return false;
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent ev) {
+ if (mIgnoreTouchEvent) {
+ mIgnoreTouchEvent = false;
+ return false;
+ }
+
+ if (!mDragEnabled) {
+ return super.onTouchEvent(ev);
+ }
+
+ boolean more = false;
+
+ boolean lastCallWasIntercept = mLastCallWasIntercept;
+ mLastCallWasIntercept = false;
+
+ if (!lastCallWasIntercept) {
+ saveTouchCoords(ev);
+ }
+
+ if (mDragState == DRAGGING) {
+ onDragTouchEvent(ev);
+ more = true; // give us more!
+ } else {
+ // TODO: what if float view is null because we dropped in middle
+ // of drag touch event?
+
+ if (mDragState == IDLE) {
+ if (super.onTouchEvent(ev)) {
+ more = true;
+ }
+ }
+
+ int action = ev.getAction() & MotionEvent.ACTION_MASK;
+
+ switch (action) {
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ doActionUpOrCancel();
+ break;
+ default:
+ if (more) {
+ mCancelMethod = ON_TOUCH_EVENT;
+ }
+ }
+ }
+
+ return more;
+ }
+
+ private void doActionUpOrCancel() {
+ mCancelMethod = NO_CANCEL;
+ mInTouchEvent = false;
+ if (mDragState == STOPPED) {
+ mDragState = IDLE;
+ }
+ mCurrFloatAlpha = mFloatAlpha;
+ mListViewIntercepted = false;
+ mChildHeightCache.clear();
+ }
+
+ private void saveTouchCoords(MotionEvent ev) {
+ int action = ev.getAction() & MotionEvent.ACTION_MASK;
+ if (action != MotionEvent.ACTION_DOWN) {
+ //mLastX = mX;
+ mLastY = mY;
+ }
+ mX = (int) ev.getX();
+ mY = (int) ev.getY();
+ if (action == MotionEvent.ACTION_DOWN) {
+ //mLastX = mX;
+ mLastY = mY;
+ }
+ //mOffsetX = (int) ev.getRawX() - mX;
+ //mOffsetY = (int) ev.getRawY() - mY;
+ }
+
+ public boolean listViewIntercepted() {
+ return mListViewIntercepted;
+ }
+
+ private boolean mListViewIntercepted = false;
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent ev) {
+ if (!mDragEnabled) {
+ return super.onInterceptTouchEvent(ev);
+ }
+
+ saveTouchCoords(ev);
+ mLastCallWasIntercept = true;
+
+ int action = ev.getAction() & MotionEvent.ACTION_MASK;
+
+ if (action == MotionEvent.ACTION_DOWN) {
+ if (mDragState != IDLE) {
+ // intercept and ignore
+ mIgnoreTouchEvent = true;
+ return true;
+ }
+ mInTouchEvent = true;
+ }
+
+ boolean intercept = false;
+
+ // the following deals with calls to super.onInterceptTouchEvent
+ if (mFloatView != null) {
+ // super's touch event cancelled in startDrag
+ intercept = true;
+ } else {
+ if (super.onInterceptTouchEvent(ev)) {
+ mListViewIntercepted = true;
+ intercept = true;
+ }
+
+ switch (action) {
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ doActionUpOrCancel();
+ break;
+ default:
+ if (intercept) {
+ mCancelMethod = ON_TOUCH_EVENT;
+ } else {
+ mCancelMethod = ON_INTERCEPT_TOUCH_EVENT;
+ }
+ }
+ }
+
+ if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
+ mInTouchEvent = false;
+ }
+
+ return intercept;
+ }
+
+ /**
+ * Set the width of each drag scroll region by specifying
+ * a fraction of the ListView height.
+ *
+ * @param heightFraction Fraction of ListView height. Capped at
+ * 0.5f.
+ *
+ */
+ public void setDragScrollStart(float heightFraction) {
+ setDragScrollStarts(heightFraction, heightFraction);
+ }
+
+ /**
+ * Set the width of each drag scroll region by specifying
+ * a fraction of the ListView height.
+ *
+ * @param upperFrac Fraction of ListView height for up-scroll bound.
+ * Capped at 0.5f.
+ * @param lowerFrac Fraction of ListView height for down-scroll bound.
+ * Capped at 0.5f.
+ *
+ */
+ public void setDragScrollStarts(float upperFrac, float lowerFrac) {
+ if (lowerFrac > 0.5f) {
+ mDragDownScrollStartFrac = 0.5f;
+ } else {
+ mDragDownScrollStartFrac = lowerFrac;
+ }
+
+ if (upperFrac > 0.5f) {
+ mDragUpScrollStartFrac = 0.5f;
+ } else {
+ mDragUpScrollStartFrac = upperFrac;
+ }
+
+ if (getHeight() != 0) {
+ updateScrollStarts();
+ }
+ }
+
+ private void continueDrag(int x, int y) {
+ // proposed position
+ mFloatLoc.x = x - mDragDeltaX;
+ mFloatLoc.y = y - mDragDeltaY;
+
+ doDragFloatView(true);
+
+ int minY = Math.min(y, mFloatViewMid + mFloatViewHeightHalf);
+ int maxY = Math.max(y, mFloatViewMid - mFloatViewHeightHalf);
+
+ // get the current scroll direction
+ int currentScrollDir = mDragScroller.getScrollDir();
+
+ if (minY > mLastY && minY > mDownScrollStartY && currentScrollDir != DragScroller.DOWN) {
+ // dragged down, it is below the down scroll start and it is not
+ // scrolling up
+
+ if (currentScrollDir != DragScroller.STOP) {
+ // moved directly from up scroll to down scroll
+ mDragScroller.stopScrolling(true);
+ }
+
+ // start scrolling down
+ mDragScroller.startScrolling(DragScroller.DOWN);
+ } else if (maxY < mLastY && maxY < mUpScrollStartY && currentScrollDir != DragScroller.UP) {
+ // dragged up, it is above the up scroll start and it is not
+ // scrolling up
+
+ if (currentScrollDir != DragScroller.STOP) {
+ // moved directly from down scroll to up scroll
+ mDragScroller.stopScrolling(true);
+ }
+
+ // start scrolling up
+ mDragScroller.startScrolling(DragScroller.UP);
+ }
+ else if (maxY >= mUpScrollStartY && minY <= mDownScrollStartY
+ && mDragScroller.isScrolling()) {
+ // not in the upper nor in the lower drag-scroll regions but it is
+ // still scrolling
+
+ mDragScroller.stopScrolling(true);
+ }
+ }
+
+ private void updateScrollStarts() {
+ final int padTop = getPaddingTop();
+ final int listHeight = getHeight() - padTop - getPaddingBottom();
+ float heightF = (float) listHeight;
+
+ mUpScrollStartYF = padTop + mDragUpScrollStartFrac * heightF;
+ mDownScrollStartYF = padTop + (1.0f - mDragDownScrollStartFrac) * heightF;
+
+ mUpScrollStartY = (int) mUpScrollStartYF;
+ mDownScrollStartY = (int) mDownScrollStartYF;
+
+ mDragUpScrollHeight = mUpScrollStartYF - padTop;
+ mDragDownScrollHeight = padTop + listHeight - mDownScrollStartYF;
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+ updateScrollStarts();
+ }
+
+ private void adjustAllItems() {
+ final int first = getFirstVisiblePosition();
+ final int last = getLastVisiblePosition();
+
+ int begin = Math.max(0, getHeaderViewsCount() - first);
+ int end = Math.min(last - first, getCount() - 1 - getFooterViewsCount() - first);
+
+ for (int i = begin; i <= end; ++i) {
+ View v = getChildAt(i);
+ if (v != null) {
+ adjustItem(first + i, v, false);
+ }
+ }
+ }
+
+ /**
+ * Sets layout param height, gravity, and visibility on
+ * wrapped item.
+ */
+ private void adjustItem(int position, View v, boolean invalidChildHeight) {
+ // Adjust item height
+ ViewGroup.LayoutParams lp = v.getLayoutParams();
+ int height;
+ if (position != mSrcPos && position != mFirstExpPos && position != mSecondExpPos) {
+ height = ViewGroup.LayoutParams.WRAP_CONTENT;
+ } else {
+ height = calcItemHeight(position, v, invalidChildHeight);
+ }
+
+ if (height != lp.height) {
+ lp.height = height;
+ v.setLayoutParams(lp);
+ }
+
+ // Adjust item gravity
+ if (position == mFirstExpPos || position == mSecondExpPos) {
+ if (position < mSrcPos) {
+ ((DragSortItemView) v).setGravity(Gravity.BOTTOM);
+ } else if (position > mSrcPos) {
+ ((DragSortItemView) v).setGravity(Gravity.TOP);
+ }
+ }
+
+ // Finally adjust item visibility
+ int oldVis = v.getVisibility();
+ int vis = View.VISIBLE;
+
+ if (position == mSrcPos && mFloatView != null) {
+ vis = View.INVISIBLE;
+ }
+
+ if (vis != oldVis) {
+ v.setVisibility(vis);
+ }
+ }
+
+ private int getChildHeight(int position) {
+ if (position == mSrcPos) {
+ return 0;
+ }
+
+ View v = getChildAt(position - getFirstVisiblePosition());
+
+ if (v != null) {
+ // item is onscreen, therefore child height is valid,
+ // hence the "true"
+ return getChildHeight(position, v, false);
+ } else {
+ // item is offscreen
+ // first check cache for child height at this position
+ int childHeight = mChildHeightCache.get(position);
+ if (childHeight != -1) {
+ return childHeight;
+ }
+
+ final ListAdapter adapter = getAdapter();
+ int type = adapter.getItemViewType(position);
+
+ // There might be a better place for checking for the following
+ final int typeCount = adapter.getViewTypeCount();
+ if (typeCount != mSampleViewTypes.length) {
+ mSampleViewTypes = new View[typeCount];
+ }
+
+ if (type >= 0) {
+ if (mSampleViewTypes[type] == null) {
+ v = adapter.getView(position, null, this);
+ mSampleViewTypes[type] = v;
+ } else {
+ v = adapter.getView(position, mSampleViewTypes[type], this);
+ }
+ } else {
+ // type is HEADER_OR_FOOTER or IGNORE
+ v = adapter.getView(position, null, this);
+ }
+
+ // current child height is invalid, hence "true" below
+ childHeight = getChildHeight(position, v, true);
+
+ // cache it because this could have been expensive
+ mChildHeightCache.add(position, childHeight);
+
+ return childHeight;
+ }
+ }
+
+ private int getChildHeight(int position, View item, boolean invalidChildHeight) {
+ if (position == mSrcPos) {
+ return 0;
+ }
+
+ View child;
+ if (position < getHeaderViewsCount() || position >= getCount() - getFooterViewsCount()) {
+ child = item;
+ } else {
+ child = ((ViewGroup) item).getChildAt(0);
+ }
+
+ ViewGroup.LayoutParams lp = child.getLayoutParams();
+ if (lp != null) {
+ if (lp.height > 0) {
+ return lp.height;
+ }
+ }
+
+ int childHeight = child.getHeight();
+ if (childHeight == 0 || invalidChildHeight) {
+ measureItem(child);
+ childHeight = child.getMeasuredHeight();
+ }
+
+ return childHeight;
+ }
+
+ private int calcItemHeight(int position, View item, boolean invalidChildHeight) {
+ return calcItemHeight(position, getChildHeight(position, item, invalidChildHeight));
+ }
+
+ private int calcItemHeight(int position, int childHeight) {
+ boolean isSliding = mAnimate && mFirstExpPos != mSecondExpPos;
+ int maxNonSrcBlankHeight = mFloatViewHeight - mItemHeightCollapsed;
+ int slideHeight = (int) (mSlideFrac * maxNonSrcBlankHeight);
+
+ int height;
+
+ if (position == mSrcPos) {
+ if (mSrcPos == mFirstExpPos) {
+ if (isSliding) {
+ height = slideHeight + mItemHeightCollapsed;
+ } else {
+ height = mFloatViewHeight;
+ }
+ } else if (mSrcPos == mSecondExpPos) {
+ // if gets here, we know an item is sliding
+ height = mFloatViewHeight - slideHeight;
+ } else {
+ height = mItemHeightCollapsed;
+ }
+ } else if (position == mFirstExpPos) {
+ if (isSliding) {
+ height = childHeight + slideHeight;
+ } else {
+ height = childHeight + maxNonSrcBlankHeight;
+ }
+ } else if (position == mSecondExpPos) {
+ // we know an item is sliding (b/c 2ndPos != 1stPos)
+ height = childHeight + maxNonSrcBlankHeight - slideHeight;
+ } else {
+ height = childHeight;
+ }
+
+ return height;
+ }
+
+ @Override
+ public void requestLayout() {
+ if (!mBlockLayoutRequests) {
+ super.requestLayout();
+ }
+ }
+
+ private int adjustScroll(int movePos, View moveItem, int oldFirstExpPos, int oldSecondExpPos) {
+ int adjust = 0;
+
+ final int childHeight = getChildHeight(movePos);
+
+ int moveHeightBefore = moveItem.getHeight();
+ int moveHeightAfter = calcItemHeight(movePos, childHeight);
+
+ int moveBlankBefore = moveHeightBefore;
+ int moveBlankAfter = moveHeightAfter;
+ if (movePos != mSrcPos) {
+ moveBlankBefore -= childHeight;
+ moveBlankAfter -= childHeight;
+ }
+
+ int maxBlank = mFloatViewHeight;
+ if (mSrcPos != mFirstExpPos && mSrcPos != mSecondExpPos) {
+ maxBlank -= mItemHeightCollapsed;
+ }
+
+ if (movePos <= oldFirstExpPos) {
+ if (movePos > mFirstExpPos) {
+ adjust += maxBlank - moveBlankAfter;
+ }
+ } else if (movePos == oldSecondExpPos) {
+ if (movePos <= mFirstExpPos) {
+ adjust += moveBlankBefore - maxBlank;
+ } else if (movePos == mSecondExpPos) {
+ adjust += moveHeightBefore - moveHeightAfter;
+ } else {
+ adjust += moveBlankBefore;
+ }
+ } else {
+ if (movePos <= mFirstExpPos) {
+ adjust -= maxBlank;
+ } else if (movePos == mSecondExpPos) {
+ adjust -= moveBlankAfter;
+ }
+ }
+
+ return adjust;
+ }
+
+ private void measureItem(View item) {
+ ViewGroup.LayoutParams lp = item.getLayoutParams();
+ if (lp == null) {
+ lp = new AbsListView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ item.setLayoutParams(lp);
+ }
+
+ int wspec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec, getListPaddingLeft()
+ + getListPaddingRight(), lp.width);
+ int hspec;
+ if (lp.height > 0) {
+ hspec = MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY);
+ } else {
+ hspec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ }
+ item.measure(wspec, hspec);
+ }
+
+ private void measureFloatView() {
+ if (mFloatView != null) {
+ measureItem(mFloatView);
+ mFloatViewHeight = mFloatView.getMeasuredHeight();
+ mFloatViewHeightHalf = mFloatViewHeight / 2;
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ if (mFloatView != null) {
+ if (mFloatView.isLayoutRequested()) {
+ measureFloatView();
+ }
+ mFloatViewOnMeasured = true; // set to false after layout
+ }
+ mWidthMeasureSpec = widthMeasureSpec;
+ }
+
+ @Override
+ protected void layoutChildren() {
+ super.layoutChildren();
+
+ if (mFloatView != null) {
+ if (mFloatView.isLayoutRequested() && !mFloatViewOnMeasured) {
+ // Have to measure here when usual android measure
+ // pass is skipped. This happens during a drag-sort
+ // when layoutChildren is called directly.
+ measureFloatView();
+ }
+ mFloatView.layout(0, 0, mFloatView.getMeasuredWidth(), mFloatView.getMeasuredHeight());
+ mFloatViewOnMeasured = false;
+ }
+ }
+
+ protected boolean onDragTouchEvent(MotionEvent ev) {
+ int action = ev.getAction() & MotionEvent.ACTION_MASK;
+ switch (action) {
+ case MotionEvent.ACTION_CANCEL:
+ if (mDragState == DRAGGING) {
+ cancelDrag();
+ }
+ doActionUpOrCancel();
+ break;
+ case MotionEvent.ACTION_UP:
+ if (mDragState == DRAGGING) {
+ stopDrag(false);
+ }
+ doActionUpOrCancel();
+ break;
+ case MotionEvent.ACTION_MOVE:
+ continueDrag((int) ev.getX(), (int) ev.getY());
+ break;
+ }
+
+ return true;
+ }
+
+ /**
+ * Start a drag of item at <code>position</code> using the
+ * registered FloatViewManager. Calls through
+ * to {@link #startDrag(int,View,int,int,int)} after obtaining
+ * the floating View from the FloatViewManager.
+ *
+ * @param position Item to drag.
+ * @param dragFlags Flags that restrict some movements of the
+ * floating View. For example, set <code>dragFlags |=
+ * ~{@link #DRAG_NEG_X}</code> to allow dragging the floating
+ * View in all directions except off the screen to the left.
+ * @param deltaX Offset in x of the touch coordinate from the
+ * left edge of the floating View (i.e. touch-x minus float View
+ * left).
+ * @param deltaY Offset in y of the touch coordinate from the
+ * top edge of the floating View (i.e. touch-y minus float View
+ * top).
+ *
+ * @return True if the drag was started, false otherwise. This
+ * <code>startDrag</code> will fail if we are not currently in
+ * a touch event, there is no registered FloatViewManager,
+ * or the FloatViewManager returns a null View.
+ */
+ public boolean startDrag(int position, int dragFlags, int deltaX, int deltaY) {
+ if (!mInTouchEvent || mFloatViewManager == null) {
+ return false;
+ }
+
+ View v = mFloatViewManager.onCreateFloatView(position);
+
+ if (v == null) {
+ return false;
+ } else {
+ return startDrag(position, v, dragFlags, deltaX, deltaY);
+ }
+
+ }
+
+ /**
+ * Start a drag of item at <code>position</code> without using
+ * a FloatViewManager.
+ *
+ * @param position Item to drag.
+ * @param floatView Floating View.
+ * @param dragFlags Flags that restrict some movements of the
+ * floating View. For example, set <code>dragFlags |=
+ * ~{@link #DRAG_NEG_X}</code> to allow dragging the floating
+ * View in all directions except off the screen to the left.
+ * @param deltaX Offset in x of the touch coordinate from the
+ * left edge of the floating View (i.e. touch-x minus float View
+ * left).
+ * @param deltaY Offset in y of the touch coordinate from the
+ * top edge of the floating View (i.e. touch-y minus float View
+ * top).
+ *
+ * @return True if the drag was started, false otherwise. This
+ * <code>startDrag</code> will fail if we are not currently in
+ * a touch event, <code>floatView</code> is null, or there is
+ * a drag in progress.
+ */
+ public boolean startDrag(int position, View floatView, int dragFlags, int deltaX, int deltaY) {
+ if (mDragState != IDLE || !mInTouchEvent || mFloatView != null || floatView == null
+ || !mDragEnabled) {
+ return false;
+ }
+
+ if (getParent() != null) {
+ getParent().requestDisallowInterceptTouchEvent(true);
+ }
+
+ int pos = position + getHeaderViewsCount();
+ mFirstExpPos = pos;
+ mSecondExpPos = pos;
+ mSrcPos = pos;
+ mFloatPos = pos;
+
+ mDragState = DRAGGING;
+ mDragFlags = 0;
+ mDragFlags |= dragFlags;
+
+ mFloatView = floatView;
+ measureFloatView(); // sets mFloatViewHeight
+
+ mDragDeltaX = deltaX;
+ mDragDeltaY = deltaY;
+
+ mFloatLoc.x = mX - mDragDeltaX;
+ mFloatLoc.y = mY - mDragDeltaY;
+
+ // set src item invisible
+ final View srcItem = getChildAt(mSrcPos - getFirstVisiblePosition());
+
+ if (srcItem != null) {
+ srcItem.setVisibility(View.INVISIBLE);
+ }
+
+ if (mTrackDragSort) {
+ mDragSortTracker.startTracking();
+ }
+
+ // once float view is created, events are no longer passed
+ // to ListView
+ switch (mCancelMethod) {
+ case ON_TOUCH_EVENT:
+ super.onTouchEvent(mCancelEvent);
+ break;
+ case ON_INTERCEPT_TOUCH_EVENT:
+ super.onInterceptTouchEvent(mCancelEvent);
+ break;
+ }
+
+ requestLayout();
+
+ if (mLiftAnimator != null) {
+ mLiftAnimator.start();
+ }
+
+ return true;
+ }
+
+ private void doDragFloatView(boolean forceInvalidate) {
+ int movePos = getFirstVisiblePosition() + getChildCount() / 2;
+ View moveItem = getChildAt(getChildCount() / 2);
+
+ if (moveItem == null) {
+ return;
+ }
+
+ doDragFloatView(movePos, moveItem, forceInvalidate);
+ }
+
+ private void doDragFloatView(int movePos, View moveItem, boolean forceInvalidate) {
+ mBlockLayoutRequests = true;
+
+ updateFloatView();
+
+ int oldFirstExpPos = mFirstExpPos;
+ int oldSecondExpPos = mSecondExpPos;
+
+ boolean updated = updatePositions();
+
+ if (updated) {
+ adjustAllItems();
+ int scroll = adjustScroll(movePos, moveItem, oldFirstExpPos, oldSecondExpPos);
+
+ setSelectionFromTop(movePos, moveItem.getTop() + scroll - getPaddingTop());
+ layoutChildren();
+ }
+
+ if (updated || forceInvalidate) {
+ invalidate();
+ }
+
+ mBlockLayoutRequests = false;
+ }
+
+ /**
+ * Sets float View location based on suggested values and
+ * constraints set in mDragFlags.
+ */
+ private void updateFloatView() {
+
+ if (mFloatViewManager != null) {
+ mTouchLoc.set(mX, mY);
+ mFloatViewManager.onDragFloatView(mFloatView, mFloatLoc, mTouchLoc);
+ }
+
+ final int floatX = mFloatLoc.x;
+ final int floatY = mFloatLoc.y;
+
+ // restrict x motion
+ int padLeft = getPaddingLeft();
+ if ((mDragFlags & DRAG_POS_X) == 0 && floatX > padLeft) {
+ mFloatLoc.x = padLeft;
+ } else if ((mDragFlags & DRAG_NEG_X) == 0 && floatX < padLeft) {
+ mFloatLoc.x = padLeft;
+ }
+
+ // keep floating view from going past bottom of last header view
+ final int numHeaders = getHeaderViewsCount();
+ final int numFooters = getFooterViewsCount();
+ final int firstPos = getFirstVisiblePosition();
+ final int lastPos = getLastVisiblePosition();
+
+ int topLimit = getPaddingTop();
+ if (firstPos < numHeaders) {
+ topLimit = getChildAt(numHeaders - firstPos - 1).getBottom();
+ }
+ if ((mDragFlags & DRAG_NEG_Y) == 0) {
+ if (firstPos <= mSrcPos) {
+ topLimit = Math.max(getChildAt(mSrcPos - firstPos).getTop(), topLimit);
+ }
+ }
+ // bottom limit is top of first footer View or
+ // bottom of last item in list
+ int bottomLimit = getHeight() - getPaddingBottom();
+ if (lastPos >= getCount() - numFooters - 1) {
+ bottomLimit = getChildAt(getCount() - numFooters - 1 - firstPos).getBottom();
+ }
+ if ((mDragFlags & DRAG_POS_Y) == 0) {
+ if (lastPos >= mSrcPos) {
+ bottomLimit = Math.min(getChildAt(mSrcPos - firstPos).getBottom(), bottomLimit);
+ }
+ }
+
+ if (floatY < topLimit) {
+ mFloatLoc.y = topLimit;
+ } else if (floatY + mFloatViewHeight > bottomLimit) {
+ mFloatLoc.y = bottomLimit - mFloatViewHeight;
+ }
+
+ // get y-midpoint of floating view (constrained to ListView bounds)
+ mFloatViewMid = mFloatLoc.y + mFloatViewHeightHalf;
+ }
+
+ private void destroyFloatView() {
+ if (mFloatView != null) {
+ mFloatView.setVisibility(GONE);
+ if (mFloatViewManager != null) {
+ mFloatViewManager.onDestroyFloatView(mFloatView);
+ }
+ mFloatView = null;
+ invalidate();
+ }
+ }
+
+ /**
+ * Interface for customization of the floating View appearance
+ * and dragging behavior. Implement
+ * your own and pass it to {@link #setFloatViewManager}. If
+ * your own is not passed, the default {@link SimpleFloatViewManager}
+ * implementation is used.
+ */
+ public interface FloatViewManager {
+ /**
+ * Return the floating View for item at <code>position</code>.
+ * DragSortListView will measure and layout this View for you,
+ * so feel free to just inflate it. You can help DSLV by
+ * setting some {@link ViewGroup.LayoutParams} on this View;
+ * otherwise it will set some for you (with a width of FILL_PARENT
+ * and a height of WRAP_CONTENT).
+ *
+ * @param position Position of item to drag (NOTE:
+ * <code>position</code> excludes header Views; thus, if you
+ * want to call {@link ListView#getChildAt(int)}, you will need
+ * to add {@link ListView#getHeaderViewsCount()} to the index).
+ *
+ * @return The View you wish to display as the floating View.
+ */
+ public View onCreateFloatView(int position);
+
+ /**
+ * Called whenever the floating View is dragged. Float View
+ * properties can be changed here. Also, the upcoming location
+ * of the float View can be altered by setting
+ * <code>location.x</code> and <code>location.y</code>.
+ *
+ * @param floatView The floating View.
+ * @param location The location (top-left; relative to DSLV
+ * top-left) at which the float
+ * View would like to appear, given the current touch location
+ * and the offset provided in {@link DragSortListView#startDrag}.
+ * @param touch The current touch location (relative to DSLV
+ * top-left).
+ * @param pendingScroll
+ */
+ public void onDragFloatView(View floatView, Point location, Point touch);
+
+ /**
+ * Called when the float View is dropped; lets you perform
+ * any necessary cleanup. The internal DSLV floating View
+ * reference is set to null immediately after this is called.
+ *
+ * @param floatView The floating View passed to
+ * {@link #onCreateFloatView(int)}.
+ */
+ public void onDestroyFloatView(View floatView);
+ }
+
+ public void setFloatViewManager(FloatViewManager manager) {
+ mFloatViewManager = manager;
+ }
+
+ public void setDragListener(DragListener l) {
+ mDragListener = l;
+ }
+
+ /**
+ * Allows for easy toggling between a DragSortListView
+ * and a regular old ListView. If enabled, items are
+ * draggable, where the drag init mode determines how
+ * items are lifted (see {@link setDragInitMode(int)}).
+ * If disabled, items cannot be dragged.
+ *
+ * @param enabled Set <code>true</code> to enable list
+ * item dragging
+ */
+ public void setDragEnabled(boolean enabled) {
+ mDragEnabled = enabled;
+ }
+
+ public boolean isDragEnabled() {
+ return mDragEnabled;
+ }
+
+ /**
+ * This better reorder your ListAdapter! DragSortListView does not do this
+ * for you; doesn't make sense to. Make sure
+ * {@link BaseAdapter#notifyDataSetChanged()} or something like it is called
+ * in your implementation. Furthermore, if you have a choiceMode other than
+ * none and the ListAdapter does not return true for
+ * {@link ListAdapter#hasStableIds()}, you will need to call
+ * {@link #moveCheckState(int, int)} to move the check boxes along with the
+ * list items.
+ *
+ * @param l
+ */
+ public void setDropListener(DropListener l) {
+ mDropListener = l;
+ }
+
+ /**
+ * Probably a no-brainer, but make sure that your remove listener
+ * calls {@link BaseAdapter#notifyDataSetChanged()} or something like it.
+ * When an item removal occurs, DragSortListView
+ * relies on a redraw of all the items to recover invisible views
+ * and such. Strictly speaking, if you remove something, your dataset
+ * has changed...
+ *
+ * @param l
+ */
+ public void setRemoveListener(RemoveListener l) {
+ mRemoveListener = l;
+ }
+
+ public interface DragListener {
+ public void drag(int from, int to);
+ }
+
+ /**
+ * Your implementation of this has to reorder your ListAdapter!
+ * Make sure to call
+ * {@link BaseAdapter#notifyDataSetChanged()} or something like it
+ * in your implementation.
+ *
+ * @author heycosmo
+ *
+ */
+ public interface DropListener {
+ public void drop(int from, int to);
+ }
+
+ /**
+ * Make sure to call
+ * {@link BaseAdapter#notifyDataSetChanged()} or something like it
+ * in your implementation.
+ *
+ * @author heycosmo
+ *
+ */
+ public interface RemoveListener {
+ public void remove(int which);
+ }
+
+ public interface DragSortListener extends DropListener, DragListener, RemoveListener {
+ }
+
+ public void setDragSortListener(DragSortListener listener) {
+ setDropListener(listener);
+ setDragListener(listener);
+ setRemoveListener(listener);
+ }
+
+ /**
+ * Completely custom scroll speed profile. Default increases linearly
+ * with position and is constant in time. Create your own by implementing
+ * {@link DragSortListView.DragScrollProfile}.
+ *
+ * @param scrollProfile
+ */
+ public void setDragScrollProfile(DragScrollProfile scrollProfile) {
+ if (scrollProfile != null) {
+ mScrollProfile = scrollProfile;
+ }
+ }
+
+ /**
+ * Use this to move the check state of an item from one position to another
+ * in a drop operation. If you have a choiceMode which is not none, this
+ * method must be called when the order of items changes in an underlying
+ * adapter which does not have stable IDs (see
+ * {@link ListAdapter#hasStableIds()}). This is because without IDs, the
+ * ListView has no way of knowing which items have moved where, and cannot
+ * update the check state accordingly.
+ * <p>
+ * A word of warning about a "feature" in Android that you may run into when
+ * dealing with movable list items: for an adapter that <em>does</em> have
+ * stable IDs, ListView will attempt to locate each item based on its ID and
+ * move the check state from the item's old position to the new position 鈥�
+ * which is all fine and good (and removes the need for calling this
+ * function), except for the half-baked approach. Apparently to save time in
+ * the naive algorithm used, ListView will only search for an ID in the
+ * close neighborhood of the old position. If the user moves an item too far
+ * (specifically, more than 20 rows away), ListView will give up and just
+ * force the item to be unchecked. So if there is a reasonable chance that
+ * the user will move items more than 20 rows away from the original
+ * position, you may wish to use an adapter with unstable IDs and call this
+ * method manually instead.
+ *
+ * @param from
+ * @param to
+ */
+ public void moveCheckState(int from, int to) {
+ // This method runs in O(n log n) time (n being the number of list
+ // items). The bottleneck is the call to AbsListView.setItemChecked,
+ // which is O(log n) because of the binary search involved in calling
+ // SparseBooleanArray.put().
+ //
+ // To improve on the average time, we minimize the number of calls to
+ // setItemChecked by only calling it for items that actually have a
+ // changed state. This is achieved by building a list containing the
+ // start and end of the "runs" of checked items, and then moving the
+ // runs. Note that moving an item from A to B is essentially a rotation
+ // of the range of items in [A, B]. Let's say we have
+ // . . U V X Y Z . .
+ // and move U after Z. This is equivalent to a rotation one step to the
+ // left within the range you are moving across:
+ // . . V X Y Z U . .
+ //
+ // So, to perform the move we enumerate all the runs within the move
+ // range, then rotate each run one step to the left or right (depending
+ // on move direction). For example, in the list:
+ // X X . X X X . X
+ // we have two runs. One begins at the last item of the list and wraps
+ // around to the beginning, ending at position 1. The second begins at
+ // position 3 and ends at position 5. To rotate a run, regardless of
+ // length, we only need to set a check mark at one end of the run, and
+ // clear a check mark at the other end:
+ // X . X X X . X X
+ SparseBooleanArray cip = getCheckedItemPositions();
+ int rangeStart = from;
+ int rangeEnd = to;
+ if (to < from) {
+ rangeStart = to;
+ rangeEnd = from;
+ }
+ rangeEnd += 1;
+
+ int[] runStart = new int[cip.size()];
+ int[] runEnd = new int[cip.size()];
+ int runCount = buildRunList(cip, rangeStart, rangeEnd, runStart, runEnd);
+ if (runCount == 1 && (runStart[0] == runEnd[0])) {
+ // Special case where all items are checked, we can never set any
+ // item to false like we do below.
+ return;
+ }
+
+ if (from < to) {
+ for (int i = 0; i != runCount; i++) {
+ setItemChecked(rotate(runStart[i], -1, rangeStart, rangeEnd), true);
+ setItemChecked(rotate(runEnd[i], -1, rangeStart, rangeEnd), false);
+ }
+
+ } else {
+ for (int i = 0; i != runCount; i++) {
+ setItemChecked(runStart[i], false);
+ setItemChecked(runEnd[i], true);
+ }
+ }
+ }
+
+ /**
+ * Use this when an item has been deleted, to move the check state of all
+ * following items up one step. If you have a choiceMode which is not none,
+ * this method must be called when the order of items changes in an
+ * underlying adapter which does not have stable IDs (see
+ * {@link ListAdapter#hasStableIds()}). This is because without IDs, the
+ * ListView has no way of knowing which items have moved where, and cannot
+ * update the check state accordingly.
+ *
+ * See also further comments on {@link #moveCheckState(int, int)}.
+ *
+ * @param position
+ */
+ public void removeCheckState(int position) {
+ SparseBooleanArray cip = getCheckedItemPositions();
+
+ if (cip.size() == 0)
+ return;
+ int[] runStart = new int[cip.size()];
+ int[] runEnd = new int[cip.size()];
+ int rangeStart = position;
+ int rangeEnd = cip.keyAt(cip.size() - 1) + 1;
+ int runCount = buildRunList(cip, rangeStart, rangeEnd, runStart, runEnd);
+ for (int i = 0; i != runCount; i++) {
+ if (!(runStart[i] == position || (runEnd[i] < runStart[i] && runEnd[i] > position))) {
+ // Only set a new check mark in front of this run if it does
+ // not contain the deleted position. If it does, we only need
+ // to make it one check mark shorter at the end.
+ setItemChecked(rotate(runStart[i], -1, rangeStart, rangeEnd), true);
+ }
+ setItemChecked(rotate(runEnd[i], -1, rangeStart, rangeEnd), false);
+ }
+ }
+
+ private static int buildRunList(SparseBooleanArray cip, int rangeStart, int rangeEnd,
+ int[] runStart, int[] runEnd) {
+
+ int runCount = 0;
+
+ int i = findFirstSetIndex(cip, rangeStart, rangeEnd);
+ if (i == -1)
+ return 0;
+
+ int position = cip.keyAt(i);
+ int currentRunStart = position;
+ int currentRunEnd = currentRunStart + 1;
+ for (i++; i < cip.size() && (position = cip.keyAt(i)) < rangeEnd; i++) {
+ if (!cip.valueAt(i)) // not checked => not interesting
+ continue;
+ if (position == currentRunEnd) {
+ currentRunEnd++;
+ } else {
+ runStart[runCount] = currentRunStart;
+ runEnd[runCount] = currentRunEnd;
+ runCount++;
+ currentRunStart = position;
+ currentRunEnd = position + 1;
+ }
+ }
+
+ if (currentRunEnd == rangeEnd) {
+ // rangeStart and rangeEnd are equivalent positions so to be
+ // consistent we translate them to the same integer value. That way
+ // we can check whether a run covers the entire range by just
+ // checking if the start equals the end position.
+ currentRunEnd = rangeStart;
+ }
+ runStart[runCount] = currentRunStart;
+ runEnd[runCount] = currentRunEnd;
+ runCount++;
+
+ if (runCount > 1) {
+ if (runStart[0] == rangeStart && runEnd[runCount - 1] == rangeStart) {
+ // The last run ends at the end of the range, and the first run
+ // starts at the beginning of the range. So they are actually
+ // part of the same run, except they wrap around the end of the
+ // range. To avoid adjacent runs, we need to merge them.
+ runStart[0] = runStart[runCount - 1];
+ runCount--;
+ }
+ }
+ return runCount;
+ }
+
+ private static int rotate(int value, int offset, int lowerBound, int upperBound) {
+ int windowSize = upperBound - lowerBound;
+
+ value += offset;
+ if (value < lowerBound) {
+ value += windowSize;
+ } else if (value >= upperBound) {
+ value -= windowSize;
+ }
+ return value;
+ }
+
+ private static int findFirstSetIndex(SparseBooleanArray sba, int rangeStart, int rangeEnd) {
+ int size = sba.size();
+ int i = insertionIndexForKey(sba, rangeStart);
+ while (i < size && sba.keyAt(i) < rangeEnd && !sba.valueAt(i))
+ i++;
+ if (i == size || sba.keyAt(i) >= rangeEnd)
+ return -1;
+ return i;
+ }
+
+ private static int insertionIndexForKey(SparseBooleanArray sba, int key) {
+ int low = 0;
+ int high = sba.size();
+ while (high - low > 0) {
+ int middle = (low + high) >> 1;
+ if (sba.keyAt(middle) < key)
+ low = middle + 1;
+ else
+ high = middle;
+ }
+ return low;
+ }
+
+ /**
+ * Interface for controlling
+ * scroll speed as a function of touch position and time. Use
+ * {@link DragSortListView#setDragScrollProfile(DragScrollProfile)} to
+ * set custom profile.
+ *
+ * @author heycosmo
+ *
+ */
+ public interface DragScrollProfile {
+ /**
+ * Return a scroll speed in pixels/millisecond. Always return a
+ * positive number.
+ *
+ * @param w Normalized position in scroll region (i.e. w \in [0,1]).
+ * Small w typically means slow scrolling.
+ * @param t Time (in milliseconds) since start of scroll (handy if you
+ * want scroll acceleration).
+ * @return Scroll speed at position w and time t in pixels/ms.
+ */
+ float getSpeed(float w, long t);
+ }
+
+ private class DragScroller implements Runnable {
+
+ private boolean mAbort;
+
+ private long mPrevTime;
+ private long mCurrTime;
+
+ private int dy;
+ private float dt;
+ private long tStart;
+ private int scrollDir;
+
+ public final static int STOP = -1;
+ public final static int UP = 0;
+ public final static int DOWN = 1;
+
+ private float mScrollSpeed; // pixels per ms
+
+ private boolean mScrolling = false;
+
+ public boolean isScrolling() {
+ return mScrolling;
+ }
+
+ public int getScrollDir() {
+ return mScrolling ? scrollDir : STOP;
+ }
+
+ public DragScroller() {
+ }
+
+ public void startScrolling(int dir) {
+ if (!mScrolling) {
+ mAbort = false;
+ mScrolling = true;
+ tStart = SystemClock.uptimeMillis();
+ mPrevTime = tStart;
+ scrollDir = dir;
+ post(this);
+ }
+ }
+
+ public void stopScrolling(boolean now) {
+ if (now) {
+ DragSortListView.this.removeCallbacks(this);
+ mScrolling = false;
+ } else {
+ mAbort = true;
+ }
+ }
+
+ @Override
+ public void run() {
+ if (mAbort) {
+ mScrolling = false;
+ return;
+ }
+
+ final int first = getFirstVisiblePosition();
+ final int last = getLastVisiblePosition();
+ final int count = getCount();
+ final int padTop = getPaddingTop();
+ final int listHeight = getHeight() - padTop - getPaddingBottom();
+
+ int minY = Math.min(mY, mFloatViewMid + mFloatViewHeightHalf);
+ int maxY = Math.max(mY, mFloatViewMid - mFloatViewHeightHalf);
+
+ if (scrollDir == UP) {
+ View v = getChildAt(0);
+ if (v == null) {
+ mScrolling = false;
+ return;
+ } else {
+ if (first == 0 && v.getTop() == padTop) {
+ mScrolling = false;
+ return;
+ }
+ }
+ mScrollSpeed = mScrollProfile.getSpeed((mUpScrollStartYF - maxY)
+ / mDragUpScrollHeight, mPrevTime);
+ } else {
+ View v = getChildAt(last - first);
+ if (v == null) {
+ mScrolling = false;
+ return;
+ } else {
+ if (last == count - 1 && v.getBottom() <= listHeight + padTop) {
+ mScrolling = false;
+ return;
+ }
+ }
+ mScrollSpeed = -mScrollProfile.getSpeed((minY - mDownScrollStartYF)
+ / mDragDownScrollHeight, mPrevTime);
+ }
+
+ mCurrTime = SystemClock.uptimeMillis();
+ dt = (float) (mCurrTime - mPrevTime);
+
+ // dy is change in View position of a list item; i.e. positive dy
+ // means user is scrolling up (list item moves down the screen,
+ // remember
+ // y=0 is at top of View).
+ dy = (int) Math.round(mScrollSpeed * dt);
+
+ int movePos;
+ if (dy >= 0) {
+ dy = Math.min(listHeight, dy);
+ movePos = first;
+ } else {
+ dy = Math.max(-listHeight, dy);
+ movePos = last;
+ }
+
+ final View moveItem = getChildAt(movePos - first);
+ int top = moveItem.getTop() + dy;
+
+ if (movePos == 0 && top > padTop) {
+ top = padTop;
+ }
+
+ // always do scroll
+ mBlockLayoutRequests = true;
+
+ setSelectionFromTop(movePos, top - padTop);
+ DragSortListView.this.layoutChildren();
+ invalidate();
+
+ mBlockLayoutRequests = false;
+
+ // scroll means relative float View movement
+ doDragFloatView(movePos, moveItem, false);
+
+ mPrevTime = mCurrTime;
+
+ post(this);
+ }
+ }
+
+ // TODO: Bluuurgh... switch to SharedPreferences
+ private class DragSortTracker {
+ StringBuilder mBuilder = new StringBuilder();
+
+ File mFile;
+
+ private int mNumInBuffer = 0;
+ private int mNumFlushes = 0;
+
+ private boolean mTracking = false;
+
+ public DragSortTracker() {
+ File root = Environment.getExternalStorageDirectory();
+ mFile = new File(root, "dslv_state.txt");
+
+ if (!mFile.exists()) {
+ try {
+ mFile.createNewFile();
+ Log.d("mobeta", "file created");
+ } catch (IOException e) {
+ Log.w("mobeta", "Could not create dslv_state.txt");
+ Log.d("mobeta", e.getMessage());
+ }
+ }
+
+ }
+
+ public void startTracking() {
+ mBuilder.append("<DSLVStates>\n");
+ mNumFlushes = 0;
+ mTracking = true;
+ }
+
+ public void appendState() {
+ if (!mTracking) {
+ return;
+ }
+
+ mBuilder.append("<DSLVState>\n");
+ final int children = getChildCount();
+ final int first = getFirstVisiblePosition();
+ mBuilder.append(" <Positions>");
+ for (int i = 0; i < children; ++i) {
+ mBuilder.append(first + i).append(",");
+ }
+ mBuilder.append("</Positions>\n");
+
+ mBuilder.append(" <Tops>");
+ for (int i = 0; i < children; ++i) {
+ mBuilder.append(getChildAt(i).getTop()).append(",");
+ }
+ mBuilder.append("</Tops>\n");
+ mBuilder.append(" <Bottoms>");
+ for (int i = 0; i < children; ++i) {
+ mBuilder.append(getChildAt(i).getBottom()).append(",");
+ }
+ mBuilder.append("</Bottoms>\n");
+
+ mBuilder.append(" <FirstExpPos>").append(mFirstExpPos).append("</FirstExpPos>\n");
+ mBuilder.append(" <FirstExpBlankHeight>")
+ .append(getItemHeight(mFirstExpPos) - getChildHeight(mFirstExpPos))
+ .append("</FirstExpBlankHeight>\n");
+ mBuilder.append(" <SecondExpPos>").append(mSecondExpPos).append("</SecondExpPos>\n");
+ mBuilder.append(" <SecondExpBlankHeight>")
+ .append(getItemHeight(mSecondExpPos) - getChildHeight(mSecondExpPos))
+ .append("</SecondExpBlankHeight>\n");
+ mBuilder.append(" <SrcPos>").append(mSrcPos).append("</SrcPos>\n");
+ mBuilder.append(" <SrcHeight>").append(mFloatViewHeight + getDividerHeight())
+ .append("</SrcHeight>\n");
+ mBuilder.append(" <ViewHeight>").append(getHeight()).append("</ViewHeight>\n");
+ mBuilder.append(" <LastY>").append(mLastY).append("</LastY>\n");
+ mBuilder.append(" <FloatY>").append(mFloatViewMid).append("</FloatY>\n");
+ mBuilder.append(" <ShuffleEdges>");
+ for (int i = 0; i < children; ++i) {
+ mBuilder.append(getShuffleEdge(first + i, getChildAt(i).getTop())).append(",");
+ }
+ mBuilder.append("</ShuffleEdges>\n");
+
+ mBuilder.append("</DSLVState>\n");
+ mNumInBuffer++;
+
+ if (mNumInBuffer > 1000) {
+ flush();
+ mNumInBuffer = 0;
+ }
+ }
+
+ public void flush() {
+ if (!mTracking) {
+ return;
+ }
+
+ // save to file on sdcard
+ try {
+ boolean append = true;
+ if (mNumFlushes == 0) {
+ append = false;
+ }
+ FileWriter writer = new FileWriter(mFile, append);
+
+ writer.write(mBuilder.toString());
+ mBuilder.delete(0, mBuilder.length());
+
+ writer.flush();
+ writer.close();
+
+ mNumFlushes++;
+ } catch (IOException e) {
+ // do nothing
+ }
+ }
+
+ public void stopTracking() {
+ if (mTracking) {
+ mBuilder.append("</DSLVStates>\n");
+ flush();
+ mTracking = false;
+ }
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/com/mobeta/android/dslv/ResourceDragSortCursorAdapter.java b/app/src/main/java/com/mobeta/android/dslv/ResourceDragSortCursorAdapter.java
new file mode 100644
index 0000000..b589447
--- /dev/null
+++ b/app/src/main/java/com/mobeta/android/dslv/ResourceDragSortCursorAdapter.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.mobeta.android.dslv;
+
+import android.content.Context;
+import android.database.Cursor;
+import androidx.cursoradapter.widget.CursorAdapter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+// taken from v4 rev. 10 ResourceCursorAdapter.java
+
+/**
+ * Static library support version of the framework's {@link android.widget.ResourceCursorAdapter}.
+ * Used to write apps that run on platforms prior to Android 3.0. When running
+ * on Android 3.0 or above, this implementation is still used; it does not try
+ * to switch to the framework's implementation. See the framework SDK
+ * documentation for a class overview.
+ */
+public abstract class ResourceDragSortCursorAdapter extends DragSortCursorAdapter {
+ private int mLayout;
+
+ private int mDropDownLayout;
+
+ private LayoutInflater mInflater;
+
+ /**
+ * Constructor the enables auto-requery.
+ *
+ * @deprecated This option is discouraged, as it results in Cursor queries
+ * being performed on the application's UI thread and thus can cause poor
+ * responsiveness or even Application Not Responding errors. As an alternative,
+ * use {@link android.app.LoaderManager} with a {@link android.content.CursorLoader}.
+ *
+ * @param context The context where the ListView associated with this adapter is running
+ * @param layout resource identifier of a layout file that defines the views
+ * for this list item. Unless you override them later, this will
+ * define both the item views and the drop down views.
+ */
+ @Deprecated
+ public ResourceDragSortCursorAdapter(Context context, int layout, Cursor c) {
+ super(context, c);
+ mLayout = mDropDownLayout = layout;
+ mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ }
+
+ /**
+ * Constructor with default behavior as per
+ * {@link CursorAdapter#CursorAdapter(Context, Cursor, boolean)}; it is recommended
+ * you not use this, but instead {@link #ResourceCursorAdapter(Context, int, Cursor, int)}.
+ * When using this constructor, {@link #FLAG_REGISTER_CONTENT_OBSERVER}
+ * will always be set.
+ *
+ * @param context The context where the ListView associated with this adapter is running
+ * @param layout resource identifier of a layout file that defines the views
+ * for this list item. Unless you override them later, this will
+ * define both the item views and the drop down views.
+ * @param c The cursor from which to get the data.
+ * @param autoRequery If true the adapter will call requery() on the
+ * cursor whenever it changes so the most recent
+ * data is always displayed. Using true here is discouraged.
+ */
+ public ResourceDragSortCursorAdapter(Context context, int layout, Cursor c,
+ boolean autoRequery) {
+
+ super(context, c, autoRequery);
+ mLayout = mDropDownLayout = layout;
+ mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ }
+
+ /**
+ * Standard constructor.
+ *
+ * @param context The context where the ListView associated with this adapter is running
+ * @param layout Resource identifier of a layout file that defines the views
+ * for this list item. Unless you override them later, this will
+ * define both the item views and the drop down views.
+ * @param c The cursor from which to get the data.
+ * @param flags Flags used to determine the behavior of the adapter,
+ * as per {@link CursorAdapter#CursorAdapter(Context, Cursor, int)}.
+ */
+ public ResourceDragSortCursorAdapter(Context context, int layout, Cursor c, int flags) {
+ super(context, c, flags);
+ mLayout = mDropDownLayout = layout;
+ mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ }
+
+ /**
+ * Inflates view(s) from the specified XML file.
+ *
+ * @see android.widget.CursorAdapter#newView(android.content.Context,
+ * android.database.Cursor, ViewGroup)
+ */
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return mInflater.inflate(mLayout, parent, false);
+ }
+
+ @Override
+ public View newDropDownView(Context context, Cursor cursor, ViewGroup parent) {
+ return mInflater.inflate(mDropDownLayout, parent, false);
+ }
+
+ /**
+ * <p>Sets the layout resource of the item views.</p>
+ *
+ * @param layout the layout resources used to create item views
+ */
+ public void setViewResource(int layout) {
+ mLayout = layout;
+ }
+
+ /**
+ * <p>Sets the layout resource of the drop down views.</p>
+ *
+ * @param dropDownLayout the layout resources used to create drop down views
+ */
+ public void setDropDownViewResource(int dropDownLayout) {
+ mDropDownLayout = dropDownLayout;
+ }
+}
diff --git a/app/src/main/java/com/mobeta/android/dslv/SimpleDragSortCursorAdapter.java b/app/src/main/java/com/mobeta/android/dslv/SimpleDragSortCursorAdapter.java
new file mode 100644
index 0000000..fa4e669
--- /dev/null
+++ b/app/src/main/java/com/mobeta/android/dslv/SimpleDragSortCursorAdapter.java
@@ -0,0 +1,427 @@
+/*
+ * Copyright (C) 2006 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.mobeta.android.dslv;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import androidx.cursoradapter.widget.CursorAdapter;
+import androidx.cursoradapter.widget.SimpleCursorAdapter;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+// taken from sdk/sources/android-16/android/widget/SimpleCursorAdapter.java
+
+/**
+ * An easy adapter to map columns from a cursor to TextViews or ImageViews
+ * defined in an XML file. You can specify which columns you want, which
+ * views you want to display the columns, and the XML file that defines
+ * the appearance of these views.
+ *
+ * Binding occurs in two phases. First, if a
+ * {@link android.widget.SimpleCursorAdapter.ViewBinder} is available,
+ * {@link ViewBinder#setViewValue(android.view.View, android.database.Cursor, int)}
+ * is invoked. If the returned value is true, binding has occured. If the
+ * returned value is false and the view to bind is a TextView,
+ * {@link #setViewText(TextView, String)} is invoked. If the returned value
+ * is false and the view to bind is an ImageView,
+ * {@link #setViewImage(ImageView, String)} is invoked. If no appropriate
+ * binding can be found, an {@link IllegalStateException} is thrown.
+ *
+ * If this adapter is used with filtering, for instance in an
+ * {@link android.widget.AutoCompleteTextView}, you can use the
+ * {@link android.widget.SimpleCursorAdapter.CursorToStringConverter} and the
+ * {@link android.widget.FilterQueryProvider} interfaces
+ * to get control over the filtering process. You can refer to
+ * {@link #convertToString(android.database.Cursor)} and
+ * {@link #runQueryOnBackgroundThread(CharSequence)} for more information.
+ */
+public class SimpleDragSortCursorAdapter extends ResourceDragSortCursorAdapter {
+ /**
+ * A list of columns containing the data to bind to the UI.
+ * This field should be made private, so it is hidden from the SDK.
+ * {@hide}
+ */
+ protected int[] mFrom;
+ /**
+ * A list of View ids representing the views to which the data must be bound.
+ * This field should be made private, so it is hidden from the SDK.
+ * {@hide}
+ */
+ protected int[] mTo;
+
+ private int mStringConversionColumn = -1;
+ private CursorToStringConverter mCursorToStringConverter;
+ private ViewBinder mViewBinder;
+
+ String[] mOriginalFrom;
+
+ /**
+ * Constructor the enables auto-requery.
+ *
+ * @deprecated This option is discouraged, as it results in Cursor queries
+ * being performed on the application's UI thread and thus can cause poor
+ * responsiveness or even Application Not Responding errors. As an alternative,
+ * use {@link android.app.LoaderManager} with a {@link android.content.CursorLoader}.
+ */
+ @Deprecated
+ public SimpleDragSortCursorAdapter(Context context, int layout, Cursor c, String[] from,
+ int[] to) {
+
+ super(context, layout, c);
+ mTo = to;
+ mOriginalFrom = from;
+ findColumns(c, from);
+ }
+
+ /**
+ * Standard constructor.
+ *
+ * @param context The context where the ListView associated with this
+ * SimpleListItemFactory is running
+ * @param layout resource identifier of a layout file that defines the views
+ * for this list item. The layout file should include at least
+ * those named views defined in "to"
+ * @param c The database cursor. Can be null if the cursor is not available yet.
+ * @param from A list of column names representing the data to bind to the UI. Can be null
+ * if the cursor is not available yet.
+ * @param to The views that should display column in the "from" parameter.
+ * These should all be TextViews. The first N views in this list
+ * are given the values of the first N columns in the from
+ * parameter. Can be null if the cursor is not available yet.
+ * @param flags Flags used to determine the behavior of the adapter,
+ * as per {@link CursorAdapter#CursorAdapter(Context, Cursor, int)}.
+ */
+ public SimpleDragSortCursorAdapter(Context context, int layout, Cursor c, String[] from,
+ int[] to, int flags) {
+
+ super(context, layout, c, flags);
+ mTo = to;
+ mOriginalFrom = from;
+ findColumns(c, from);
+ }
+
+ /**
+ * Binds all of the field names passed into the "to" parameter of the
+ * constructor with their corresponding cursor columns as specified in the
+ * "from" parameter.
+ *
+ * Binding occurs in two phases. First, if a
+ * {@link android.widget.SimpleCursorAdapter.ViewBinder} is available,
+ * {@link ViewBinder#setViewValue(android.view.View, android.database.Cursor, int)}
+ * is invoked. If the returned value is true, binding has occured. If the
+ * returned value is false and the view to bind is a TextView,
+ * {@link #setViewText(TextView, String)} is invoked. If the returned value is
+ * false and the view to bind is an ImageView,
+ * {@link #setViewImage(ImageView, String)} is invoked. If no appropriate
+ * binding can be found, an {@link IllegalStateException} is thrown.
+ *
+ * @throws IllegalStateException if binding cannot occur
+ *
+ * @see android.widget.CursorAdapter#bindView(android.view.View,
+ * android.content.Context, android.database.Cursor)
+ * @see #getViewBinder()
+ * @see #setViewBinder(android.widget.SimpleCursorAdapter.ViewBinder)
+ * @see #setViewImage(ImageView, String)
+ * @see #setViewText(TextView, String)
+ */
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ final ViewBinder binder = mViewBinder;
+ final int count = mTo.length;
+ final int[] from = mFrom;
+ final int[] to = mTo;
+
+ for (int i = 0; i < count; i++) {
+ final View v = view.findViewById(to[i]);
+ if (v != null) {
+ boolean bound = false;
+ if (binder != null) {
+ bound = binder.setViewValue(v, cursor, from[i]);
+ }
+
+ if (!bound) {
+ String text = cursor.getString(from[i]);
+ if (text == null) {
+ text = "";
+ }
+
+ if (v instanceof TextView) {
+ setViewText((TextView) v, text);
+ } else if (v instanceof ImageView) {
+ setViewImage((ImageView) v, text);
+ } else {
+ throw new IllegalStateException(v.getClass().getName() + " is not a " +
+ " view that can be bounds by this SimpleCursorAdapter");
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns the {@link ViewBinder} used to bind data to views.
+ *
+ * @return a ViewBinder or null if the binder does not exist
+ *
+ * @see #bindView(android.view.View, android.content.Context, android.database.Cursor)
+ * @see #setViewBinder(android.widget.SimpleCursorAdapter.ViewBinder)
+ */
+ public ViewBinder getViewBinder() {
+ return mViewBinder;
+ }
+
+ /**
+ * Sets the binder used to bind data to views.
+ *
+ * @param viewBinder the binder used to bind data to views, can be null to
+ * remove the existing binder
+ *
+ * @see #bindView(android.view.View, android.content.Context, android.database.Cursor)
+ * @see #getViewBinder()
+ */
+ public void setViewBinder(ViewBinder viewBinder) {
+ mViewBinder = viewBinder;
+ }
+
+ /**
+ * Called by bindView() to set the image for an ImageView but only if
+ * there is no existing ViewBinder or if the existing ViewBinder cannot
+ * handle binding to an ImageView.
+ *
+ * By default, the value will be treated as an image resource. If the
+ * value cannot be used as an image resource, the value is used as an
+ * image Uri.
+ *
+ * Intended to be overridden by Adapters that need to filter strings
+ * retrieved from the database.
+ *
+ * @param v ImageView to receive an image
+ * @param value the value retrieved from the cursor
+ */
+ public void setViewImage(ImageView v, String value) {
+ try {
+ v.setImageResource(Integer.parseInt(value));
+ } catch (NumberFormatException nfe) {
+ v.setImageURI(Uri.parse(value));
+ }
+ }
+
+ /**
+ * Called by bindView() to set the text for a TextView but only if
+ * there is no existing ViewBinder or if the existing ViewBinder cannot
+ * handle binding to a TextView.
+ *
+ * Intended to be overridden by Adapters that need to filter strings
+ * retrieved from the database.
+ *
+ * @param v TextView to receive text
+ * @param text the text to be set for the TextView
+ */
+ public void setViewText(TextView v, String text) {
+ v.setText(text);
+ }
+
+ /**
+ * Return the index of the column used to get a String representation
+ * of the Cursor.
+ *
+ * @return a valid index in the current Cursor or -1
+ *
+ * @see android.widget.CursorAdapter#convertToString(android.database.Cursor)
+ * @see #setStringConversionColumn(int)
+ * @see #setCursorToStringConverter(android.widget.SimpleCursorAdapter.CursorToStringConverter)
+ * @see #getCursorToStringConverter()
+ */
+ public int getStringConversionColumn() {
+ return mStringConversionColumn;
+ }
+
+ /**
+ * Defines the index of the column in the Cursor used to get a String
+ * representation of that Cursor. The column is used to convert the
+ * Cursor to a String only when the current CursorToStringConverter
+ * is null.
+ *
+ * @param stringConversionColumn a valid index in the current Cursor or -1 to use the default
+ * conversion mechanism
+ *
+ * @see android.widget.CursorAdapter#convertToString(android.database.Cursor)
+ * @see #getStringConversionColumn()
+ * @see #setCursorToStringConverter(android.widget.SimpleCursorAdapter.CursorToStringConverter)
+ * @see #getCursorToStringConverter()
+ */
+ public void setStringConversionColumn(int stringConversionColumn) {
+ mStringConversionColumn = stringConversionColumn;
+ }
+
+ /**
+ * Returns the converter used to convert the filtering Cursor
+ * into a String.
+ *
+ * @return null if the converter does not exist or an instance of
+ * {@link android.widget.SimpleCursorAdapter.CursorToStringConverter}
+ *
+ * @see #setCursorToStringConverter(android.widget.SimpleCursorAdapter.CursorToStringConverter)
+ * @see #getStringConversionColumn()
+ * @see #setStringConversionColumn(int)
+ * @see android.widget.CursorAdapter#convertToString(android.database.Cursor)
+ */
+ public CursorToStringConverter getCursorToStringConverter() {
+ return mCursorToStringConverter;
+ }
+
+ /**
+ * Sets the converter used to convert the filtering Cursor
+ * into a String.
+ *
+ * @param cursorToStringConverter the Cursor to String converter, or
+ * null to remove the converter
+ *
+ * @see #setCursorToStringConverter(android.widget.SimpleCursorAdapter.CursorToStringConverter)
+ * @see #getStringConversionColumn()
+ * @see #setStringConversionColumn(int)
+ * @see android.widget.CursorAdapter#convertToString(android.database.Cursor)
+ */
+ public void setCursorToStringConverter(CursorToStringConverter cursorToStringConverter) {
+ mCursorToStringConverter = cursorToStringConverter;
+ }
+
+ /**
+ * Returns a CharSequence representation of the specified Cursor as defined
+ * by the current CursorToStringConverter. If no CursorToStringConverter
+ * has been set, the String conversion column is used instead. If the
+ * conversion column is -1, the returned String is empty if the cursor
+ * is null or Cursor.toString().
+ *
+ * @param cursor the Cursor to convert to a CharSequence
+ *
+ * @return a non-null CharSequence representing the cursor
+ */
+ @Override
+ public CharSequence convertToString(Cursor cursor) {
+ if (mCursorToStringConverter != null) {
+ return mCursorToStringConverter.convertToString(cursor);
+ } else if (mStringConversionColumn > -1) {
+ return cursor.getString(mStringConversionColumn);
+ }
+
+ return super.convertToString(cursor);
+ }
+
+ /**
+ * Create a map from an array of strings to an array of column-id integers in cursor c.
+ * If c is null, the array will be discarded.
+ *
+ * @param c the cursor to find the columns from
+ * @param from the Strings naming the columns of interest
+ */
+ private void findColumns(Cursor c, String[] from) {
+ if (c != null) {
+ int i;
+ int count = from.length;
+ if (mFrom == null || mFrom.length != count) {
+ mFrom = new int[count];
+ }
+ for (i = 0; i < count; i++) {
+ mFrom[i] = c.getColumnIndexOrThrow(from[i]);
+ }
+ } else {
+ mFrom = null;
+ }
+ }
+
+ @Override
+ public Cursor swapCursor(Cursor c) {
+ // super.swapCursor() will notify observers before we have
+ // a valid mapping, make sure we have a mapping before this
+ // happens
+ findColumns(c, mOriginalFrom);
+ return super.swapCursor(c);
+ }
+
+ /**
+ * Change the cursor and change the column-to-view mappings at the same time.
+ *
+ * @param c The database cursor. Can be null if the cursor is not available yet.
+ * @param from A list of column names representing the data to bind to the UI. Can be null
+ * if the cursor is not available yet.
+ * @param to The views that should display column in the "from" parameter.
+ * These should all be TextViews. The first N views in this list
+ * are given the values of the first N columns in the from
+ * parameter. Can be null if the cursor is not available yet.
+ */
+ public void changeCursorAndColumns(Cursor c, String[] from, int[] to) {
+ mOriginalFrom = from;
+ mTo = to;
+ // super.changeCursor() will notify observers before we have
+ // a valid mapping, make sure we have a mapping before this
+ // happens
+ findColumns(c, mOriginalFrom);
+ super.changeCursor(c);
+ }
+
+ /**
+ * This class can be used by external clients of SimpleCursorAdapter
+ * to bind values fom the Cursor to views.
+ *
+ * You should use this class to bind values from the Cursor to views
+ * that are not directly supported by SimpleCursorAdapter or to
+ * change the way binding occurs for views supported by
+ * SimpleCursorAdapter.
+ *
+ * @see SimpleCursorAdapter#bindView(android.view.View, android.content.Context, android.database.Cursor)
+ * @see SimpleCursorAdapter#setViewImage(ImageView, String)
+ * @see SimpleCursorAdapter#setViewText(TextView, String)
+ */
+ public static interface ViewBinder {
+ /**
+ * Binds the Cursor column defined by the specified index to the specified view.
+ *
+ * When binding is handled by this ViewBinder, this method must return true.
+ * If this method returns false, SimpleCursorAdapter will attempts to handle
+ * the binding on its own.
+ *
+ * @param view the view to bind the data to
+ * @param cursor the cursor to get the data from
+ * @param columnIndex the column at which the data can be found in the cursor
+ *
+ * @return true if the data was bound to the view, false otherwise
+ */
+ boolean setViewValue(View view, Cursor cursor, int columnIndex);
+ }
+
+ /**
+ * This class can be used by external clients of SimpleCursorAdapter
+ * to define how the Cursor should be converted to a String.
+ *
+ * @see android.widget.CursorAdapter#convertToString(android.database.Cursor)
+ */
+ public static interface CursorToStringConverter {
+ /**
+ * Returns a CharSequence representing the specified Cursor.
+ *
+ * @param cursor the cursor for which a CharSequence representation
+ * is requested
+ *
+ * @return a non-null CharSequence representing the cursor
+ */
+ CharSequence convertToString(Cursor cursor);
+ }
+
+}
diff --git a/app/src/main/java/com/mobeta/android/dslv/SimpleFloatViewManager.java b/app/src/main/java/com/mobeta/android/dslv/SimpleFloatViewManager.java
new file mode 100644
index 0000000..49cea14
--- /dev/null
+++ b/app/src/main/java/com/mobeta/android/dslv/SimpleFloatViewManager.java
@@ -0,0 +1,85 @@
+package com.mobeta.android.dslv;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.Point;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.ListView;
+
+/**
+ * Simple implementation of the FloatViewManager class. Uses list
+ * items as they appear in the ListView to create the floating View.
+ */
+public class SimpleFloatViewManager implements DragSortListView.FloatViewManager {
+
+ private Bitmap mFloatBitmap;
+
+ private ImageView mImageView;
+
+ private int mFloatBGColor = Color.BLACK;
+
+ private ListView mListView;
+
+ public SimpleFloatViewManager(ListView lv) {
+ mListView = lv;
+ }
+
+ public void setBackgroundColor(int color) {
+ mFloatBGColor = color;
+ }
+
+ /**
+ * This simple implementation creates a Bitmap copy of the
+ * list item currently shown at ListView <code>position</code>.
+ */
+ @Override
+ public View onCreateFloatView(int position) {
+ // Guaranteed that this will not be null? I think so. Nope, got
+ // a NullPointerException once...
+ View v = mListView.getChildAt(position + mListView.getHeaderViewsCount()
+ - mListView.getFirstVisiblePosition());
+
+ if (v == null) {
+ return null;
+ }
+
+ v.setPressed(false);
+
+ // Create a copy of the drawing cache so that it does not get
+ // recycled by the framework when the list tries to clean up memory
+ v.setDrawingCacheEnabled(true);
+ mFloatBitmap = Bitmap.createBitmap(v.getDrawingCache());
+ v.setDrawingCacheEnabled(false);
+
+ if (mImageView == null) {
+ mImageView = new ImageView(mListView.getContext());
+ }
+ mImageView.setBackgroundColor(mFloatBGColor);
+ mImageView.setPadding(0, 0, 0, 0);
+ mImageView.setImageBitmap(mFloatBitmap);
+ mImageView.setLayoutParams(new ViewGroup.LayoutParams(v.getWidth(), v.getHeight()));
+
+ return mImageView;
+ }
+
+ @Override
+ public void onDragFloatView(View floatView, Point position, Point touch) {
+ // Do nothing so we have a concrete class
+ }
+
+ /**
+ * Removes the Bitmap from the ImageView created in
+ * onCreateFloatView() and tells the system to recycle it.
+ */
+ @Override
+ public void onDestroyFloatView(View floatView) {
+ ((ImageView) floatView).setImageDrawable(null);
+
+ mFloatBitmap.recycle();
+ mFloatBitmap = null;
+ }
+
+}
+
diff --git a/app/src/main/java/lineageos/app/LineageContextConstants.java b/app/src/main/java/lineageos/app/LineageContextConstants.java
new file mode 100644
index 0000000..a5ab9d3
--- /dev/null
+++ b/app/src/main/java/lineageos/app/LineageContextConstants.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2015, The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.app;
+
+
+public final class LineageContextConstants {
+
+ private LineageContextConstants() {
+ // Empty constructor
+ }
+
+ public static final String LINEAGE_WEATHER_SERVICE = "lineageweather";
+
+ public static class Features {
+ public static final String WEATHER_SERVICES = "org.lineageos.weather";
+ }
+}
diff --git a/app/src/main/java/lineageos/os/Build.java b/app/src/main/java/lineageos/os/Build.java
new file mode 100644
index 0000000..61d475b
--- /dev/null
+++ b/app/src/main/java/lineageos/os/Build.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2015 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.os;
+
+
+public class Build {
+ public static class LINEAGE_VERSION_CODES {
+ public static final int APRICOT = 1;
+ public static final int BOYSENBERRY = 2;
+ public static final int CANTALOUPE = 3;
+ public static final int DRAGON_FRUIT = 4;
+ public static final int ELDERBERRY = 5;
+ public static final int FIG = 6;
+ public static final int GUAVA = 7;
+ public static final int HACKBERRY = 8;
+ public static final int ILAMA = 9;
+ }
+}
diff --git a/app/src/main/java/lineageos/os/Concierge.java b/app/src/main/java/lineageos/os/Concierge.java
new file mode 100644
index 0000000..d4f9b4e
--- /dev/null
+++ b/app/src/main/java/lineageos/os/Concierge.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.os;
+
+import android.os.Parcel;
+
+import lineageos.os.Build.LINEAGE_VERSION_CODES;
+
+/**
+ * Simply, Concierge handles your parcels and makes sure they get marshalled and unmarshalled
+ * correctly when cross IPC boundaries even when there is a version mismatch between the client
+ * sdk level and the framework implementation.
+ *
+ * <p>On incoming parcel (to be unmarshalled):
+ *
+ * <pre class="prettyprint">
+ * ParcelInfo incomingParcelInfo = Concierge.receiveParcel(incomingParcel);
+ * int parcelableVersion = incomingParcelInfo.getParcelVersion();
+ *
+ * // Do unmarshalling steps here iterating over every plausible version
+ *
+ * // Complete the process
+ * incomingParcelInfo.complete();
+ * </pre>
+ *
+ * <p>On outgoing parcel (to be marshalled):
+ *
+ * <pre class="prettyprint">
+ * ParcelInfo outgoingParcelInfo = Concierge.prepareParcel(incomingParcel);
+ *
+ * // Do marshalling steps here iterating over every plausible version
+ *
+ * // Complete the process
+ * outgoingParcelInfo.complete();
+ * </pre>
+ */
+public final class Concierge {
+
+ /** Not instantiable */
+ private Concierge() {
+ // Don't instantiate
+ }
+
+ /**
+ * Since there might be a case where new versions of the lineage framework use applications running
+ * old versions of the protocol (and thus old versions of this class), we need a versioning
+ * system for the parcels sent between the core framework and its sdk users.
+ *
+ * This parcelable version should be the latest version API version listed in
+ * {@link LINEAGE_VERSION_CODES}
+ * @hide
+ */
+ public static final int PARCELABLE_VERSION = LINEAGE_VERSION_CODES.ILAMA;
+
+ /**
+ * Tell the concierge to receive our parcel, so we can get information from it.
+ *
+ * MUST CALL {@link ParcelInfo#complete()} AFTER UNMARSHALLING.
+ *
+ * @param parcel Incoming parcel to be unmarshalled
+ * @return {@link ParcelInfo} containing parcel information, specifically the version.
+ */
+ public static ParcelInfo receiveParcel(Parcel parcel) {
+ return new ParcelInfo(parcel);
+ }
+
+ /**
+ * Prepare a parcel for the Concierge.
+ *
+ * MUST CALL {@link ParcelInfo#complete()} AFTER MARSHALLING.
+ *
+ * @param parcel Outgoing parcel to be marshalled
+ * @return {@link ParcelInfo} containing parcel information, specifically the version.
+ */
+ public static ParcelInfo prepareParcel(Parcel parcel) {
+ return new ParcelInfo(parcel, PARCELABLE_VERSION);
+ }
+
+ /**
+ * Parcel header info specific to the Parcel object that is passed in via
+ * {@link #prepareParcel(Parcel)} or {@link #receiveParcel(Parcel)}. The exposed method
+ * of {@link #getParcelVersion()} gets the api level of the parcel object.
+ */
+ public final static class ParcelInfo {
+ private Parcel mParcel;
+ private int mParcelableVersion;
+ private int mParcelableSize;
+ private int mStartPosition;
+ private int mSizePosition;
+ private boolean mCreation = false;
+
+ ParcelInfo(Parcel parcel) {
+ mCreation = false;
+ mParcel = parcel;
+ mParcelableVersion = parcel.readInt();
+ mParcelableSize = parcel.readInt();
+ mStartPosition = parcel.dataPosition();
+ }
+
+ ParcelInfo(Parcel parcel, int parcelableVersion) {
+ mCreation = true;
+ mParcel = parcel;
+ mParcelableVersion = parcelableVersion;
+
+ // Write parcelable version, make sure to define explicit changes
+ // within {@link #PARCELABLE_VERSION);
+ mParcel.writeInt(mParcelableVersion);
+
+ // Inject a placeholder that will store the parcel size from this point on
+ // (not including the size itself).
+ mSizePosition = parcel.dataPosition();
+ mParcel.writeInt(0);
+ mStartPosition = parcel.dataPosition();
+ }
+
+ /**
+ * Get the parcel version from the {@link Parcel} received by the Concierge.
+ * @return {@link #PARCELABLE_VERSION} of the {@link Parcel}
+ */
+ public int getParcelVersion() {
+ return mParcelableVersion;
+ }
+
+ /**
+ * Complete the {@link ParcelInfo} for the Concierge.
+ */
+ public void complete() {
+ if (mCreation) {
+ // Go back and write size
+ mParcelableSize = mParcel.dataPosition() - mStartPosition;
+ mParcel.setDataPosition(mSizePosition);
+ mParcel.writeInt(mParcelableSize);
+ mParcel.setDataPosition(mStartPosition + mParcelableSize);
+ } else {
+ mParcel.setDataPosition(mStartPosition + mParcelableSize);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/lineageos/providers/WeatherContract.java b/app/src/main/java/lineageos/providers/WeatherContract.java
new file mode 100644
index 0000000..b352db5
--- /dev/null
+++ b/app/src/main/java/lineageos/providers/WeatherContract.java
@@ -0,0 +1,245 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.providers;
+
+import android.net.Uri;
+
+/**
+ * The contract between the weather provider and applications.
+ */
+public class WeatherContract {
+
+ /**
+ * The authority of the weather content provider
+ */
+ public static final String AUTHORITY = "org.lineageos.weather";
+
+ /**
+ * A content:// style uri to the authority for the weather provider
+ */
+ public static final Uri AUTHORITY_URI = Uri.parse("content://" + AUTHORITY);
+
+ public static class WeatherColumns {
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "weather");
+
+ public static final Uri CURRENT_AND_FORECAST_WEATHER_URI
+ = Uri.withAppendedPath(CONTENT_URI, "current_and_forecast");
+ public static final Uri CURRENT_WEATHER_URI
+ = Uri.withAppendedPath(CONTENT_URI, "current");
+ public static final Uri FORECAST_WEATHER_URI
+ = Uri.withAppendedPath(CONTENT_URI, "forecast");
+
+ /**
+ * The city name
+ * <P>Type: TEXT</P>
+ */
+ public static final String CURRENT_CITY = "city";
+
+ /**
+ * A Valid {@link WeatherCode}
+ * <P>Type: INTEGER</P>
+ */
+ public static final String CURRENT_CONDITION_CODE = "condition_code";
+
+
+ /**
+ * A localized string mapped to the current weather condition code. Note that, if no
+ * locale is found, the string will be in english
+ * <P>Type: TEXT</P>
+ */
+ public static final String CURRENT_CONDITION = "condition";
+
+ /**
+ * The current weather temperature
+ * <P>Type: DOUBLE</P>
+ */
+ public static final String CURRENT_TEMPERATURE = "temperature";
+
+ /**
+ * The unit in which current temperature is reported
+ * <P>Type: INTEGER</P>
+ * Can be one of the following:
+ * <ul>
+ * <li>{@link TempUnit#CELSIUS}</li>
+ * <li>{@link TempUnit#FAHRENHEIT}</li>
+ * </ul>
+ */
+ public static final String CURRENT_TEMPERATURE_UNIT = "temperature_unit";
+
+ /**
+ * The current weather humidity
+ * <P>Type: DOUBLE</P>
+ */
+ public static final String CURRENT_HUMIDITY = "humidity";
+
+ /**
+ * The current wind direction (in degrees)
+ * <P>Type: DOUBLE</P>
+ */
+ public static final String CURRENT_WIND_DIRECTION = "wind_direction";
+
+ /**
+ * The current wind speed
+ * <P>Type: DOUBLE</P>
+ */
+ public static final String CURRENT_WIND_SPEED = "wind_speed";
+
+ /**
+ * The unit in which the wind speed is reported
+ * <P>Type: INTEGER</P>
+ * Can be one of the following:
+ * <ul>
+ * <li>{@link WindSpeedUnit#KPH}</li>
+ * <li>{@link WindSpeedUnit#MPH}</li>
+ * </ul>
+ */
+ public static final String CURRENT_WIND_SPEED_UNIT = "wind_speed_unit";
+
+ /**
+ * The timestamp when this weather was reported
+ * <P>Type: LONG</P>
+ */
+ public static final String CURRENT_TIMESTAMP = "timestamp";
+
+ /**
+ * Today's high temperature.
+ * <p>Type: DOUBLE</p>
+ */
+ public static final String TODAYS_HIGH_TEMPERATURE = "todays_high";
+
+ /**
+ * Today's low temperature.
+ * <p>Type: DOUBLE</p>
+ */
+ public static final String TODAYS_LOW_TEMPERATURE = "todays_low";
+
+ /**
+ * The forecasted low temperature
+ * <P>Type: DOUBLE</P>
+ */
+ public static final String FORECAST_LOW = "forecast_low";
+
+ /**
+ * The forecasted high temperature
+ * <P>Type: DOUBLE</P>
+ */
+ public static final String FORECAST_HIGH = "forecast_high";
+
+ /**
+ * A localized string mapped to the forecasted weather condition code. Note that, if no
+ * locale is found, the string will be in english
+ * <P>Type: TEXT</P>
+ */
+ public static final String FORECAST_CONDITION = "forecast_condition";
+
+ /**
+ * The code identifying the forecasted weather condition.
+ * @see #CURRENT_CONDITION_CODE
+ */
+ public static final String FORECAST_CONDITION_CODE = "forecast_condition_code";
+
+ /**
+ * Temperature units
+ */
+ public static final class TempUnit {
+ private TempUnit() {}
+ public final static int CELSIUS = 1;
+ public final static int FAHRENHEIT = 2;
+ }
+
+ /**
+ * Wind speed units
+ */
+ public static final class WindSpeedUnit {
+ private WindSpeedUnit() {}
+ /**
+ * Kilometers per hour
+ */
+ public final static int KPH = 1;
+
+ /**
+ * Miles per hour
+ */
+ public final static int MPH = 2;
+ }
+
+ /**
+ * Weather condition codes
+ */
+ public static final class WeatherCode {
+ private WeatherCode() {}
+
+ /**
+ * @hide
+ */
+ public final static int WEATHER_CODE_MIN = 0;
+
+ public final static int TORNADO = 0;
+ public final static int TROPICAL_STORM = 1;
+ public final static int HURRICANE = 2;
+ public final static int SEVERE_THUNDERSTORMS = 3;
+ public final static int THUNDERSTORMS = 4;
+ public final static int MIXED_RAIN_AND_SNOW = 5;
+ public final static int MIXED_RAIN_AND_SLEET = 6;
+ public final static int MIXED_SNOW_AND_SLEET = 7;
+ public final static int FREEZING_DRIZZLE = 8;
+ public final static int DRIZZLE = 9;
+ public final static int FREEZING_RAIN = 10;
+ public final static int SHOWERS = 11;
+ public final static int SNOW_FLURRIES = 12;
+ public final static int LIGHT_SNOW_SHOWERS = 13;
+ public final static int BLOWING_SNOW = 14;
+ public final static int SNOW = 15;
+ public final static int HAIL = 16;
+ public final static int SLEET = 17;
+ public final static int DUST = 18;
+ public final static int FOGGY = 19;
+ public final static int HAZE = 20;
+ public final static int SMOKY = 21;
+ public final static int BLUSTERY = 22;
+ public final static int WINDY = 23;
+ public final static int COLD = 24;
+ public final static int CLOUDY = 25;
+ public final static int MOSTLY_CLOUDY_NIGHT = 26;
+ public final static int MOSTLY_CLOUDY_DAY = 27;
+ public final static int PARTLY_CLOUDY_NIGHT = 28;
+ public final static int PARTLY_CLOUDY_DAY = 29;
+ public final static int CLEAR_NIGHT = 30;
+ public final static int SUNNY = 31;
+ public final static int FAIR_NIGHT = 32;
+ public final static int FAIR_DAY = 33;
+ public final static int MIXED_RAIN_AND_HAIL = 34;
+ public final static int HOT = 35;
+ public final static int ISOLATED_THUNDERSTORMS = 36;
+ public final static int SCATTERED_THUNDERSTORMS = 37;
+ public final static int SCATTERED_SHOWERS = 38;
+ public final static int HEAVY_SNOW = 39;
+ public final static int SCATTERED_SNOW_SHOWERS = 40;
+ public final static int PARTLY_CLOUDY = 41;
+ public final static int THUNDERSHOWER = 42;
+ public final static int SNOW_SHOWERS = 43;
+ public final static int ISOLATED_THUNDERSHOWERS = 44;
+
+ /**
+ * @hide
+ */
+ public final static int WEATHER_CODE_MAX = 44;
+
+ public final static int NOT_AVAILABLE = 3200;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/lineageos/weather/LineageWeatherManager.java b/app/src/main/java/lineageos/weather/LineageWeatherManager.java
new file mode 100644
index 0000000..48c767d
--- /dev/null
+++ b/app/src/main/java/lineageos/weather/LineageWeatherManager.java
@@ -0,0 +1,435 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weather;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.location.Location;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.ArraySet;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import lineageos.app.LineageContextConstants;
+import lineageos.providers.WeatherContract;
+
+/**
+ * Provides access to the weather services in the device.
+ */
+@RequiresApi(api = Build.VERSION_CODES.M)
+public class LineageWeatherManager {
+
+ private static ILineageWeatherManager sWeatherManagerService;
+ private static LineageWeatherManager sInstance;
+ private Context mContext;
+ private Map<RequestInfo,WeatherUpdateRequestListener> mWeatherUpdateRequestListeners
+ = Collections.synchronizedMap(new HashMap<RequestInfo,WeatherUpdateRequestListener>());
+ private Map<RequestInfo,LookupCityRequestListener> mLookupNameRequestListeners
+ = Collections.synchronizedMap(new HashMap<RequestInfo,LookupCityRequestListener>());
+ private Handler mHandler;
+ private Set<WeatherServiceProviderChangeListener> mProviderChangedListeners = new ArraySet<>();
+
+ private static final String TAG = LineageWeatherManager.class.getSimpleName();
+
+
+ /**
+ * The different request statuses
+ */
+ public static final class RequestStatus {
+
+ private RequestStatus() {}
+
+ /**
+ * Request successfully completed
+ */
+ public static final int COMPLETED = 1;
+ /**
+ * An error occurred while trying to honor the request
+ */
+ public static final int FAILED = -1;
+ /**
+ * The request can't be processed at this time
+ */
+ public static final int SUBMITTED_TOO_SOON = -2;
+ /**
+ * Another request is already in progress
+ */
+ public static final int ALREADY_IN_PROGRESS = -3;
+ /**
+ * No match found for the query
+ */
+ public static final int NO_MATCH_FOUND = -4;
+ }
+
+ private LineageWeatherManager(Context context) {
+ Context appContext = context.getApplicationContext();
+ mContext = (appContext != null) ? appContext : context;
+ sWeatherManagerService = getService();
+
+ if (context.getPackageManager().hasSystemFeature(
+ LineageContextConstants.Features.WEATHER_SERVICES) && (sWeatherManagerService == null)) {
+ Log.wtf(TAG, "Unable to bind the LineageWeatherManagerService");
+ }
+ mHandler = new Handler(appContext.getMainLooper());
+ }
+
+ /**
+ * Gets or creates an instance of the {@link lineageos.weather.LineageWeatherManager}
+ * @param context
+ * @return {@link LineageWeatherManager}
+ */
+ public static LineageWeatherManager getInstance(Context context) {
+ if (sInstance == null) {
+ sInstance = new LineageWeatherManager(context);
+ }
+ return sInstance;
+ }
+
+ /**
+ * @hide
+ */
+ @SuppressLint("PrivateApi")
+ public static ILineageWeatherManager getService() {
+ if (sWeatherManagerService != null) {
+ return sWeatherManagerService;
+ }
+
+ // This is a Gadgetbridge hack
+ IBinder binder = null;
+ try {
+ Class localClass = Class.forName("android.os.ServiceManager");
+ Method getService = localClass.getMethod("getService", String.class);
+ Object result = getService.invoke(localClass, LineageContextConstants.LINEAGE_WEATHER_SERVICE);
+ if (result != null) {
+ binder = (IBinder) result;
+ }
+ } catch (NoSuchMethodException e) {
+ e.printStackTrace();
+ } catch (IllegalAccessException e) {
+ e.printStackTrace();
+ } catch (InvocationTargetException e) {
+ e.printStackTrace();
+ } catch (ClassNotFoundException e) {
+ e.printStackTrace();
+ }
+ if (binder != null) {
+ sWeatherManagerService = ILineageWeatherManager.Stub.asInterface(binder);
+ return sWeatherManagerService;
+ }
+
+ return null;
+ }
+
+ /**
+ * Forces the weather service to request the latest available weather information for
+ * the supplied {@link android.location.Location} location.
+ *
+ * @param location The location you want to get the latest weather data from.
+ * @param listener {@link WeatherUpdateRequestListener} To be notified once the active weather
+ * service provider has finished
+ * processing your request
+ * @return An integer that identifies the request submitted to the weather service
+ * Note that this method might return -1 if an error occurred while trying to submit
+ * the request.
+ */
+ public int requestWeatherUpdate(@NonNull Location location,
+ @NonNull WeatherUpdateRequestListener listener) {
+ if (sWeatherManagerService == null) {
+ return -1;
+ }
+
+ try {
+ int tempUnit = WeatherContract.WeatherColumns.TempUnit.CELSIUS;
+
+ RequestInfo info = new RequestInfo
+ .Builder(mRequestInfoListener)
+ .setLocation(location)
+ .setTemperatureUnit(tempUnit)
+ .build();
+ if (listener != null) mWeatherUpdateRequestListeners.put(info, listener);
+ sWeatherManagerService.updateWeather(info);
+ return info.hashCode();
+ } catch (RemoteException e) {
+ return -1;
+ }
+ }
+
+ /**
+ * Forces the weather service to request the latest weather information for the provided
+ * WeatherLocation. This is the preferred method for requesting a weather update.
+ *
+ * @param weatherLocation A {@link lineageos.weather.WeatherLocation} that was previously
+ * obtained by calling
+ * {@link #lookupCity(String, LookupCityRequestListener)}
+ * @param listener {@link WeatherUpdateRequestListener} To be notified once the active weather
+ * service provider has finished
+ * processing your request
+ * @return An integer that identifies the request submitted to the weather service.
+ * Note that this method might return -1 if an error occurred while trying to submit
+ * the request.
+ */
+ public int requestWeatherUpdate(@NonNull WeatherLocation weatherLocation,
+ @NonNull WeatherUpdateRequestListener listener) {
+ if (sWeatherManagerService == null) {
+ return -1;
+ }
+
+ try {
+ int tempUnit = WeatherContract.WeatherColumns.TempUnit.CELSIUS;
+
+ RequestInfo info = new RequestInfo
+ .Builder(mRequestInfoListener)
+ .setWeatherLocation(weatherLocation)
+ .setTemperatureUnit(tempUnit)
+ .build();
+ if (listener != null) mWeatherUpdateRequestListeners.put(info, listener);
+ sWeatherManagerService.updateWeather(info);
+ return info.hashCode();
+ } catch (RemoteException e) {
+ return -1;
+ }
+ }
+
+ /**
+ * Request the active weather provider service to lookup the supplied city name.
+ *
+ * @param city The city name
+ * @param listener {@link LookupCityRequestListener} To be notified once the request has been
+ * completed. Upon success, a list of
+ * {@link lineageos.weather.WeatherLocation}
+ * will be provided
+ * @return An integer that identifies the request submitted to the weather service.
+ * Note that this method might return -1 if an error occurred while trying to submit
+ * the request.
+ */
+ public int lookupCity(@NonNull String city, @NonNull LookupCityRequestListener listener) {
+ if (sWeatherManagerService == null) {
+ return -1;
+ }
+ try {
+ RequestInfo info = new RequestInfo
+ .Builder(mRequestInfoListener)
+ .setCityName(city)
+ .build();
+ if (listener != null) mLookupNameRequestListeners.put(info, listener);
+ sWeatherManagerService.lookupCity(info);
+ return info.hashCode();
+ } catch (RemoteException e) {
+ return -1;
+ }
+ }
+
+ /**
+ * Cancels a request that was previously submitted to the weather service.
+ * @param requestId The ID that you received when the request was submitted
+ */
+ public void cancelRequest(int requestId) {
+ if (sWeatherManagerService == null) {
+ return;
+ }
+
+ try {
+ sWeatherManagerService.cancelRequest(requestId);
+ }catch (RemoteException e){
+ }
+ }
+
+ /**
+ * Registers a {@link WeatherServiceProviderChangeListener} to be notified when a new weather
+ * service provider becomes active.
+ * @param listener {@link WeatherServiceProviderChangeListener} to register
+ */
+ public void registerWeatherServiceProviderChangeListener(
+ @NonNull WeatherServiceProviderChangeListener listener) {
+ if (sWeatherManagerService == null) return;
+
+ synchronized (mProviderChangedListeners) {
+ if (mProviderChangedListeners.contains(listener)) {
+ throw new IllegalArgumentException("Listener already registered");
+ }
+ if (mProviderChangedListeners.size() == 0) {
+ try {
+ sWeatherManagerService.registerWeatherServiceProviderChangeListener(
+ mProviderChangeListener);
+ } catch (RemoteException e){
+ }
+ }
+ mProviderChangedListeners.add(listener);
+ }
+ }
+
+ /**
+ * Unregisters a listener
+ * @param listener A previously registered {@link WeatherServiceProviderChangeListener}
+ */
+ public void unregisterWeatherServiceProviderChangeListener(
+ @NonNull WeatherServiceProviderChangeListener listener) {
+ if (sWeatherManagerService == null) return;
+
+ synchronized (mProviderChangedListeners) {
+ if (!mProviderChangedListeners.contains(listener)) {
+ throw new IllegalArgumentException("Listener was never registered");
+ }
+ mProviderChangedListeners.remove(listener);
+ if (mProviderChangedListeners.size() == 0) {
+ try {
+ sWeatherManagerService.unregisterWeatherServiceProviderChangeListener(
+ mProviderChangeListener);
+ } catch(RemoteException e){
+ }
+ }
+ }
+ }
+
+ /**
+ * Gets the service's label as declared by the active weather service provider in its manifest
+ * @return the service's label
+ */
+ public String getActiveWeatherServiceProviderLabel() {
+ if (sWeatherManagerService == null) return null;
+
+ try {
+ return sWeatherManagerService.getActiveWeatherServiceProviderLabel();
+ } catch(RemoteException e){
+ }
+ return null;
+ }
+
+ private final IWeatherServiceProviderChangeListener mProviderChangeListener =
+ new IWeatherServiceProviderChangeListener.Stub() {
+ @Override
+ public void onWeatherServiceProviderChanged(final String providerName) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ synchronized (mProviderChangedListeners) {
+ List<WeatherServiceProviderChangeListener> deadListeners
+ = new ArrayList<>();
+ for (WeatherServiceProviderChangeListener listener
+ : mProviderChangedListeners) {
+ try {
+ listener.onWeatherServiceProviderChanged(providerName);
+ } catch (Throwable e) {
+ deadListeners.add(listener);
+ }
+ }
+ if (deadListeners.size() > 0) {
+ for (WeatherServiceProviderChangeListener listener : deadListeners) {
+ mProviderChangedListeners.remove(listener);
+ }
+ }
+ }
+ }
+ });
+ }
+ };
+
+ private final IRequestInfoListener mRequestInfoListener = new IRequestInfoListener.Stub() {
+
+ @Override
+ public void onWeatherRequestCompleted(final RequestInfo requestInfo, final int status,
+ final WeatherInfo weatherInfo) {
+ final WeatherUpdateRequestListener listener
+ = mWeatherUpdateRequestListeners.remove(requestInfo);
+ if (listener != null) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ listener.onWeatherRequestCompleted(status, weatherInfo);
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onLookupCityRequestCompleted(RequestInfo requestInfo, final int status,
+ final List<WeatherLocation> weatherLocations) {
+
+ final LookupCityRequestListener listener
+ = mLookupNameRequestListeners.remove(requestInfo);
+ if (listener != null) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ listener.onLookupCityRequestCompleted(status, weatherLocations);
+ }
+ });
+ }
+ }
+ };
+
+ /**
+ * Interface used to receive notifications upon completion of a weather update request
+ */
+ public interface WeatherUpdateRequestListener {
+ /**
+ * This method will be called when the weather service provider has finished processing the
+ * request
+ *
+ * @param status See {@link RequestStatus}
+ *
+ * @param weatherInfo A fully populated {@link WeatherInfo} if state is
+ * {@link RequestStatus#COMPLETED}, null otherwise
+ */
+ void onWeatherRequestCompleted(int status, WeatherInfo weatherInfo);
+ }
+
+ /**
+ * Interface used to receive notifications upon completion of a request to lookup a city name
+ */
+ public interface LookupCityRequestListener {
+ /**
+ * This method will be called when the weather service provider has finished processing the
+ * request.
+ *
+ * @param status See {@link RequestStatus}
+ *
+ * @param locations A list of {@link WeatherLocation} if the status is
+ * {@link RequestStatus#COMPLETED}, null otherwise
+ */
+ void onLookupCityRequestCompleted(int status, List<WeatherLocation> locations);
+ }
+
+ /**
+ * Interface used to be notified when the user changes the weather service provider
+ */
+ public interface WeatherServiceProviderChangeListener {
+ /**
+ * This method will be called when a new weather service provider becomes active in the
+ * system. The parameter can be null when
+ * <p>The user removed the active weather service provider from the system </p>
+ * <p>The active weather provider was disabled.</p>
+ *
+ * @param providerLabel The label as declared on the weather service provider manifest
+ */
+ void onWeatherServiceProviderChanged(String providerLabel);
+ }
+}
diff --git a/app/src/main/java/lineageos/weather/RequestInfo.java b/app/src/main/java/lineageos/weather/RequestInfo.java
new file mode 100644
index 0000000..a236bef
--- /dev/null
+++ b/app/src/main/java/lineageos/weather/RequestInfo.java
@@ -0,0 +1,379 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weather;
+
+import android.location.Location;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import android.text.TextUtils;
+
+import lineageos.os.Build;
+import lineageos.os.Concierge;
+import lineageos.os.Concierge.ParcelInfo;
+import lineageos.providers.WeatherContract;
+
+import java.util.UUID;
+
+/**
+ * This class holds the information of a request submitted to the active weather provider service
+ */
+public final class RequestInfo implements Parcelable {
+
+ private Location mLocation;
+ private String mCityName;
+ private WeatherLocation mWeatherLocation;
+ private int mRequestType;
+ private IRequestInfoListener mListener;
+ private int mTempUnit;
+ private String mKey;
+ private boolean mIsQueryOnly;
+
+ /**
+ * A request to update the weather data using a geographical {@link android.location.Location}
+ */
+ public static final int TYPE_WEATHER_BY_GEO_LOCATION_REQ = 1;
+ /**
+ * A request to update the weather data using a {@link WeatherLocation}
+ */
+ public static final int TYPE_WEATHER_BY_WEATHER_LOCATION_REQ = 2;
+
+ /**
+ * A request to look up a city name
+ */
+ public static final int TYPE_LOOKUP_CITY_NAME_REQ = 3;
+
+ private RequestInfo() {}
+
+ /* package */ static class Builder {
+ private Location mLocation;
+ private String mCityName;
+ private WeatherLocation mWeatherLocation;
+ private int mRequestType;
+ private IRequestInfoListener mListener;
+ private int mTempUnit = WeatherContract.WeatherColumns.TempUnit.FAHRENHEIT;
+ private boolean mIsQueryOnly = false;
+
+ public Builder(IRequestInfoListener listener) {
+ this.mListener = listener;
+ }
+
+ /**
+ * Sets the city name and identifies this request as a {@link #TYPE_LOOKUP_CITY_NAME_REQ}
+ * request. If set, will null out the location and weather location. Attempting to set
+ * a null city name will get you an IllegalArgumentException
+ */
+ public Builder setCityName(String cityName) {
+ if (cityName == null) {
+ throw new IllegalArgumentException("City name can't be null");
+ }
+ this.mCityName = cityName;
+ this.mRequestType = TYPE_LOOKUP_CITY_NAME_REQ;
+ this.mLocation = null;
+ this.mWeatherLocation = null;
+ return this;
+ }
+
+ /**
+ * Sets the Location and identifies this request as a
+ * {@link #TYPE_WEATHER_BY_GEO_LOCATION_REQ}. If set, will null out the city name and
+ * weather location. Attempting to set a null location will get you an
+ * IllegalArgumentException
+ */
+ public Builder setLocation(Location location) {
+ if (location == null) {
+ throw new IllegalArgumentException("Location can't be null");
+ }
+ this.mLocation = new Location(location);
+ this.mCityName = null;
+ this.mWeatherLocation = null;
+ this.mRequestType = TYPE_WEATHER_BY_GEO_LOCATION_REQ;
+ return this;
+ }
+
+ /**
+ * Sets the weather location and identifies this request as a
+ * {@link #TYPE_WEATHER_BY_WEATHER_LOCATION_REQ}. If set, will null out the location and
+ * city name. Attempting to set a null weather location will get you an
+ * IllegalArgumentException
+ */
+ public Builder setWeatherLocation(WeatherLocation weatherLocation) {
+ if (weatherLocation == null) {
+ throw new IllegalArgumentException("WeatherLocation can't be null");
+ }
+ this.mWeatherLocation = weatherLocation;
+ this.mLocation = null;
+ this.mCityName = null;
+ this.mRequestType = TYPE_WEATHER_BY_WEATHER_LOCATION_REQ;
+ return this;
+ }
+
+ /**
+ * Sets the unit in which the temperature will be reported if the request is honored.
+ * Valid values are:
+ * <ul>
+ * {@link lineageos.providers.WeatherContract.WeatherColumns.TempUnit#CELSIUS}
+ * {@link lineageos.providers.WeatherContract.WeatherColumns.TempUnit#FAHRENHEIT}
+ * </ul>
+ * Any other value will generate an IllegalArgumentException. If the temperature unit is not
+ * set, the default will be degrees Fahrenheit
+ * @param unit A valid temperature unit
+ */
+ public Builder setTemperatureUnit(int unit) {
+ if (!isValidTempUnit(unit)) {
+ throw new IllegalArgumentException("Invalid temperature unit");
+ }
+ this.mTempUnit = unit;
+ return this;
+ }
+
+ /**
+ * If this is a weather request, marks the request as a query only, meaning that the
+ * content provider won't be updated after the active weather service has finished
+ * processing the request.
+ */
+ public Builder queryOnly() {
+ switch (mRequestType) {
+ case TYPE_WEATHER_BY_GEO_LOCATION_REQ:
+ case TYPE_WEATHER_BY_WEATHER_LOCATION_REQ:
+ this.mIsQueryOnly = true;
+ break;
+ default:
+ this.mIsQueryOnly = false;
+ break;
+ }
+ return this;
+ }
+
+ /**
+ * Combine all of the options that have been set and return a new {@link RequestInfo} object
+ * @return {@link RequestInfo}
+ */
+ public RequestInfo build() {
+ RequestInfo info = new RequestInfo();
+ info.mListener = this.mListener;
+ info.mRequestType = this.mRequestType;
+ info.mCityName = this.mCityName;
+ info.mWeatherLocation = this.mWeatherLocation;
+ info.mLocation = this.mLocation;
+ info.mTempUnit = this.mTempUnit;
+ info.mIsQueryOnly = this.mIsQueryOnly;
+ info.mKey = UUID.randomUUID().toString();
+ return info;
+ }
+
+ private boolean isValidTempUnit(int unit) {
+ switch (unit) {
+ case WeatherContract.WeatherColumns.TempUnit.CELSIUS:
+ case WeatherContract.WeatherColumns.TempUnit.FAHRENHEIT:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ }
+
+ private RequestInfo(Parcel parcel) {
+ // Read parcelable version via the Concierge
+ ParcelInfo parcelInfo = Concierge.receiveParcel(parcel);
+ int parcelableVersion = parcelInfo.getParcelVersion();
+
+ if (parcelableVersion >= Build.LINEAGE_VERSION_CODES.ELDERBERRY) {
+ mKey = parcel.readString();
+ mRequestType = parcel.readInt();
+ switch (mRequestType) {
+ case TYPE_WEATHER_BY_GEO_LOCATION_REQ:
+ mLocation = Location.CREATOR.createFromParcel(parcel);
+ mTempUnit = parcel.readInt();
+ break;
+ case TYPE_WEATHER_BY_WEATHER_LOCATION_REQ:
+ mWeatherLocation = WeatherLocation.CREATOR.createFromParcel(parcel);
+ mTempUnit = parcel.readInt();
+ break;
+ case TYPE_LOOKUP_CITY_NAME_REQ:
+ mCityName = parcel.readString();
+ break;
+ }
+ mIsQueryOnly = (parcel.readInt() == 1);
+ mListener = IRequestInfoListener.Stub.asInterface(parcel.readStrongBinder());
+ }
+
+ // Complete parcel info for the concierge
+ parcelInfo.complete();
+ }
+
+
+ /**
+ * @return The request type
+ */
+ public int getRequestType() {
+ return mRequestType;
+ }
+
+ /**
+ * @return the {@link android.location.Location} if this is a request by location, null
+ * otherwise
+ */
+ public Location getLocation() {
+ return new Location(mLocation);
+ }
+
+ /**
+ * @return the {@link lineageos.weather.WeatherLocation} if this is a request by weather
+ * location, null otherwise
+ */
+ public WeatherLocation getWeatherLocation() {
+ return mWeatherLocation;
+ }
+
+ /**
+ * @hide
+ */
+ public IRequestInfoListener getRequestListener() {
+ return mListener;
+ }
+
+ /**
+ * @return the city name if this is a lookup request, null otherwise
+ */
+ public String getCityName() {
+ return mCityName;
+ }
+
+ /**
+ * @return the temperature unit if this is a weather request, -1 otherwise
+ */
+ public int getTemperatureUnit() {
+ switch (mRequestType) {
+ case TYPE_WEATHER_BY_GEO_LOCATION_REQ:
+ case TYPE_WEATHER_BY_WEATHER_LOCATION_REQ:
+ return mTempUnit;
+ default:
+ return -1;
+ }
+ }
+
+ /**
+ * @return if this is a weather request, whether the request will update the content provider.
+ * False for other kind of requests
+ * @hide
+ */
+ public boolean isQueryOnlyWeatherRequest() {
+ switch (mRequestType) {
+ case TYPE_WEATHER_BY_GEO_LOCATION_REQ:
+ case TYPE_WEATHER_BY_WEATHER_LOCATION_REQ:
+ return mIsQueryOnly;
+ default:
+ return false;
+ }
+ }
+
+ public static final Creator<RequestInfo> CREATOR = new Creator<RequestInfo>() {
+ @Override
+ public RequestInfo createFromParcel(Parcel in) {
+ return new RequestInfo(in);
+ }
+
+ @Override
+ public RequestInfo[] newArray(int size) {
+ return new RequestInfo[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ // Tell the concierge to prepare the parcel
+ ParcelInfo parcelInfo = Concierge.prepareParcel(dest);
+
+ // ==== ELDERBERRY =====
+ dest.writeString(mKey);
+ dest.writeInt(mRequestType);
+ switch (mRequestType) {
+ case TYPE_WEATHER_BY_GEO_LOCATION_REQ:
+ mLocation.writeToParcel(dest, 0);
+ dest.writeInt(mTempUnit);
+ break;
+ case TYPE_WEATHER_BY_WEATHER_LOCATION_REQ:
+ mWeatherLocation.writeToParcel(dest, 0);
+ dest.writeInt(mTempUnit);
+ break;
+ case TYPE_LOOKUP_CITY_NAME_REQ:
+ dest.writeString(mCityName);
+ break;
+ }
+ dest.writeInt(mIsQueryOnly == true ? 1 : 0);
+ dest.writeStrongBinder(mListener.asBinder());
+
+ // Complete parcel info for the concierge
+ parcelInfo.complete();
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("{ Request for ");
+ switch (mRequestType) {
+ case TYPE_WEATHER_BY_GEO_LOCATION_REQ:
+ builder.append("Location: ").append(mLocation);
+ builder.append(" Temp Unit: ");
+ if (mTempUnit == WeatherContract.WeatherColumns.TempUnit.FAHRENHEIT) {
+ builder.append("Fahrenheit");
+ } else {
+ builder.append(" Celsius");
+ }
+ break;
+ case TYPE_WEATHER_BY_WEATHER_LOCATION_REQ:
+ builder.append("WeatherLocation: ").append(mWeatherLocation);
+ builder.append(" Temp Unit: ");
+ if (mTempUnit == WeatherContract.WeatherColumns.TempUnit.FAHRENHEIT) {
+ builder.append("Fahrenheit");
+ } else {
+ builder.append(" Celsius");
+ }
+ break;
+ case TYPE_LOOKUP_CITY_NAME_REQ:
+ builder.append("Lookup City: ").append(mCityName);
+ break;
+ }
+ return builder.append(" }").toString();
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((mKey != null) ? mKey.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) return false;
+
+ if (getClass() == obj.getClass()) {
+ RequestInfo info = (RequestInfo) obj;
+ return (TextUtils.equals(mKey, info.mKey));
+ }
+ return false;
+ }
+}
diff --git a/app/src/main/java/lineageos/weather/WeatherInfo.java b/app/src/main/java/lineageos/weather/WeatherInfo.java
new file mode 100644
index 0000000..91d8b34
--- /dev/null
+++ b/app/src/main/java/lineageos/weather/WeatherInfo.java
@@ -0,0 +1,642 @@
+/*
+ * Copyright (C) 2016 The CyanongenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weather;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+
+import lineageos.os.Build;
+import lineageos.os.Concierge;
+import lineageos.os.Concierge.ParcelInfo;
+import lineageos.providers.WeatherContract;
+import lineageos.weatherservice.ServiceRequest;
+import lineageos.weatherservice.ServiceRequestResult;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * This class represents the weather information that a
+ * {@link lineageos.weatherservice.WeatherProviderService} will use to update the weather content
+ * provider. A weather provider service will be called by the system to process an update
+ * request at any time. If the service successfully processes the request, then the weather provider
+ * service is responsible of calling
+ * {@link ServiceRequest#complete(ServiceRequestResult)} to notify the
+ * system that the request was completed and that the weather content provider should be updated
+ * with the supplied weather information.
+ */
+public final class WeatherInfo implements Parcelable {
+
+ private String mCity;
+ private int mConditionCode;
+ private double mTemperature;
+ private int mTempUnit;
+ private double mTodaysHighTemp;
+ private double mTodaysLowTemp;
+ private double mHumidity;
+ private double mWindSpeed;
+ private double mWindDirection;
+ private int mWindSpeedUnit;
+ private long mTimestamp;
+ private List<DayForecast> mForecastList;
+ private String mKey;
+
+ private WeatherInfo() {}
+
+ /**
+ * Builder class for {@link WeatherInfo}
+ */
+ public static class Builder {
+ private String mCity;
+ private int mConditionCode = WeatherContract.WeatherColumns.WeatherCode.NOT_AVAILABLE;
+ private double mTemperature;
+ private int mTempUnit;
+ private double mTodaysHighTemp = Double.NaN;
+ private double mTodaysLowTemp = Double.NaN;
+ private double mHumidity = Double.NaN;
+ private double mWindSpeed = Double.NaN;
+ private double mWindDirection = Double.NaN;
+ private int mWindSpeedUnit = WeatherContract.WeatherColumns.WindSpeedUnit.MPH;
+ private long mTimestamp = -1;
+ private List<DayForecast> mForecastList = new ArrayList<>(0);
+
+ /**
+ * @param cityName A valid city name. Attempting to pass null will get you an
+ * IllegalArgumentException
+ * @param temperature A valid temperature value. Attempting pass an invalid double value,
+ * will get you an IllegalArgumentException
+ * @param tempUnit A valid temperature unit value. See
+ * {@link lineageos.providers.WeatherContract.WeatherColumns.TempUnit} for
+ * valid values. Attempting to pass an invalid temperature unit will get you
+ * an IllegalArgumentException
+ */
+ public Builder(@NonNull String cityName, double temperature, int tempUnit) {
+ if (cityName == null) {
+ throw new IllegalArgumentException("City name can't be null");
+ }
+ if (Double.isNaN(temperature)) {
+ throw new IllegalArgumentException("Invalid temperature");
+ }
+ if (!isValidTempUnit(tempUnit)) {
+ throw new IllegalArgumentException("Invalid temperature unit");
+ }
+ this.mCity = cityName;
+ this.mTemperature = temperature;
+ this.mTempUnit = tempUnit;
+ }
+
+ /**
+ * @param timeStamp A timestamp indicating when this data was generated. If timestamps is
+ * not set, then the builder will set it to the time of object creation
+ * @return The {@link Builder} instance
+ */
+ public Builder setTimestamp(long timeStamp) {
+ mTimestamp = timeStamp;
+ return this;
+ }
+
+ /**
+ * @param humidity The weather humidity. Attempting to pass an invalid double value will get
+ * you an IllegalArgumentException
+ * @return The {@link Builder} instance
+ */
+ public Builder setHumidity(double humidity) {
+ if (Double.isNaN(humidity)) {
+ throw new IllegalArgumentException("Invalid humidity value");
+ }
+
+ mHumidity = humidity;
+ return this;
+ }
+
+ /**
+ * @param windSpeed The wind speed. Attempting to pass an invalid double value will get you
+ * an IllegalArgumentException
+ * @param windDirection The wind direction. Attempting to pass an invalid double value will
+ * get you an IllegalArgumentException
+ * @param windSpeedUnit A valid wind speed direction unit. See
+ * {@link lineageos.providers.WeatherContract.WeatherColumns.WindSpeedUnit}
+ * for valid values. Attempting to pass an invalid speed unit will get
+ * you an IllegalArgumentException
+ * @return The {@link Builder} instance
+ */
+ public Builder setWind(double windSpeed, double windDirection, int windSpeedUnit) {
+ if (Double.isNaN(windSpeed)) {
+ throw new IllegalArgumentException("Invalid wind speed value");
+ }
+ if (Double.isNaN(windDirection)) {
+ throw new IllegalArgumentException("Invalid wind direction value");
+ }
+ if (!isValidWindSpeedUnit(windSpeedUnit)) {
+ throw new IllegalArgumentException("Invalid speed unit");
+ }
+ mWindSpeed = windSpeed;
+ mWindSpeedUnit = windSpeedUnit;
+ mWindDirection = windDirection;
+ return this;
+ }
+
+ /**
+ * @param conditionCode A valid weather condition code. See
+ * {@link lineageos.providers.WeatherContract.WeatherColumns.WeatherCode}
+ * for valid codes. Attempting to pass an invalid code will get you an
+ * IllegalArgumentException.
+ * @return The {@link Builder} instance
+ */
+ public Builder setWeatherCondition(int conditionCode) {
+ if (!isValidWeatherCode(conditionCode)) {
+ throw new IllegalArgumentException("Invalid weather condition code");
+ }
+ mConditionCode = conditionCode;
+ return this;
+ }
+
+ /**
+ * @param forecasts A valid array list of {@link DayForecast} objects. Attempting to pass
+ * null will get you an IllegalArgumentException'
+ * @return The {@link Builder} instance
+ */
+ public Builder setForecast(@NonNull List<DayForecast> forecasts) {
+ if (forecasts == null) {
+ throw new IllegalArgumentException("Forecast list can't be null");
+ }
+ mForecastList = forecasts;
+ return this;
+ }
+
+ /**
+ *
+ * @param todaysHigh Today's high temperature. Attempting to pass an invalid double value
+ * will get you an IllegalArgumentException
+ * @return The {@link Builder} instance
+ */
+ public Builder setTodaysHigh(double todaysHigh) {
+ if (Double.isNaN(todaysHigh)) {
+ throw new IllegalArgumentException("Invalid temperature value");
+ }
+ mTodaysHighTemp = todaysHigh;
+ return this;
+ }
+
+ /**
+ * @param todaysLow Today's low temperature. Attempting to pass an invalid double value will
+ * get you an IllegalArgumentException
+ * @return
+ */
+ public Builder setTodaysLow(double todaysLow) {
+ if (Double.isNaN(todaysLow)) {
+ throw new IllegalArgumentException("Invalid temperature value");
+ }
+ mTodaysLowTemp = todaysLow;
+ return this;
+ }
+
+ /**
+ * Combine all of the options that have been set and return a new {@link WeatherInfo} object
+ * @return {@link WeatherInfo}
+ */
+ public WeatherInfo build() {
+ WeatherInfo info = new WeatherInfo();
+ info.mCity = this.mCity;
+ info.mConditionCode = this.mConditionCode;
+ info.mTemperature = this.mTemperature;
+ info.mTempUnit = this.mTempUnit;
+ info.mHumidity = this.mHumidity;
+ info.mWindSpeed = this.mWindSpeed;
+ info.mWindDirection = this.mWindDirection;
+ info.mWindSpeedUnit = this.mWindSpeedUnit;
+ info.mTimestamp = this.mTimestamp == -1 ? System.currentTimeMillis() : this.mTimestamp;
+ info.mForecastList = this.mForecastList;
+ info.mTodaysHighTemp = this.mTodaysHighTemp;
+ info.mTodaysLowTemp = this.mTodaysLowTemp;
+ info.mKey = UUID.randomUUID().toString();
+ return info;
+ }
+
+ private boolean isValidTempUnit(int unit) {
+ switch (unit) {
+ case WeatherContract.WeatherColumns.TempUnit.CELSIUS:
+ case WeatherContract.WeatherColumns.TempUnit.FAHRENHEIT:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private boolean isValidWindSpeedUnit(int unit) {
+ switch (unit) {
+ case WeatherContract.WeatherColumns.WindSpeedUnit.KPH:
+ case WeatherContract.WeatherColumns.WindSpeedUnit.MPH:
+ return true;
+ default:
+ return false;
+ }
+ }
+ }
+
+
+ private static boolean isValidWeatherCode(int code) {
+ if (code < WeatherContract.WeatherColumns.WeatherCode.WEATHER_CODE_MIN
+ || code > WeatherContract.WeatherColumns.WeatherCode.WEATHER_CODE_MAX) {
+ if (code != WeatherContract.WeatherColumns.WeatherCode.NOT_AVAILABLE) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * @return city name
+ */
+ public String getCity() {
+ return mCity;
+ }
+
+ /**
+ * @return An implementation specific weather condition code
+ */
+ public int getConditionCode() {
+ return mConditionCode;
+ }
+
+ /**
+ * @return humidity
+ */
+ public double getHumidity() {
+ return mHumidity;
+ }
+
+ /**
+ * @return time stamp when the request was processed
+ */
+ public long getTimestamp() {
+ return mTimestamp;
+ }
+
+ /**
+ * @return wind direction (degrees)
+ */
+ public double getWindDirection() {
+ return mWindDirection;
+ }
+
+ /**
+ * @return wind speed
+ */
+ public double getWindSpeed() {
+ return mWindSpeed;
+ }
+
+ /**
+ * @return wind speed unit
+ */
+ public int getWindSpeedUnit() {
+ return mWindSpeedUnit;
+ }
+
+ /**
+ * @return current temperature
+ */
+ public double getTemperature() {
+ return mTemperature;
+ }
+
+ /**
+ * @return temperature unit
+ */
+ public int getTemperatureUnit() {
+ return mTempUnit;
+ }
+
+ /**
+ * @return today's high temperature
+ */
+ public double getTodaysHigh() {
+ return mTodaysHighTemp;
+ }
+
+ /**
+ * @return today's low temperature
+ */
+ public double getTodaysLow() {
+ return mTodaysLowTemp;
+ }
+
+ /**
+ * @return List of {@link lineageos.weather.WeatherInfo.DayForecast}. This list will contain
+ * the forecast weather for the upcoming days. If you want to know today's high and low
+ * temperatures, use {@link WeatherInfo#getTodaysHigh()} and {@link WeatherInfo#getTodaysLow()}
+ */
+ public List<DayForecast> getForecasts() {
+ return new ArrayList<>(mForecastList);
+ }
+
+ private WeatherInfo(Parcel parcel) {
+ // Read parcelable version via the Concierge
+ ParcelInfo parcelInfo = Concierge.receiveParcel(parcel);
+ int parcelableVersion = parcelInfo.getParcelVersion();
+
+ if (parcelableVersion >= Build.LINEAGE_VERSION_CODES.ELDERBERRY) {
+ mKey = parcel.readString();
+ mCity = parcel.readString();
+ mConditionCode = parcel.readInt();
+ mTemperature = parcel.readDouble();
+ mTempUnit = parcel.readInt();
+ mHumidity = parcel.readDouble();
+ mWindSpeed = parcel.readDouble();
+ mWindDirection = parcel.readDouble();
+ mWindSpeedUnit = parcel.readInt();
+ mTodaysHighTemp = parcel.readDouble();
+ mTodaysLowTemp = parcel.readDouble();
+ mTimestamp = parcel.readLong();
+ int forecastListSize = parcel.readInt();
+ mForecastList = new ArrayList<>();
+ while (forecastListSize > 0) {
+ mForecastList.add(DayForecast.CREATOR.createFromParcel(parcel));
+ forecastListSize--;
+ }
+ }
+
+ // Complete parcel info for the concierge
+ parcelInfo.complete();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ // Tell the concierge to prepare the parcel
+ ParcelInfo parcelInfo = Concierge.prepareParcel(dest);
+
+ // ==== ELDERBERRY =====
+ dest.writeString(mKey);
+ dest.writeString(mCity);
+ dest.writeInt(mConditionCode);
+ dest.writeDouble(mTemperature);
+ dest.writeInt(mTempUnit);
+ dest.writeDouble(mHumidity);
+ dest.writeDouble(mWindSpeed);
+ dest.writeDouble(mWindDirection);
+ dest.writeInt(mWindSpeedUnit);
+ dest.writeDouble(mTodaysHighTemp);
+ dest.writeDouble(mTodaysLowTemp);
+ dest.writeLong(mTimestamp);
+ dest.writeInt(mForecastList.size());
+ for (DayForecast dayForecast : mForecastList) {
+ dayForecast.writeToParcel(dest, 0);
+ }
+
+ // Complete parcel info for the concierge
+ parcelInfo.complete();
+ }
+
+ public static final Parcelable.Creator<WeatherInfo> CREATOR =
+ new Parcelable.Creator<WeatherInfo>() {
+
+ @Override
+ public WeatherInfo createFromParcel(Parcel source) {
+ return new WeatherInfo(source);
+ }
+
+ @Override
+ public WeatherInfo[] newArray(int size) {
+ return new WeatherInfo[size];
+ }
+ };
+
+ /**
+ * This class represents the weather forecast for a given day. Do not add low and high
+ * temperatures for the current day in this list. Use
+ * {@link WeatherInfo.Builder#setTodaysHigh(double)} and
+ * {@link WeatherInfo.Builder#setTodaysLow(double)} instead.
+ */
+ public static class DayForecast implements Parcelable{
+ double mLow;
+ double mHigh;
+ int mConditionCode;
+ String mKey;
+
+ private DayForecast() {}
+
+ /**
+ * Builder class for {@link DayForecast}
+ */
+ public static class Builder {
+ double mLow = Double.NaN;
+ double mHigh = Double.NaN;
+ int mConditionCode;
+
+ /**
+ * @param conditionCode A valid weather condition code. See
+ * {@link lineageos.providers.WeatherContract.WeatherColumns.WeatherCode} for valid
+ * values. Attempting to pass an invalid code will get you an
+ * IllegalArgumentException
+ */
+ public Builder(int conditionCode) {
+ if (!isValidWeatherCode(conditionCode)) {
+ throw new IllegalArgumentException("Invalid weather condition code");
+ }
+ mConditionCode = conditionCode;
+ }
+
+ /**
+ * @param high Forecast high temperature for this day. Attempting to pass an invalid
+ * double value will get you an IllegalArgumentException
+ * @return The {@link Builder} instance
+ */
+ public Builder setHigh(double high) {
+ if (Double.isNaN(high)) {
+ throw new IllegalArgumentException("Invalid high forecast temperature");
+ }
+ mHigh = high;
+ return this;
+ }
+
+ /**
+ * @param low Forecast low temperate for this day. Attempting to pass an invalid double
+ * value will get you an IllegalArgumentException
+ * @return The {@link Builder} instance
+ */
+ public Builder setLow(double low) {
+ if (Double.isNaN(low)) {
+ throw new IllegalArgumentException("Invalid low forecast temperature");
+ }
+ mLow = low;
+ return this;
+ }
+
+
+ /**
+ * Combine all of the options that have been set and return a new {@link DayForecast}
+ * object
+ * @return {@link DayForecast}
+ */
+ public DayForecast build() {
+ DayForecast forecast = new DayForecast();
+ forecast.mLow = this.mLow;
+ forecast.mHigh = this.mHigh;
+ forecast.mConditionCode = this.mConditionCode;
+ forecast.mKey = UUID.randomUUID().toString();
+ return forecast;
+ }
+ }
+
+ /**
+ * @return forecasted low temperature
+ */
+ public double getLow() {
+ return mLow;
+ }
+
+ /**
+ * @return not what you think. Returns the forecasted high temperature
+ */
+ public double getHigh() {
+ return mHigh;
+ }
+
+ /**
+ * @return forecasted weather condition code. Implementation specific
+ */
+ public int getConditionCode() {
+ return mConditionCode;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ // Tell the concierge to prepare the parcel
+ ParcelInfo parcelInfo = Concierge.prepareParcel(dest);
+
+ // ==== ELDERBERRY =====
+ dest.writeString(mKey);
+ dest.writeDouble(mLow);
+ dest.writeDouble(mHigh);
+ dest.writeInt(mConditionCode);
+
+ // Complete parcel info for the concierge
+ parcelInfo.complete();
+ }
+
+ public static final Parcelable.Creator<DayForecast> CREATOR =
+ new Parcelable.Creator<DayForecast>() {
+ @Override
+ public DayForecast createFromParcel(Parcel source) {
+ return new DayForecast(source);
+ }
+
+ @Override
+ public DayForecast[] newArray(int size) {
+ return new DayForecast[size];
+ }
+ };
+
+ private DayForecast(Parcel parcel) {
+ // Read parcelable version via the Concierge
+ ParcelInfo parcelInfo = Concierge.receiveParcel(parcel);
+ int parcelableVersion = parcelInfo.getParcelVersion();
+
+ if (parcelableVersion >= Build.LINEAGE_VERSION_CODES.ELDERBERRY) {
+ mKey = parcel.readString();
+ mLow = parcel.readDouble();
+ mHigh = parcel.readDouble();
+ mConditionCode = parcel.readInt();
+ }
+
+ // Complete parcel info for the concierge
+ parcelInfo.complete();
+ }
+
+ @Override
+ public String toString() {
+ return new StringBuilder()
+ .append("{Low temp: ").append(mLow)
+ .append(" High temp: ").append(mHigh)
+ .append(" Condition code: ").append(mConditionCode)
+ .append("}").toString();
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((mKey != null) ? mKey.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) return false;
+
+ if (getClass() == obj.getClass()) {
+ DayForecast forecast = (DayForecast) obj;
+ return (TextUtils.equals(mKey, forecast.mKey));
+ }
+ return false;
+ }
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder()
+ .append(" City Name: ").append(mCity)
+ .append(" Condition Code: ").append(mConditionCode)
+ .append(" Temperature: ").append(mTemperature)
+ .append(" Temperature Unit: ").append(mTempUnit)
+ .append(" Humidity: ").append(mHumidity)
+ .append(" Wind speed: ").append(mWindSpeed)
+ .append(" Wind direction: ").append(mWindDirection)
+ .append(" Wind Speed Unit: ").append(mWindSpeedUnit)
+ .append(" Today's high temp: ").append(mTodaysHighTemp)
+ .append(" Today's low temp: ").append(mTodaysLowTemp)
+ .append(" Timestamp: ").append(mTimestamp).append(" Forecasts: [");
+ for (DayForecast dayForecast : mForecastList) {
+ builder.append(dayForecast.toString());
+ }
+ return builder.append("]}").toString();
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((mKey != null) ? mKey.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) return false;
+
+ if (getClass() == obj.getClass()) {
+ WeatherInfo info = (WeatherInfo) obj;
+ return (TextUtils.equals(mKey, info.mKey));
+ }
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/lineageos/weather/WeatherLocation.java b/app/src/main/java/lineageos/weather/WeatherLocation.java
new file mode 100644
index 0000000..21a6b07
--- /dev/null
+++ b/app/src/main/java/lineageos/weather/WeatherLocation.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weather;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import android.text.TextUtils;
+import lineageos.os.Build;
+import lineageos.os.Concierge;
+import lineageos.os.Concierge.ParcelInfo;
+
+import java.util.UUID;
+
+/**
+ * A class representing a geographical location that a weather service provider can use to
+ * get weather data from. Each service provider will potentially populate objects of this class
+ * with different content, so make sure you don't preserve the values when a service provider
+ * is changed
+ */
+public final class WeatherLocation implements Parcelable{
+ private String mCityId;
+ private String mCity;
+ private String mState;
+ private String mPostal;
+ private String mCountryId;
+ private String mCountry;
+ private String mKey;
+
+ private WeatherLocation() {}
+
+ /**
+ * Builder class for {@link WeatherLocation}
+ */
+ public static class Builder {
+ String mCityId = "";
+ String mCity = "";
+ String mState = "";
+ String mPostal = "";
+ String mCountryId = "";
+ String mCountry = "";
+
+ /**
+ * @param cityId An identifier for the city (for example WOEID - Where On Earth IDentifier)
+ * @param cityName The name of the city
+ */
+ public Builder(String cityId, String cityName) {
+ if (cityId == null || cityName == null) {
+ throw new IllegalArgumentException("Illegal to set city id AND city to null");
+ }
+ this.mCityId = cityId;
+ this.mCity = cityName;
+ }
+
+ /**
+ * @param cityName The name of the city
+ */
+ public Builder(String cityName) {
+ if (cityName == null) {
+ throw new IllegalArgumentException("City name can't be null");
+ }
+ this.mCity = cityName;
+ }
+
+ /**
+ * @param countryId An identifier for the country (for example ISO alpha-2, ISO alpha-3,
+ * ISO 3166-1 numeric-3, etc)
+ * @return The {@link Builder} instance
+ */
+ public Builder setCountryId(String countryId) {
+ if (countryId == null) {
+ throw new IllegalArgumentException("Country ID can't be null");
+ }
+ this.mCountryId = countryId;
+ return this;
+ }
+
+ /**
+ * @param country The country name
+ * @return The {@link Builder} instance
+ */
+ public Builder setCountry(String country) {
+ if (country == null) {
+ throw new IllegalArgumentException("Country can't be null");
+ }
+ this.mCountry = country;
+ return this;
+ }
+
+ /**
+ * @param postalCode The postal/ZIP code
+ * @return The {@link Builder} instance
+ */
+ public Builder setPostalCode(String postalCode) {
+ if (postalCode == null) {
+ throw new IllegalArgumentException("Postal code/ZIP can't be null");
+ }
+ this.mPostal = postalCode;
+ return this;
+ }
+
+ /**
+ * @param state The state or territory where the city is located
+ * @return The {@link Builder} instance
+ */
+ public Builder setState(String state) {
+ if (state == null) {
+ throw new IllegalArgumentException("State can't be null");
+ }
+ this.mState = state;
+ return this;
+ }
+
+ /**
+ * Combine all of the options that have been set and return a new {@link WeatherLocation}
+ * object
+ * @return {@link WeatherLocation}
+ */
+ public WeatherLocation build() {
+ WeatherLocation weatherLocation = new WeatherLocation();
+ weatherLocation.mCityId = this.mCityId;
+ weatherLocation.mCity = this.mCity;
+ weatherLocation.mState = this.mState;
+ weatherLocation.mPostal = this.mPostal;
+ weatherLocation.mCountryId = this.mCountryId;
+ weatherLocation.mCountry = this.mCountry;
+ weatherLocation.mKey = UUID.randomUUID().toString();
+ return weatherLocation;
+ }
+ }
+
+ /**
+ * @return The city ID. This method will return an empty string if the city ID was not set
+ */
+ public String getCityId() {
+ return mCityId;
+ }
+
+ /**
+ * @return The city name. This method will return an empty string if the city name was not set
+ */
+ public String getCity() {
+ return mCity;
+ }
+
+ /**
+ * @return The state name. This method will return an empty string if the state was not set
+ */
+ public String getState() {
+ return mState;
+ }
+
+ /**
+ * @return The postal/ZIP code. This method will return an empty string if the postal/ZIP code
+ * was not set
+ */
+ public String getPostalCode() {
+ return mPostal;
+ }
+
+ /**
+ * @return The country ID. This method will return an empty string if the country ID was not set
+ */
+ public String getCountryId() {
+ return mCountryId;
+ }
+
+ /**
+ * @return The country name. This method will return an empty string if the country ID was not
+ * set
+ */
+ public String getCountry() {
+ return mCountry;
+ }
+
+ private WeatherLocation(Parcel in) {
+ // Read parcelable version via the Concierge
+ ParcelInfo parcelInfo = Concierge.receiveParcel(in);
+ int parcelableVersion = parcelInfo.getParcelVersion();
+
+ if (parcelableVersion >= Build.LINEAGE_VERSION_CODES.ELDERBERRY) {
+ mKey = in.readString();
+ mCityId = in.readString();
+ mCity = in.readString();
+ mState = in.readString();
+ mPostal = in.readString();
+ mCountryId = in.readString();
+ mCountry = in.readString();
+ }
+
+ // Complete parcel info for the concierge
+ parcelInfo.complete();
+ }
+
+ public static final Creator<WeatherLocation> CREATOR = new Creator<WeatherLocation>() {
+ @Override
+ public WeatherLocation createFromParcel(Parcel in) {
+ return new WeatherLocation(in);
+ }
+
+ @Override
+ public WeatherLocation[] newArray(int size) {
+ return new WeatherLocation[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ // Tell the concierge to prepare the parcel
+ ParcelInfo parcelInfo = Concierge.prepareParcel(dest);
+
+ // ==== ELDERBERRY =====
+ dest.writeString(mKey);
+ dest.writeString(mCityId);
+ dest.writeString(mCity);
+ dest.writeString(mState);
+ dest.writeString(mPostal);
+ dest.writeString(mCountryId);
+ dest.writeString(mCountry);
+
+ // Complete parcel info for the concierge
+ parcelInfo.complete();
+ }
+
+ @Override
+ public String toString() {
+ return new StringBuilder()
+ .append("{ City ID: ").append(mCityId)
+ .append(" City: ").append(mCity)
+ .append(" State: ").append(mState)
+ .append(" Postal/ZIP Code: ").append(mPostal)
+ .append(" Country Id: ").append(mCountryId)
+ .append(" Country: ").append(mCountry).append("}")
+ .toString();
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((mKey != null) ? mKey.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) return false;
+
+ if (getClass() == obj.getClass()) {
+ WeatherLocation location = (WeatherLocation) obj;
+ return (TextUtils.equals(mKey, location.mKey));
+ }
+ return false;
+ }
+}
diff --git a/app/src/main/java/lineageos/weather/util/WeatherUtils.java b/app/src/main/java/lineageos/weather/util/WeatherUtils.java
new file mode 100644
index 0000000..dd8418f
--- /dev/null
+++ b/app/src/main/java/lineageos/weather/util/WeatherUtils.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weather.util;
+
+
+import lineageos.providers.WeatherContract;
+
+import java.text.DecimalFormat;
+
+/**
+ * Helper class to perform operations and formatting of weather data
+ */
+public class WeatherUtils {
+
+ /**
+ * Converts a temperature expressed in degrees Celsius to degrees Fahrenheit
+ * @param celsius temperature in Celsius
+ * @return the temperature in degrees Fahrenheit
+ */
+ public static double celsiusToFahrenheit(double celsius) {
+ return ((celsius * (9d/5d)) + 32d);
+ }
+
+ /**
+ * Converts a temperature expressed in degrees Fahrenheit to degrees Celsius
+ * @param fahrenheit temperature in Fahrenheit
+ * @return the temperature in degrees Celsius
+ */
+ public static double fahrenheitToCelsius(double fahrenheit) {
+ return ((fahrenheit - 32d) * (5d/9d));
+ }
+
+ /**
+ * Returns a string representation of the temperature and unit supplied. The temperature value
+ * will be half-even rounded.
+ * @param temperature the temperature value
+ * @param tempUnit A valid {@link WeatherContract.WeatherColumns.TempUnit}
+ * @return A string with the format XX°F or XX°C (where XX is the temperature)
+ * depending on the temperature unit that was provided or null if an invalid unit is supplied
+ */
+ public static String formatTemperature(double temperature, int tempUnit) {
+ if (!isValidTempUnit(tempUnit)) return null;
+ if (Double.isNaN(temperature)) return "-";
+
+ DecimalFormat noDigitsFormat = new DecimalFormat("0");
+ String noDigitsTemp = noDigitsFormat.format(temperature);
+ if (noDigitsTemp.equals("-0")) {
+ noDigitsTemp = "0";
+ }
+
+ StringBuilder formatted = new StringBuilder()
+ .append(noDigitsTemp).append("\u00b0");
+ if (tempUnit == WeatherContract.WeatherColumns.TempUnit.CELSIUS) {
+ formatted.append("C");
+ } else if (tempUnit == WeatherContract.WeatherColumns.TempUnit.FAHRENHEIT) {
+ formatted.append("F");
+ }
+ return formatted.toString();
+ }
+
+ private static boolean isValidTempUnit(int unit) {
+ switch (unit) {
+ case WeatherContract.WeatherColumns.TempUnit.CELSIUS:
+ case WeatherContract.WeatherColumns.TempUnit.FAHRENHEIT:
+ return true;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/app/src/main/java/lineageos/weatherservice/ServiceRequest.java b/app/src/main/java/lineageos/weatherservice/ServiceRequest.java
new file mode 100644
index 0000000..7f45e56
--- /dev/null
+++ b/app/src/main/java/lineageos/weatherservice/ServiceRequest.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weatherservice;
+
+
+import android.os.RemoteException;
+
+import androidx.annotation.NonNull;
+
+import lineageos.weatherservice.IWeatherProviderServiceClient;
+import lineageos.weather.LineageWeatherManager;
+import lineageos.weather.RequestInfo;
+
+/**
+ * This class represents a request submitted by the system to the active weather provider service
+ */
+public final class ServiceRequest {
+
+ private final RequestInfo mInfo;
+ private final IWeatherProviderServiceClient mClient;
+
+ private enum Status {
+ IN_PROGRESS, COMPLETED, CANCELLED, FAILED, REJECTED
+ }
+ private Status mStatus;
+
+ /* package */ ServiceRequest(RequestInfo info, IWeatherProviderServiceClient client) {
+ mInfo = info;
+ mClient = client;
+ mStatus = Status.IN_PROGRESS;
+ }
+
+ /**
+ * Obtains the request information
+ * @return {@link lineageos.weather.RequestInfo}
+ */
+ public RequestInfo getRequestInfo() {
+ return mInfo;
+ }
+
+ /**
+ * This method should be called once the request has been completed
+ */
+ public void complete(@NonNull ServiceRequestResult result) {
+ synchronized (this) {
+ if (mStatus.equals(Status.IN_PROGRESS)) {
+ try {
+ final int requestType = mInfo.getRequestType();
+ switch (requestType) {
+ case RequestInfo.TYPE_WEATHER_BY_GEO_LOCATION_REQ:
+ case RequestInfo.TYPE_WEATHER_BY_WEATHER_LOCATION_REQ:
+ if (result.getWeatherInfo() == null) {
+ throw new IllegalStateException("The service request result doesn't"
+ + " contain a valid WeatherInfo object");
+ }
+ mClient.setServiceRequestState(mInfo, result,
+ LineageWeatherManager.RequestStatus.COMPLETED);
+ break;
+ case RequestInfo.TYPE_LOOKUP_CITY_NAME_REQ:
+ if (result.getLocationLookupList() == null
+ || result.getLocationLookupList().size() <= 0) {
+ //In case the user decided to mark this request as completed with
+ //null or empty list. It's not necessarily a failure
+ mClient.setServiceRequestState(mInfo, null,
+ LineageWeatherManager.RequestStatus.NO_MATCH_FOUND);
+ } else {
+ mClient.setServiceRequestState(mInfo, result,
+ LineageWeatherManager.RequestStatus.COMPLETED);
+ }
+ break;
+ }
+ } catch (RemoteException e) {
+ }
+ mStatus = Status.COMPLETED;
+ }
+ }
+ }
+
+ /**
+ * This method should be called if the service failed to process the request
+ * (no internet connection, time out, etc.)
+ */
+ public void fail() {
+ synchronized (this) {
+ if (mStatus.equals(Status.IN_PROGRESS)) {
+ try {
+ final int requestType = mInfo.getRequestType();
+ switch (requestType) {
+ case RequestInfo.TYPE_WEATHER_BY_GEO_LOCATION_REQ:
+ case RequestInfo.TYPE_WEATHER_BY_WEATHER_LOCATION_REQ:
+ mClient.setServiceRequestState(mInfo, null,
+ LineageWeatherManager.RequestStatus.FAILED);
+ break;
+ case RequestInfo.TYPE_LOOKUP_CITY_NAME_REQ:
+ mClient.setServiceRequestState(mInfo, null,
+ LineageWeatherManager.RequestStatus.FAILED);
+ break;
+ }
+ } catch (RemoteException e) {
+ }
+ mStatus = Status.FAILED;
+ }
+ }
+ }
+
+ /**
+ * This method should be called if the service decides not to honor the request. Note this
+ * method will accept only the following values.
+ * <ul>
+ * <li>{@link lineageos.weather.LineageWeatherManager.RequestStatus#SUBMITTED_TOO_SOON}</li>
+ * <li>{@link lineageos.weather.LineageWeatherManager.RequestStatus#ALREADY_IN_PROGRESS}</li>
+ * </ul>
+ * Attempting to pass any other value will get you an IllegalArgumentException
+ * @param status
+ */
+ public void reject(int status) {
+ synchronized (this) {
+ if (mStatus.equals(Status.IN_PROGRESS)) {
+ switch (status) {
+ case LineageWeatherManager.RequestStatus.ALREADY_IN_PROGRESS:
+ case LineageWeatherManager.RequestStatus.SUBMITTED_TOO_SOON:
+ try {
+ mClient.setServiceRequestState(mInfo, null, status);
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ }
+ break;
+ default:
+ throw new IllegalArgumentException("Can't reject with status " + status);
+ }
+ mStatus = Status.REJECTED;
+ }
+ }
+ }
+
+ /**
+ * Called by the WeatherProviderService base class to notify we don't want this request anymore.
+ * The service implementing the WeatherProviderService will be notified of this action
+ * via onRequestCancelled()
+ * @hide
+ */
+ public void cancel() {
+ synchronized (this) {
+ mStatus = Status.CANCELLED;
+ }
+ }
+}
diff --git a/app/src/main/java/lineageos/weatherservice/ServiceRequestResult.java b/app/src/main/java/lineageos/weatherservice/ServiceRequestResult.java
new file mode 100644
index 0000000..cb1b2a0
--- /dev/null
+++ b/app/src/main/java/lineageos/weatherservice/ServiceRequestResult.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weatherservice;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+
+import lineageos.os.Build;
+import lineageos.os.Concierge;
+import lineageos.os.Concierge.ParcelInfo;
+import lineageos.weather.WeatherLocation;
+import lineageos.weather.WeatherInfo;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Use this class to build a request result.
+ */
+public final class ServiceRequestResult implements Parcelable {
+
+ private WeatherInfo mWeatherInfo;
+ private List<WeatherLocation> mLocationLookupList;
+ private String mKey;
+
+ private ServiceRequestResult() {}
+
+ private ServiceRequestResult(Parcel in) {
+ // Read parcelable version via the Concierge
+ ParcelInfo parcelInfo = Concierge.receiveParcel(in);
+ int parcelableVersion = parcelInfo.getParcelVersion();
+
+ if (parcelableVersion >= Build.LINEAGE_VERSION_CODES.ELDERBERRY) {
+ mKey = in.readString();
+ int hasWeatherInfo = in.readInt();
+ if (hasWeatherInfo == 1) {
+ mWeatherInfo = WeatherInfo.CREATOR.createFromParcel(in);
+ }
+ int hasLocationLookupList = in.readInt();
+ if (hasLocationLookupList == 1) {
+ mLocationLookupList = new ArrayList<>();
+ int listSize = in.readInt();
+ while (listSize > 0) {
+ mLocationLookupList.add(WeatherLocation.CREATOR.createFromParcel(in));
+ listSize--;
+ }
+ }
+ }
+
+ // Complete parcel info for the concierge
+ parcelInfo.complete();
+ }
+
+ public static final Creator<ServiceRequestResult> CREATOR
+ = new Creator<ServiceRequestResult>() {
+ @Override
+ public ServiceRequestResult createFromParcel(Parcel in) {
+ return new ServiceRequestResult(in);
+ }
+
+ @Override
+ public ServiceRequestResult[] newArray(int size) {
+ return new ServiceRequestResult[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ // Tell the concierge to prepare the parcel
+ ParcelInfo parcelInfo = Concierge.prepareParcel(dest);
+
+ // ==== ELDERBERRY =====
+ dest.writeString(mKey);
+ if (mWeatherInfo != null) {
+ dest.writeInt(1);
+ mWeatherInfo.writeToParcel(dest, 0);
+ } else {
+ dest.writeInt(0);
+ }
+ if (mLocationLookupList != null) {
+ dest.writeInt(1);
+ dest.writeInt(mLocationLookupList.size());
+ for (WeatherLocation lookup : mLocationLookupList) {
+ lookup.writeToParcel(dest, 0);
+ }
+ } else {
+ dest.writeInt(0);
+ }
+
+ // Complete parcel info for the concierge
+ parcelInfo.complete();
+ }
+
+ /**
+ * Builder class for {@link ServiceRequestResult}
+ */
+ public static class Builder {
+ private WeatherInfo mWeatherInfo;
+ private List<WeatherLocation> mLocationLookupList;
+ public Builder() {
+ this.mWeatherInfo = null;
+ this.mLocationLookupList = null;
+ }
+
+ /**
+ * @param weatherInfo The WeatherInfo object holding the data that will be used to update
+ * the weather content provider
+ */
+ public Builder(@NonNull WeatherInfo weatherInfo) {
+ if (weatherInfo == null) {
+ throw new IllegalArgumentException("WeatherInfo can't be null");
+ }
+
+ mWeatherInfo = weatherInfo;
+ }
+
+ /**
+ * @param locations The list of WeatherLocation objects. The list should not be null
+ */
+ public Builder(@NonNull List<WeatherLocation> locations) {
+ if (locations == null) {
+ throw new IllegalArgumentException("Weather location list can't be null");
+ }
+ mLocationLookupList = locations;
+ }
+
+ /**
+ * Creates a {@link ServiceRequestResult} with the arguments
+ * supplied to this builder
+ * @return {@link ServiceRequestResult}
+ */
+ public ServiceRequestResult build() {
+ ServiceRequestResult result = new ServiceRequestResult();
+ result.mWeatherInfo = this.mWeatherInfo;
+ result.mLocationLookupList = this.mLocationLookupList;
+ result.mKey = UUID.randomUUID().toString();
+ return result;
+ }
+ }
+
+ /**
+ * @return The WeatherInfo object supplied by the weather provider service
+ */
+ public WeatherInfo getWeatherInfo() {
+ return mWeatherInfo;
+ }
+
+ /**
+ * @return The list of WeatherLocation objects supplied by the weather provider service
+ */
+ public List<WeatherLocation> getLocationLookupList() {
+ return new ArrayList<>(mLocationLookupList);
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((mKey != null) ? mKey.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) return false;
+
+ if (getClass() == obj.getClass()) {
+ ServiceRequestResult request = (ServiceRequestResult) obj;
+ return (TextUtils.equals(mKey, request.mKey));
+ }
+ return false;
+ }
+}
diff --git a/app/src/main/java/lineageos/weatherservice/WeatherProviderService.java b/app/src/main/java/lineageos/weatherservice/WeatherProviderService.java
new file mode 100644
index 0000000..2e3a535
--- /dev/null
+++ b/app/src/main/java/lineageos/weatherservice/WeatherProviderService.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright (C) 2016 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package lineageos.weatherservice;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+import lineageos.weather.RequestInfo;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.WeakHashMap;
+
+
+public abstract class WeatherProviderService extends Service {
+
+ private Handler mHandler;
+ private IWeatherProviderServiceClient mClient;
+ private Set<ServiceRequest> mWeakRequestsSet
+ = Collections.newSetFromMap(new WeakHashMap<ServiceRequest, Boolean>());
+
+ /**
+ * The {@link android.content.Intent} action that must be declared as handled by a service in
+ * its manifest for the system to recognize it as a weather provider service
+ */
+ public static final String SERVICE_INTERFACE
+ = "lineageos.weatherservice.WeatherProviderService";
+
+ /**
+ * Name under which a {@link WeatherProviderService} publishes information about itself.
+ * This meta-data must reference an XML resource containing
+ * a <code><weather-provider-service></code>
+ * tag.
+ */
+ public static final String SERVICE_META_DATA = "lineageos.weatherservice";
+
+ @Override
+ protected final void attachBaseContext(Context base) {
+ super.attachBaseContext(base);
+ mHandler = new ServiceHandler(base.getMainLooper());
+ }
+
+ @Override
+ public final IBinder onBind(Intent intent) {
+ return mBinder;
+ }
+
+ private final IWeatherProviderService.Stub mBinder = new IWeatherProviderService.Stub() {
+
+ @Override
+ public void processWeatherUpdateRequest(final RequestInfo info) {
+ mHandler.obtainMessage(ServiceHandler.MSG_ON_NEW_REQUEST, info).sendToTarget();
+ }
+
+ @Override
+ public void processCityNameLookupRequest(final RequestInfo info) {
+ mHandler.obtainMessage(ServiceHandler.MSG_ON_NEW_REQUEST, info).sendToTarget();
+ }
+
+ @Override
+ public void setServiceClient(IWeatherProviderServiceClient client) {
+ mHandler.obtainMessage(ServiceHandler.MSG_SET_CLIENT, client).sendToTarget();
+ }
+
+ @Override
+ public void cancelOngoingRequests() {
+ synchronized (mWeakRequestsSet) {
+ for (final ServiceRequest request : mWeakRequestsSet) {
+ request.cancel();
+ mWeakRequestsSet.remove(request);
+ mHandler.obtainMessage(ServiceHandler.MSG_CANCEL_REQUEST, request)
+ .sendToTarget();
+ }
+ }
+ }
+
+ @Override
+ public void cancelRequest(int requestId) {
+ synchronized (mWeakRequestsSet) {
+ for (final ServiceRequest request : mWeakRequestsSet) {
+ if (request.getRequestInfo().hashCode() == requestId) {
+ mWeakRequestsSet.remove(request);
+ request.cancel();
+ mHandler.obtainMessage(ServiceHandler.MSG_CANCEL_REQUEST, request)
+ .sendToTarget();
+ break;
+ }
+ }
+ }
+ }
+ };
+
+ private class ServiceHandler extends Handler {
+
+ public ServiceHandler(Looper looper) {
+ super(looper);
+ }
+ public static final int MSG_SET_CLIENT = 1;
+ public static final int MSG_ON_NEW_REQUEST = 2;
+ public static final int MSG_CANCEL_REQUEST = 3;
+
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_SET_CLIENT: {
+ mClient = (IWeatherProviderServiceClient) msg.obj;
+ if (mClient != null) {
+ onConnected();
+ } else {
+ onDisconnected();
+ }
+ return;
+ }
+ case MSG_ON_NEW_REQUEST: {
+ RequestInfo info = (RequestInfo) msg.obj;
+ if (info != null) {
+ ServiceRequest request = new ServiceRequest(info, mClient);
+ synchronized (mWeakRequestsSet) {
+ mWeakRequestsSet.add(request);
+ }
+ onRequestSubmitted(request);
+ }
+ return;
+ }
+ case MSG_CANCEL_REQUEST: {
+ ServiceRequest request = (ServiceRequest) msg.obj;
+ onRequestCancelled(request);
+ return;
+ }
+ }
+ }
+ }
+
+ /**
+ * The system has connected to this service.
+ */
+ protected void onConnected() {
+ /* Do nothing */
+ }
+
+ /**
+ * The system has disconnected from this service.
+ */
+ protected void onDisconnected() {
+ /* Do nothing */
+ }
+
+ /**
+ * A new request has been submitted to this service
+ * @param request The service request to be processed by this service
+ */
+ protected abstract void onRequestSubmitted(ServiceRequest request);
+
+ /**
+ * Called when the system is not interested on this request anymore. Note that the service
+ * <b>has marked the request as cancelled</b> and you must stop any ongoing operation
+ * (such as pulling data from internet) that this service could've been performing to honor the
+ * request.
+ *
+ * @param request The request cancelled by the system
+ */
+ protected abstract void onRequestCancelled(ServiceRequest request);
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/osmand/aidlapi/AidlParams.java b/app/src/main/java/net/osmand/aidlapi/AidlParams.java
new file mode 100644
index 0000000..6024c5f
--- /dev/null
+++ b/app/src/main/java/net/osmand/aidlapi/AidlParams.java
@@ -0,0 +1,35 @@
+package net.osmand.aidlapi;
+
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+public abstract class AidlParams implements Parcelable {
+
+ @Override
+ public final void writeToParcel(Parcel dest, int flags) {
+ Bundle bundle = new Bundle();
+ writeToBundle(bundle);
+ dest.writeBundle(bundle);
+ }
+
+ public final void readFromParcel(Parcel in) {
+ Bundle bundle = in.readBundle(getClass().getClassLoader());
+ if (bundle != null) {
+ readFromBundle(bundle);
+ }
+ }
+
+ protected void writeToBundle(Bundle bundle) {
+
+ }
+
+ protected void readFromBundle(Bundle bundle) {
+
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/osmand/aidlapi/gpx/AGpxBitmap.java b/app/src/main/java/net/osmand/aidlapi/gpx/AGpxBitmap.java
new file mode 100644
index 0000000..fd0d527
--- /dev/null
+++ b/app/src/main/java/net/osmand/aidlapi/gpx/AGpxBitmap.java
@@ -0,0 +1,49 @@
+package net.osmand.aidlapi.gpx;
+
+import android.graphics.Bitmap;
+import android.os.Bundle;
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+
+import net.osmand.aidlapi.AidlParams;
+
+public class AGpxBitmap extends AidlParams {
+
+ private Bitmap bitmap;
+
+ public AGpxBitmap(@NonNull Bitmap bitmap) {
+ this.bitmap = bitmap;
+ }
+
+ public AGpxBitmap(Parcel in) {
+ readFromParcel(in);
+ }
+
+ public Bitmap getBitmap() {
+ return bitmap;
+ }
+
+ public static final Creator<AGpxBitmap> CREATOR = new Creator<AGpxBitmap>() {
+ @Override
+ public AGpxBitmap createFromParcel(Parcel in) {
+ return new AGpxBitmap(in);
+ }
+
+ @Override
+ public AGpxBitmap[] newArray(int size) {
+ return new AGpxBitmap[size];
+ }
+ };
+
+ @Override
+ public void writeToBundle(Bundle bundle) {
+ bundle.putParcelable("bitmap", bitmap);
+ }
+
+ @Override
+ protected void readFromBundle(Bundle bundle) {
+ bundle.setClassLoader(Bitmap.class.getClassLoader());
+ bitmap = bundle.getParcelable("bitmap");
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/osmand/aidlapi/navigation/ADirectionInfo.java b/app/src/main/java/net/osmand/aidlapi/navigation/ADirectionInfo.java
new file mode 100644
index 0000000..91c59df
--- /dev/null
+++ b/app/src/main/java/net/osmand/aidlapi/navigation/ADirectionInfo.java
@@ -0,0 +1,73 @@
+package net.osmand.aidlapi.navigation;
+
+import android.os.Bundle;
+import android.os.Parcel;
+
+import net.osmand.aidlapi.AidlParams;
+
+public class ADirectionInfo extends AidlParams {
+
+ private int distanceTo; //distance to next turn
+ private int turnType; //turn type
+ private boolean isLeftSide; //is movement left-sided
+
+ public ADirectionInfo(int distanceTo, int turnType, boolean isLeftSide) {
+ this.distanceTo = distanceTo;
+ this.turnType = turnType;
+ this.isLeftSide = isLeftSide;
+ }
+
+ protected ADirectionInfo(Parcel in) {
+ readFromParcel(in);
+ }
+
+ public static final Creator<ADirectionInfo> CREATOR = new Creator<ADirectionInfo>() {
+ @Override
+ public ADirectionInfo createFromParcel(Parcel in) {
+ return new ADirectionInfo(in);
+ }
+
+ @Override
+ public ADirectionInfo[] newArray(int size) {
+ return new ADirectionInfo[size];
+ }
+ };
+
+ public int getDistanceTo() {
+ return distanceTo;
+ }
+
+ public int getTurnType() {
+ return turnType;
+ }
+
+ public boolean isLeftSide() {
+ return isLeftSide;
+ }
+
+ public void setDistanceTo(int distanceTo) {
+ this.distanceTo = distanceTo;
+ }
+
+ public void setTurnType(int turnType) {
+ this.turnType = turnType;
+ }
+
+ public void setLeftSide(boolean leftSide) {
+ isLeftSide = leftSide;
+ }
+
+ @Override
+ protected void readFromBundle(Bundle bundle) {
+ distanceTo = bundle.getInt("distanceTo");
+ turnType = bundle.getInt("turnType");
+ isLeftSide = bundle.getBoolean("isLeftSide");
+ }
+
+ @Override
+ public void writeToBundle(Bundle bundle) {
+ bundle.putInt("distanceTo", distanceTo);
+ bundle.putInt("turnType", turnType);
+ bundle.putBoolean("isLeftSide", isLeftSide);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/osmand/aidlapi/navigation/ANavigationUpdateParams.java b/app/src/main/java/net/osmand/aidlapi/navigation/ANavigationUpdateParams.java
new file mode 100644
index 0000000..aad9958
--- /dev/null
+++ b/app/src/main/java/net/osmand/aidlapi/navigation/ANavigationUpdateParams.java
@@ -0,0 +1,60 @@
+package net.osmand.aidlapi.navigation;
+
+import android.os.Bundle;
+import android.os.Parcel;
+
+import net.osmand.aidlapi.AidlParams;
+
+public class ANavigationUpdateParams extends AidlParams {
+
+ private boolean subscribeToUpdates = true;
+ private long callbackId = -1L;
+
+ public ANavigationUpdateParams() {
+
+ }
+
+ protected ANavigationUpdateParams(Parcel in) {
+ readFromParcel(in);
+ }
+
+ public static final Creator<ANavigationUpdateParams> CREATOR = new Creator<ANavigationUpdateParams>() {
+ @Override
+ public ANavigationUpdateParams createFromParcel(Parcel in) {
+ return new ANavigationUpdateParams(in);
+ }
+
+ @Override
+ public ANavigationUpdateParams[] newArray(int size) {
+ return new ANavigationUpdateParams[size];
+ }
+ };
+
+ public long getCallbackId() {
+ return callbackId;
+ }
+
+ public void setCallbackId(long callbackId) {
+ this.callbackId = callbackId;
+ }
+
+ public void setSubscribeToUpdates(boolean subscribeToUpdates) {
+ this.subscribeToUpdates = subscribeToUpdates;
+ }
+
+ public boolean isSubscribeToUpdates() {
+ return subscribeToUpdates;
+ }
+
+ @Override
+ protected void readFromBundle(Bundle bundle) {
+ callbackId = bundle.getLong("callbackId");
+ subscribeToUpdates = bundle.getBoolean("subscribeToUpdates");
+ }
+
+ @Override
+ public void writeToBundle(Bundle bundle) {
+ bundle.putLong("callbackId", callbackId);
+ bundle.putBoolean("subscribeToUpdates", subscribeToUpdates);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/osmand/aidlapi/navigation/ANavigationVoiceRouterMessageParams.java b/app/src/main/java/net/osmand/aidlapi/navigation/ANavigationVoiceRouterMessageParams.java
new file mode 100644
index 0000000..4c4ce89
--- /dev/null
+++ b/app/src/main/java/net/osmand/aidlapi/navigation/ANavigationVoiceRouterMessageParams.java
@@ -0,0 +1,60 @@
+package net.osmand.aidlapi.navigation;
+
+import android.os.Bundle;
+import android.os.Parcel;
+
+import net.osmand.aidlapi.AidlParams;
+
+public class ANavigationVoiceRouterMessageParams extends AidlParams {
+
+ private long callbackId = -1L;
+ private boolean subscribeToUpdates = true;
+
+ public ANavigationVoiceRouterMessageParams() {
+
+ }
+
+ protected ANavigationVoiceRouterMessageParams(Parcel in) {
+ readFromParcel(in);
+ }
+
+ public static final Creator<ANavigationVoiceRouterMessageParams> CREATOR = new Creator<ANavigationVoiceRouterMessageParams>() {
+ @Override
+ public ANavigationVoiceRouterMessageParams createFromParcel(Parcel in) {
+ return new ANavigationVoiceRouterMessageParams(in);
+ }
+
+ @Override
+ public ANavigationVoiceRouterMessageParams[] newArray(int size) {
+ return new ANavigationVoiceRouterMessageParams[size];
+ }
+ };
+
+ public long getCallbackId() {
+ return callbackId;
+ }
+
+ public void setCallbackId(long callbackId) {
+ this.callbackId = callbackId;
+ }
+
+ public void setSubscribeToUpdates(boolean subscribeToUpdates) {
+ this.subscribeToUpdates = subscribeToUpdates;
+ }
+
+ public boolean isSubscribeToUpdates() {
+ return subscribeToUpdates;
+ }
+
+ @Override
+ protected void readFromBundle(Bundle bundle) {
+ callbackId = bundle.getLong("callbackId");
+ subscribeToUpdates = bundle.getBoolean("subscribeToUpdates");
+ }
+
+ @Override
+ public void writeToBundle(Bundle bundle) {
+ bundle.putLong("callbackId", callbackId);
+ bundle.putBoolean("subscribeToUpdates", subscribeToUpdates);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/osmand/aidlapi/navigation/OnVoiceNavigationParams.java b/app/src/main/java/net/osmand/aidlapi/navigation/OnVoiceNavigationParams.java
new file mode 100644
index 0000000..3c74568
--- /dev/null
+++ b/app/src/main/java/net/osmand/aidlapi/navigation/OnVoiceNavigationParams.java
@@ -0,0 +1,70 @@
+package net.osmand.aidlapi.navigation;
+
+import android.os.Bundle;
+import android.os.Parcel;
+
+import net.osmand.aidlapi.AidlParams;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class OnVoiceNavigationParams extends AidlParams {
+
+ private ArrayList<String> cmds = new ArrayList<>();
+ private ArrayList<String> played = new ArrayList<>();
+
+ public OnVoiceNavigationParams() {
+
+ }
+
+ public OnVoiceNavigationParams(List<String> cmds, List<String> played) {
+ if (cmds != null) {
+ this.cmds.addAll(cmds);
+ }
+ if (played != null) {
+ this.played.addAll(played);
+ }
+ }
+
+ public OnVoiceNavigationParams(Parcel in) {
+ readFromParcel(in);
+ }
+
+ public static final Creator<OnVoiceNavigationParams> CREATOR = new Creator<OnVoiceNavigationParams>() {
+ @Override
+ public OnVoiceNavigationParams createFromParcel(Parcel in) {
+ return new OnVoiceNavigationParams(in);
+ }
+
+ @Override
+ public OnVoiceNavigationParams[] newArray(int size) {
+ return new OnVoiceNavigationParams[size];
+ }
+ };
+
+ public List<String> getCommands() {
+ return cmds;
+ }
+
+ public List<String> getPlayed() {
+ return played;
+ }
+
+ @Override
+ public void writeToBundle(Bundle bundle) {
+ bundle.putStringArrayList("cmds", cmds);
+ bundle.putStringArrayList("played", played);
+ }
+
+ @Override
+ protected void readFromBundle(Bundle bundle) {
+ cmds = bundle.getStringArrayList("cmds");
+ if (cmds == null) {
+ cmds = new ArrayList<>();
+ }
+ played = bundle.getStringArrayList("played");
+ if (played == null) {
+ played = new ArrayList<>();
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/osmand/aidlapi/search/SearchResult.java b/app/src/main/java/net/osmand/aidlapi/search/SearchResult.java
new file mode 100644
index 0000000..863b90b
--- /dev/null
+++ b/app/src/main/java/net/osmand/aidlapi/search/SearchResult.java
@@ -0,0 +1,94 @@
+package net.osmand.aidlapi.search;
+
+import android.os.Bundle;
+import android.os.Parcel;
+
+import net.osmand.aidlapi.AidlParams;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SearchResult extends AidlParams {
+
+ private double latitude;
+ private double longitude;
+
+ private String localName;
+ private String localTypeName;
+
+ private String alternateName;
+ private ArrayList<String> otherNames = new ArrayList<>();
+
+
+ public SearchResult(double latitude, double longitude, String localName, String localTypeName,
+ String alternateName, List<String> otherNames) {
+ this.latitude = latitude;
+ this.longitude = longitude;
+ this.localName = localName;
+ this.localTypeName = localTypeName;
+ this.alternateName = alternateName;
+ if (otherNames != null) {
+ this.otherNames.addAll(otherNames);
+ }
+ }
+
+ public SearchResult(Parcel in) {
+ readFromParcel(in);
+ }
+
+ public static final Creator<SearchResult> CREATOR = new Creator<SearchResult>() {
+ @Override
+ public SearchResult createFromParcel(Parcel in) {
+ return new SearchResult(in);
+ }
+
+ @Override
+ public SearchResult[] newArray(int size) {
+ return new SearchResult[size];
+ }
+ };
+
+ public double getLatitude() {
+ return latitude;
+ }
+
+ public double getLongitude() {
+ return longitude;
+ }
+
+ public String getLocalName() {
+ return localName;
+ }
+
+ public String getLocalTypeName() {
+ return localTypeName;
+ }
+
+ public String getAlternateName() {
+ return alternateName;
+ }
+
+ public List<String> getOtherNames() {
+ return otherNames;
+ }
+
+ @Override
+ public void writeToBundle(Bundle bundle) {
+ bundle.putDouble("latitude", latitude);
+ bundle.putDouble("longitude", longitude);
+ bundle.putString("localName", localName);
+ bundle.putString("localTypeName", localTypeName);
+ bundle.putString("alternateName", alternateName);
+ bundle.putStringArrayList("otherNames", otherNames);
+ }
+
+ @Override
+ protected void readFromBundle(Bundle bundle) {
+ latitude = bundle.getDouble("latitude");
+ longitude = bundle.getDouble("longitude");
+ localName = bundle.getString("localName");
+ localTypeName = bundle.getString("localTypeName");
+ alternateName = bundle.getString("alternateName");
+ otherNames = bundle.getStringArrayList("otherName");
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java
new file mode 100644
index 0000000..d1c0789
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java
@@ -0,0 +1,2096 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Damien Gaignon, Daniel Dakhno, Daniele Gobbetti, Davis Mosenkovs,
+ Dmitriy Bogdanov, Joel Beckmeyer, Jos茅 Rebelo, Korn茅l Schmidt, Ludovic
+ Jozeau, Martin, Martin.JM, mvn23, Normano64, odavo32nof, Pauli Salmenrinne,
+ Pavel Elagin, Petr Van臎k, Saul Nunez, Taavi Eom盲e, x29a
+
+ 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;
+
+import android.annotation.TargetApi;
+import android.app.AlarmManager;
+import android.app.Application;
+import android.app.NotificationManager;
+import android.app.NotificationManager.Policy;
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Build.VERSION;
+import android.preference.PreferenceManager;
+import android.provider.ContactsContract.PhoneLookup;
+import android.util.Log;
+import android.util.TypedValue;
+
+import androidx.core.app.NotificationCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBOpenHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoMaster;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.BluetoothStateChangeReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks.OpenTracksContentObserver;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceService;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.Weather;
+import nodomain.freeyourgadget.gadgetbridge.service.NotificationCollectorMonitorService;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
+import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.AMAZFITBIP;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.AMAZFITCOR;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.AMAZFITCOR2;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.FITPRO;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.GALAXY_BUDS;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.LEFUN;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.MIBAND;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.MIBAND2;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.MIBAND2_HRX;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.MIBAND3;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.PEBBLE;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.TLW64;
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceType.WATCHXPLUS;
+import static nodomain.freeyourgadget.gadgetbridge.util.GB.NOTIFICATION_CHANNEL_HIGH_PRIORITY_ID;
+import static nodomain.freeyourgadget.gadgetbridge.util.GB.NOTIFICATION_ID_ERROR;
+
+import org.apache.commons.lang3.StringUtils;
+import org.json.JSONObject;
+
+/**
+ * Main Application class that initializes and provides access to certain things like
+ * logging and DB access.
+ */
+public class GBApplication extends Application {
+ // Since this class must not log to slf4j, we use plain android.util.Log
+ private static final String TAG = "GBApplication";
+ public static final String DATABASE_NAME = "Gadgetbridge";
+
+ private static GBApplication context;
+ private static final Lock dbLock = new ReentrantLock();
+ private static DeviceService deviceService;
+ private static SharedPreferences sharedPrefs;
+ private static final String PREFS_VERSION = "shared_preferences_version";
+ //if preferences have to be migrated, increment the following and add the migration logic in migratePrefs below; see http://stackoverflow.com/questions/16397848/how-can-i-migrate-android-preferences-with-a-new-version
+ private static final int CURRENT_PREFS_VERSION = 45;
+
+ private static final LimitedQueue<Integer, String> mIDSenderLookup = new LimitedQueue<>(16);
+ private static GBPrefs prefs;
+ private static LockHandler lockHandler;
+ /**
+ * Note: is null on Lollipop
+ */
+ private static NotificationManager notificationManager;
+
+ public static final String ACTION_QUIT
+ = "nodomain.freeyourgadget.gadgetbridge.gbapplication.action.quit";
+ public static final String ACTION_LANGUAGE_CHANGE = "nodomain.freeyourgadget.gadgetbridge.gbapplication.action.language_change";
+ public static final String ACTION_THEME_CHANGE = "nodomain.freeyourgadget.gadgetbridge.gbapplication.action.theme_change";
+ public static final String ACTION_NEW_DATA = "nodomain.freeyourgadget.gadgetbridge.action.new_data";
+
+ private static GBApplication app;
+
+ private static final Logging logging = new Logging() {
+ @Override
+ protected String createLogDirectory() throws IOException {
+ if (GBEnvironment.env().isLocalTest()) {
+ return System.getProperty(Logging.PROP_LOGFILES_DIR);
+ } else {
+ File dir = FileUtils.getExternalFilesDir();
+ return dir.getAbsolutePath();
+ }
+ }
+ };
+ private static Locale language;
+
+ private DeviceManager deviceManager;
+ private BluetoothStateChangeReceiver bluetoothStateChangeReceiver;
+
+ private OpenTracksContentObserver openTracksObserver;
+
+ private long lastAutoExportTimestamp = 0;
+ private long autoExportScheduledTimestamp = 0;
+
+ public static void quit() {
+ GB.log("Quitting Gadgetbridge...", GB.INFO, null);
+ Intent quitIntent = new Intent(GBApplication.ACTION_QUIT);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(quitIntent);
+ GBApplication.deviceService().quit();
+ System.exit(0);
+ }
+
+ public static void restart() {
+ GB.log("Restarting Gadgetbridge...", GB.INFO, null);
+ final Intent quitIntent = new Intent(GBApplication.ACTION_QUIT);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(quitIntent);
+ GBApplication.deviceService().quit();
+
+ final Intent startActivity = new Intent(context, ControlCenterv2.class);
+ final PendingIntent pendingIntent = PendingIntentUtils.getActivity(context, 1337, startActivity, PendingIntent.FLAG_CANCEL_CURRENT, false);
+ final AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+ alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 1500, pendingIntent);
+
+ Runtime.getRuntime().exit(0);
+ }
+
+ public GBApplication() {
+ context = this;
+ // don't do anything here, add it to onCreate instead
+
+ //if (BuildConfig.DEBUG) {
+ // // detect everything
+ // //StrictMode.enableDefaults();
+ // // detect closeable objects
+ // //StrictMode.setVmPolicy(
+ // // new StrictMode.VmPolicy.Builder()
+ // // .detectLeakedClosableObjects()
+ // // .penaltyLog()
+ // // .build()
+ // //);
+ //}
+ }
+
+ public static Logging getLogging() {
+ return logging;
+ }
+
+ protected DeviceService createDeviceService() {
+ return new GBDeviceService(this);
+ }
+
+ @Override
+ public void onCreate() {
+ app = this;
+ super.onCreate();
+
+ if (lockHandler != null) {
+ // guard against multiple invocations (robolectric)
+ return;
+ }
+
+ sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+ prefs = new GBPrefs(sharedPrefs);
+
+ if (!GBEnvironment.isEnvironmentSetup()) {
+ GBEnvironment.setupEnvironment(GBEnvironment.createDeviceEnvironment());
+ // setup db after the environment is set up, but don't do it in test mode
+ // in test mode, it's done individually, see TestBase
+ setupDatabase();
+ }
+
+ // don't do anything here before we set up logging, otherwise
+ // slf4j may be implicitly initialized before we properly configured it.
+ setupLogging(isFileLoggingEnabled());
+
+ if (getPrefsFileVersion() != CURRENT_PREFS_VERSION) {
+ migratePrefs(getPrefsFileVersion());
+ }
+
+ // Uncomment the line below to force a device key migration, after you updated
+ // the devicetype.json file
+ //migrateDeviceTypes();
+
+ setupExceptionHandler(prefs.getBoolean("crash_notification", isDebug()));
+
+ Weather.getInstance().setCacheFile(getCacheDir(), prefs.getBoolean("cache_weather", true));
+
+ deviceManager = new DeviceManager(this);
+ String language = prefs.getString("language", "default");
+ setLanguage(language);
+
+ deviceService = createDeviceService();
+ loadAppsNotifBlackList();
+ loadAppsPebbleBlackList();
+
+ PeriodicExporter.enablePeriodicExport(context);
+
+ if (isRunningMarshmallowOrLater()) {
+ notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (isRunningOreoOrLater()) {
+ bluetoothStateChangeReceiver = new BluetoothStateChangeReceiver();
+ registerReceiver(bluetoothStateChangeReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
+ }
+ try {
+ //the following will ensure the notification manager is kept alive
+ startService(new Intent(this, NotificationCollectorMonitorService.class));
+ } catch (IllegalStateException e) {
+ String message = e.toString();
+ final Intent instructionsIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://gadgetbridge.org/basics/topics/background-service/"));
+ final PendingIntent pi = PendingIntentUtils.getActivity(context, 0, instructionsIntent, PendingIntent.FLAG_ONE_SHOT, false);
+ GB.notify(NOTIFICATION_ID_ERROR,
+ new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_HIGH_PRIORITY_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(getString(R.string.error_background_service))
+ .setContentText(getString(R.string.error_background_service_reason_truncated))
+ .setContentIntent(pi)
+ .setStyle(new NotificationCompat.BigTextStyle()
+ .bigText(getString(R.string.error_background_service_reason) + " \"" + message + "\""))
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .build(), context);
+ }
+ }
+ }
+
+ @Override
+ public void onTrimMemory(int level) {
+ super.onTrimMemory(level);
+ if (level >= TRIM_MEMORY_BACKGROUND) {
+ if (!hasBusyDevice()) {
+ DBHelper.clearSession();
+ }
+ }
+ }
+
+ /**
+ * Returns true if at least a single device is busy, e.g synchronizing activity data
+ * or something similar.
+ * Note: busy is not the same as connected or initialized!
+ */
+ private boolean hasBusyDevice() {
+ List<GBDevice> devices = getDeviceManager().getDevices();
+ for (GBDevice device : devices) {
+ if (device.isBusy()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static void setupLogging(boolean enabled) {
+ logging.setupLogging(enabled);
+ }
+
+ public static String getLogPath() {
+ return logging.getLogPath();
+ }
+
+ private void setupExceptionHandler(final boolean notifyOnCrash) {
+ final GBExceptionHandler handler = new GBExceptionHandler(Thread.getDefaultUncaughtExceptionHandler(), notifyOnCrash);
+ Thread.setDefaultUncaughtExceptionHandler(handler);
+ }
+
+ public static boolean isFileLoggingEnabled() {
+ return prefs.getBoolean("log_to_file", false);
+ }
+
+ public static boolean minimizeNotification() {
+ return prefs.getBoolean("minimize_priority", false);
+ }
+
+ public void setupDatabase() {
+ DaoMaster.OpenHelper helper;
+ GBEnvironment env = GBEnvironment.env();
+ if (env.isTest()) {
+ helper = new DaoMaster.DevOpenHelper(this, null, null);
+ } else {
+ helper = new DBOpenHelper(this, DATABASE_NAME, null);
+ }
+ SQLiteDatabase db = helper.getWritableDatabase();
+ DaoMaster daoMaster = new DaoMaster(db);
+ if (lockHandler == null) {
+ lockHandler = new LockHandler();
+ }
+ lockHandler.init(daoMaster, helper);
+ }
+
+ public static Context getContext() {
+ return context;
+ }
+
+ /**
+ * Returns the facade for talking to devices. Devices are managed by
+ * an Android Service and this facade provides access to its functionality.
+ *
+ * @return the facade for talking to the service/devices.
+ */
+ public static DeviceService deviceService() {
+ return deviceService;
+ }
+
+ /**
+ * Returns the facade for talking to a specific device. Devices are managed by
+ * an Android Service and this facade provides access to its functionality.
+ *
+ * @return the facade for talking to the service/device.
+ */
+ public static DeviceService deviceService(GBDevice device) {
+ return deviceService.forDevice(device);
+ }
+
+ /**
+ * Returns the DBHandler instance for reading/writing or throws GBException
+ * when that was not successful
+ * If acquiring was successful, callers must call #releaseDB when they
+ * are done (from the same thread that acquired the lock!
+ * <p>
+ * Callers must not hold a reference to the returned instance because it
+ * will be invalidated at some point.
+ *
+ * @return the DBHandler
+ * @throws GBException
+ * @see #releaseDB()
+ */
+ public static DBHandler acquireDB() throws GBException {
+ try {
+ if (dbLock.tryLock(30, TimeUnit.SECONDS)) {
+ return lockHandler;
+ }
+ } catch (InterruptedException ex) {
+ Log.i(TAG, "Interrupted while waiting for DB lock");
+ }
+ throw new GBException("Unable to access the database.");
+ }
+
+ /**
+ * Releases the database lock.
+ *
+ * @throws IllegalMonitorStateException if the current thread is not owning the lock
+ * @see #acquireDB()
+ */
+ public static void releaseDB() {
+ dbLock.unlock();
+ }
+
+ public static boolean isRunningMarshmallowOrLater() {
+ return VERSION.SDK_INT >= Build.VERSION_CODES.M;
+ }
+
+ public static boolean isRunningNougatOrLater() {
+ return VERSION.SDK_INT >= Build.VERSION_CODES.N;
+ }
+
+ public static boolean isRunningOreoOrLater() {
+ return VERSION.SDK_INT >= Build.VERSION_CODES.O;
+ }
+
+ public static boolean isRunningTenOrLater() {
+ return VERSION.SDK_INT >= Build.VERSION_CODES.Q;
+ }
+
+ public static boolean isRunningTwelveOrLater() {
+ return VERSION.SDK_INT >= 31; // Build.VERSION_CODES.S, but our target SDK is lower
+ }
+
+ public static boolean isRunningPieOrLater() {
+ return VERSION.SDK_INT >= Build.VERSION_CODES.P;
+ }
+
+ private static boolean isPrioritySender(int prioritySenders, String number) {
+ if (prioritySenders == Policy.PRIORITY_SENDERS_ANY) {
+ return true;
+ } else {
+ Uri uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number));
+ String[] projection = new String[]{PhoneLookup._ID, PhoneLookup.STARRED};
+ Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
+ boolean exists = false;
+ int starred = 0;
+ try {
+ if (cursor != null && cursor.moveToFirst()) {
+ exists = true;
+ starred = cursor.getInt(cursor.getColumnIndexOrThrow(PhoneLookup.STARRED));
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ if (prioritySenders == Policy.PRIORITY_SENDERS_CONTACTS && exists) {
+ return true;
+ } else if (prioritySenders == Policy.PRIORITY_SENDERS_STARRED && starred == 1) {
+ return true;
+ }
+ return false;
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ public static boolean isPriorityNumber(int priorityType, String number) {
+ NotificationManager.Policy notificationPolicy = notificationManager.getNotificationPolicy();
+ if (priorityType == Policy.PRIORITY_CATEGORY_MESSAGES) {
+ if ((notificationPolicy.priorityCategories & Policy.PRIORITY_CATEGORY_MESSAGES) == Policy.PRIORITY_CATEGORY_MESSAGES) {
+ return isPrioritySender(notificationPolicy.priorityMessageSenders, number);
+ }
+ } else if (priorityType == Policy.PRIORITY_CATEGORY_CALLS) {
+ if ((notificationPolicy.priorityCategories & Policy.PRIORITY_CATEGORY_CALLS) == Policy.PRIORITY_CATEGORY_CALLS) {
+ return isPrioritySender(notificationPolicy.priorityCallSenders, number);
+ }
+ }
+ return false;
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ public static int getGrantedInterruptionFilter() {
+ if (GBApplication.isRunningMarshmallowOrLater() && notificationManager.isNotificationPolicyAccessGranted()) {
+ return notificationManager.getCurrentInterruptionFilter();
+ }
+ return NotificationManager.INTERRUPTION_FILTER_ALL;
+ }
+
+ private static HashSet<String> apps_notification_blacklist = null;
+
+ public static boolean appIsNotifBlacklisted(String packageName) {
+ if (apps_notification_blacklist == null) {
+ GB.log("appIsNotifBlacklisted: apps_notification_blacklist is null!", GB.INFO, null);
+ }
+ return apps_notification_blacklist != null && apps_notification_blacklist.contains(packageName);
+ }
+
+ public static void setAppsNotifBlackList(Set<String> packageNames) {
+ setAppsNotifBlackList(packageNames, sharedPrefs.edit());
+ }
+
+ public static void setAppsNotifBlackList(Set<String> packageNames, SharedPreferences.Editor editor) {
+ if (packageNames == null) {
+ GB.log("Set null apps_notification_blacklist", GB.INFO, null);
+ apps_notification_blacklist = new HashSet<>();
+ } else {
+ apps_notification_blacklist = new HashSet<>(packageNames);
+ }
+ GB.log("New apps_notification_blacklist has " + apps_notification_blacklist.size() + " entries", GB.INFO, null);
+ saveAppsNotifBlackList(editor);
+ }
+
+ private static void loadAppsNotifBlackList() {
+ GB.log("Loading apps_notification_blacklist", GB.INFO, null);
+ apps_notification_blacklist = (HashSet<String>) sharedPrefs.getStringSet(GBPrefs.PACKAGE_BLACKLIST, null); // lgtm [java/abstract-to-concrete-cast]
+ if (apps_notification_blacklist == null) {
+ apps_notification_blacklist = new HashSet<>();
+ }
+ GB.log("Loaded apps_notification_blacklist has " + apps_notification_blacklist.size() + " entries", GB.INFO, null);
+ }
+
+ private static void saveAppsNotifBlackList() {
+ saveAppsNotifBlackList(sharedPrefs.edit());
+ }
+
+ private static void saveAppsNotifBlackList(SharedPreferences.Editor editor) {
+ GB.log("Saving apps_notification_blacklist with " + apps_notification_blacklist.size() + " entries", GB.INFO, null);
+ if (apps_notification_blacklist.isEmpty()) {
+ editor.putStringSet(GBPrefs.PACKAGE_BLACKLIST, null);
+ } else {
+ Prefs.putStringSet(editor, GBPrefs.PACKAGE_BLACKLIST, apps_notification_blacklist);
+ }
+ editor.apply();
+ }
+
+ public static void addAppToNotifBlacklist(String packageName) {
+ if (apps_notification_blacklist.add(packageName)) {
+ saveAppsNotifBlackList();
+ }
+ }
+
+ public static synchronized void removeFromAppsNotifBlacklist(String packageName) {
+ GB.log("Removing from apps_notification_blacklist: " + packageName, GB.INFO, null);
+ apps_notification_blacklist.remove(packageName);
+ saveAppsNotifBlackList();
+ }
+
+ private static HashSet<String> apps_pebblemsg_blacklist = null;
+
+ public static boolean appIsPebbleBlacklisted(String sender) {
+ if (apps_pebblemsg_blacklist == null) {
+ GB.log("appIsPebbleBlacklisted: apps_pebblemsg_blacklist is null!", GB.INFO, null);
+ }
+ return apps_pebblemsg_blacklist != null && apps_pebblemsg_blacklist.contains(sender);
+ }
+
+ public static void setAppsPebbleBlackList(Set<String> packageNames) {
+ setAppsPebbleBlackList(packageNames, sharedPrefs.edit());
+ }
+
+ public static void setAppsPebbleBlackList(Set<String> packageNames, SharedPreferences.Editor editor) {
+ if (packageNames == null) {
+ GB.log("Set null apps_pebblemsg_blacklist", GB.INFO, null);
+ apps_pebblemsg_blacklist = new HashSet<>();
+ } else {
+ apps_pebblemsg_blacklist = new HashSet<>(packageNames);
+ }
+ GB.log("New apps_pebblemsg_blacklist has " + apps_pebblemsg_blacklist.size() + " entries", GB.INFO, null);
+ saveAppsPebbleBlackList(editor);
+ }
+
+ private static void loadAppsPebbleBlackList() {
+ GB.log("Loading apps_pebblemsg_blacklist", GB.INFO, null);
+ apps_pebblemsg_blacklist = (HashSet<String>) sharedPrefs.getStringSet(GBPrefs.PACKAGE_PEBBLEMSG_BLACKLIST, null); // lgtm [java/abstract-to-concrete-cast]
+ if (apps_pebblemsg_blacklist == null) {
+ apps_pebblemsg_blacklist = new HashSet<>();
+ }
+ GB.log("Loaded apps_pebblemsg_blacklist has " + apps_pebblemsg_blacklist.size() + " entries", GB.INFO, null);
+ }
+
+ private static void saveAppsPebbleBlackList() {
+ saveAppsPebbleBlackList(sharedPrefs.edit());
+ }
+
+ private static void saveAppsPebbleBlackList(SharedPreferences.Editor editor) {
+ GB.log("Saving apps_pebblemsg_blacklist with " + apps_pebblemsg_blacklist.size() + " entries", GB.INFO, null);
+ if (apps_pebblemsg_blacklist.isEmpty()) {
+ editor.putStringSet(GBPrefs.PACKAGE_PEBBLEMSG_BLACKLIST, null);
+ } else {
+ Prefs.putStringSet(editor, GBPrefs.PACKAGE_PEBBLEMSG_BLACKLIST, apps_pebblemsg_blacklist);
+ }
+ editor.apply();
+ }
+
+ public static void addAppToPebbleBlacklist(String packageName) {
+ if (apps_pebblemsg_blacklist.add(packageNameToPebbleMsgSender(packageName))) {
+ saveAppsPebbleBlackList();
+ }
+ }
+
+ public static synchronized void removeFromAppsPebbleBlacklist(String packageName) {
+ GB.log("Removing from apps_pebblemsg_blacklist: " + packageName, GB.INFO, null);
+ apps_pebblemsg_blacklist.remove(packageNameToPebbleMsgSender(packageName));
+ saveAppsPebbleBlackList();
+ }
+
+ public static String packageNameToPebbleMsgSender(String packageName) {
+ if ("eu.siacs.conversations".equals(packageName)) {
+ return ("Conversations");
+ } else if ("net.osmand.plus".equals(packageName)) {
+ return ("OsmAnd");
+ }
+ return packageName;
+ }
+
+ /**
+ * Deletes both the old Activity database and the new one recreates it with empty tables.
+ *
+ * @return true on successful deletion
+ */
+ public static synchronized boolean deleteActivityDatabase(Context context) {
+ // TODO: flush, close, reopen db
+ if (lockHandler != null) {
+ lockHandler.closeDb();
+ }
+ boolean result = deleteOldActivityDatabase(context);
+ result &= getContext().deleteDatabase(DATABASE_NAME);
+ return result;
+ }
+
+ /**
+ * Deletes the legacy (pre 0.12) Activity database
+ *
+ * @return true on successful deletion
+ */
+ public static synchronized boolean deleteOldActivityDatabase(Context context) {
+ DBHelper dbHelper = new DBHelper(context);
+ boolean result = true;
+ if (dbHelper.existsDB("ActivityDatabase")) {
+ result = getContext().deleteDatabase("ActivityDatabase");
+ }
+ return result;
+ }
+
+ private int getPrefsFileVersion() {
+ try {
+ return Integer.parseInt(sharedPrefs.getString(PREFS_VERSION, "0")); //0 is legacy
+ } catch (Exception e) {
+ //in version 1 this was an int
+ return 1;
+ }
+ }
+
+ private void migrateStringPrefToPerDevicePref(String globalPref, String globalPrefDefault, String perDevicePref, ArrayList<DeviceType> deviceTypes) {
+ SharedPreferences.Editor editor = sharedPrefs.edit();
+ String globalPrefValue = prefs.getString(globalPref, globalPrefDefault);
+ try (DBHandler db = acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+ for (Device dbDevice : activeDevices) {
+ SharedPreferences deviceSpecificSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ if (deviceSpecificSharedPrefs != null) {
+ SharedPreferences.Editor deviceSharedPrefsEdit = deviceSpecificSharedPrefs.edit();
+ DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+
+ if (deviceTypes.contains(deviceType)) {
+ Log.i(TAG, "migrating global string preference " + globalPref + " for " + deviceType.name() + " " + dbDevice.getIdentifier());
+ deviceSharedPrefsEdit.putString(perDevicePref, globalPrefValue);
+ }
+ deviceSharedPrefsEdit.apply();
+ }
+ }
+ editor.remove(globalPref);
+ editor.apply();
+ } catch (Exception e) {
+ Log.w(TAG, "error acquiring DB lock");
+ }
+ }
+
+ private void migrateBooleanPrefToPerDevicePref(String globalPref, Boolean globalPrefDefault, String perDevicePref, ArrayList<DeviceType> deviceTypes) {
+ SharedPreferences.Editor editor = sharedPrefs.edit();
+ boolean globalPrefValue = prefs.getBoolean(globalPref, globalPrefDefault);
+ try (DBHandler db = acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+ for (Device dbDevice : activeDevices) {
+ SharedPreferences deviceSpecificSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ if (deviceSpecificSharedPrefs != null) {
+ SharedPreferences.Editor deviceSharedPrefsEdit = deviceSpecificSharedPrefs.edit();
+ DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+
+ if (deviceTypes.contains(deviceType)) {
+ Log.i(TAG, "migrating global boolean preference " + globalPref + " for " + deviceType.name() + " " + dbDevice.getIdentifier());
+ deviceSharedPrefsEdit.putBoolean(perDevicePref, globalPrefValue);
+ }
+ deviceSharedPrefsEdit.apply();
+ }
+ }
+ editor.remove(globalPref);
+ editor.apply();
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate " + globalPref, e);
+ }
+ }
+
+ private void migrateDeviceTypes() {
+ try (DBHandler db = acquireDB()) {
+ final InputStream inputStream = getAssets().open("migrations/devicetype.json");
+ final byte[] buffer = new byte[inputStream.available()];
+ inputStream.read(buffer);
+ inputStream.close();
+ final JSONObject deviceMapping = new JSONObject(new String(buffer));
+ final JSONObject deviceIdNameMapping = deviceMapping.getJSONObject("by-id");
+
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ String deviceTypeName = dbDevice.getTypeName();
+ if (deviceTypeName.isEmpty() || deviceTypeName.equals("UNKNOWN")) {
+ deviceTypeName = deviceIdNameMapping.optString(
+ String.valueOf(dbDevice.getType()),
+ "UNKNOWN"
+ );
+ dbDevice.setTypeName(deviceTypeName);
+ daoSession.getDeviceDao().update(dbDevice);
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate device types", e);
+ }
+ }
+
+ private void migratePrefs(int oldVersion) {
+ SharedPreferences.Editor editor = sharedPrefs.edit();
+
+ // this comes before all other migrations since the new column DeviceTypeName was added as non-null
+ if (oldVersion < 25) {
+ migrateDeviceTypes();
+ }
+
+ if (oldVersion == 0) {
+ String legacyGender = sharedPrefs.getString("mi_user_gender", null);
+ String legacyHeight = sharedPrefs.getString("mi_user_height_cm", null);
+ String legacyWeight = sharedPrefs.getString("mi_user_weight_kg", null);
+ String legacyYOB = sharedPrefs.getString("mi_user_year_of_birth", null);
+ if (legacyGender != null) {
+ int gender = "male".equals(legacyGender) ? 1 : "female".equals(legacyGender) ? 0 : 2;
+ editor.putString(ActivityUser.PREF_USER_GENDER, Integer.toString(gender));
+ editor.remove("mi_user_gender");
+ }
+ if (legacyHeight != null) {
+ editor.putString(ActivityUser.PREF_USER_HEIGHT_CM, legacyHeight);
+ editor.remove("mi_user_height_cm");
+ }
+ if (legacyWeight != null) {
+ editor.putString(ActivityUser.PREF_USER_WEIGHT_KG, legacyWeight);
+ editor.remove("mi_user_weight_kg");
+ }
+ if (legacyYOB != null) {
+ editor.putString("activity_user_year_of_birth", legacyYOB);
+ editor.remove("mi_user_year_of_birth");
+ }
+ }
+ if (oldVersion < 2) {
+ //migrate the integer version of gender introduced in version 1 to a string value, needed for the way Android accesses the shared preferences
+ int legacyGender_1 = 2;
+ try {
+ legacyGender_1 = sharedPrefs.getInt(ActivityUser.PREF_USER_GENDER, 2);
+ } catch (Exception e) {
+ Log.e(TAG, "Could not access legacy activity gender", e);
+ }
+ editor.putString(ActivityUser.PREF_USER_GENDER, Integer.toString(legacyGender_1));
+ }
+ if (oldVersion < 3) {
+ try (DBHandler db = acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+ for (Device dbDevice : activeDevices) {
+ SharedPreferences deviceSpecificSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ if (deviceSpecificSharedPrefs != null) {
+ SharedPreferences.Editor deviceSharedPrefsEdit = deviceSpecificSharedPrefs.edit();
+ String preferenceKey = dbDevice.getIdentifier() + "_lastSportsActivityTimeMillis";
+ long lastSportsActivityTimeMillis = sharedPrefs.getLong(preferenceKey, 0);
+ if (lastSportsActivityTimeMillis != 0) {
+ deviceSharedPrefsEdit.putLong("lastSportsActivityTimeMillis", lastSportsActivityTimeMillis);
+ editor.remove(preferenceKey);
+ }
+ preferenceKey = dbDevice.getIdentifier() + "_lastSyncTimeMillis";
+ long lastSyncTimeMillis = sharedPrefs.getLong(preferenceKey, 0);
+ if (lastSyncTimeMillis != 0) {
+ deviceSharedPrefsEdit.putLong("lastSyncTimeMillis", lastSyncTimeMillis);
+ editor.remove(preferenceKey);
+ }
+
+ String newLanguage = null;
+ Set<String> displayItems = null;
+
+ DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+
+ if (deviceType == AMAZFITBIP || deviceType == AMAZFITCOR || deviceType == AMAZFITCOR2) {
+ int oldLanguage = prefs.getInt("amazfitbip_language", -1);
+ newLanguage = "auto";
+ String[] oldLanguageLookup = {"zh_CN", "zh_TW", "en_US", "es_ES", "ru_RU", "de_DE", "it_IT", "fr_FR", "tr_TR"};
+ if (oldLanguage >= 0 && oldLanguage < oldLanguageLookup.length) {
+ newLanguage = oldLanguageLookup[oldLanguage];
+ }
+ }
+
+ if (deviceType == AMAZFITBIP || deviceType == AMAZFITCOR) {
+ deviceSharedPrefsEdit.putString("disconnect_notification", prefs.getString("disconnect_notification", "off"));
+ deviceSharedPrefsEdit.putString("disconnect_notification_start", prefs.getString("disconnect_notification_start", "8:00"));
+ deviceSharedPrefsEdit.putString("disconnect_notification_end", prefs.getString("disconnect_notification_end", "22:00"));
+ }
+ if (deviceType == MIBAND2 || deviceType == MIBAND2_HRX || deviceType == MIBAND3) {
+ deviceSharedPrefsEdit.putString("do_not_disturb", prefs.getString("mi2_do_not_disturb", "off"));
+ deviceSharedPrefsEdit.putString("do_not_disturb_start", prefs.getString("mi2_do_not_disturb_start", "1:00"));
+ deviceSharedPrefsEdit.putString("do_not_disturb_end", prefs.getString("mi2_do_not_disturb_end", "6:00"));
+ }
+ if (dbDevice.getManufacturer().equals("Huami")) {
+ deviceSharedPrefsEdit.putString("activate_display_on_lift_wrist", prefs.getString("activate_display_on_lift_wrist", "off"));
+ deviceSharedPrefsEdit.putString("display_on_lift_start", prefs.getString("display_on_lift_start", "0:00"));
+ deviceSharedPrefsEdit.putString("display_on_lift_end", prefs.getString("display_on_lift_end", "0:00"));
+ }
+ switch (deviceType) {
+ case MIBAND:
+ deviceSharedPrefsEdit.putBoolean("low_latency_fw_update", prefs.getBoolean("mi_low_latency_fw_update", true));
+ deviceSharedPrefsEdit.putString("device_time_offset_hours", String.valueOf(prefs.getInt("mi_device_time_offset_hours", 0)));
+ break;
+ case AMAZFITCOR:
+ displayItems = prefs.getStringSet("cor_display_items", null);
+ break;
+ case AMAZFITBIP:
+ displayItems = prefs.getStringSet("bip_display_items", null);
+ break;
+ case MIBAND2:
+ case MIBAND2_HRX:
+ displayItems = prefs.getStringSet("mi2_display_items", null);
+ deviceSharedPrefsEdit.putBoolean("mi2_enable_text_notifications", prefs.getBoolean("mi2_enable_text_notifications", true));
+ deviceSharedPrefsEdit.putString("mi2_dateformat", prefs.getString("mi2_dateformat", "dateformat_time"));
+ deviceSharedPrefsEdit.putBoolean("rotate_wrist_to_cycle_info", prefs.getBoolean("mi2_rotate_wrist_to_switch_info", false));
+ break;
+ case MIBAND3:
+ newLanguage = prefs.getString("miband3_language", "auto");
+ displayItems = prefs.getStringSet("miband3_display_items", null);
+ deviceSharedPrefsEdit.putBoolean("swipe_unlock", prefs.getBoolean("mi3_band_screen_unlock", false));
+ deviceSharedPrefsEdit.putString("night_mode", prefs.getString("mi3_night_mode", "off"));
+ deviceSharedPrefsEdit.putString("night_mode_start", prefs.getString("mi3_night_mode_start", "16:00"));
+ deviceSharedPrefsEdit.putString("night_mode_end", prefs.getString("mi3_night_mode_end", "7:00"));
+
+ }
+ if (displayItems != null) {
+ deviceSharedPrefsEdit.putStringSet("display_items", displayItems);
+ }
+ if (newLanguage != null) {
+ deviceSharedPrefsEdit.putString("language", newLanguage);
+ }
+ deviceSharedPrefsEdit.apply();
+ }
+ }
+ editor.remove("amazfitbip_language");
+ editor.remove("bip_display_items");
+ editor.remove("cor_display_items");
+ editor.remove("disconnect_notification");
+ editor.remove("disconnect_notification_start");
+ editor.remove("disconnect_notification_end");
+ editor.remove("activate_display_on_lift_wrist");
+ editor.remove("display_on_lift_start");
+ editor.remove("display_on_lift_end");
+
+ editor.remove("mi_low_latency_fw_update");
+ editor.remove("mi_device_time_offset_hours");
+ editor.remove("mi2_do_not_disturb");
+ editor.remove("mi2_do_not_disturb_start");
+ editor.remove("mi2_do_not_disturb_end");
+ editor.remove("mi2_dateformat");
+ editor.remove("mi2_display_items");
+ editor.remove("mi2_rotate_wrist_to_switch_info");
+ editor.remove("mi2_enable_text_notifications");
+ editor.remove("mi3_band_screen_unlock");
+ editor.remove("mi3_night_mode");
+ editor.remove("mi3_night_mode_start");
+ editor.remove("mi3_night_mode_end");
+ editor.remove("miband3_language");
+
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 3", e);
+ }
+ }
+ if (oldVersion < 4) {
+ try (DBHandler db = acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+ for (Device dbDevice : activeDevices) {
+ SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+
+ if (deviceType == MIBAND) {
+ int deviceTimeOffsetHours = deviceSharedPrefs.getInt("device_time_offset_hours", 0);
+ deviceSharedPrefsEdit.putString("device_time_offset_hours", Integer.toString(deviceTimeOffsetHours));
+ }
+
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 4", e);
+ }
+ }
+ if (oldVersion < 5) {
+ try (DBHandler db = acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+ for (Device dbDevice : activeDevices) {
+ SharedPreferences deviceSpecificSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ if (deviceSpecificSharedPrefs != null) {
+ SharedPreferences.Editor deviceSharedPrefsEdit = deviceSpecificSharedPrefs.edit();
+ DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+
+ String newWearside = null;
+ String newOrientation = null;
+ String newTimeformat = null;
+ switch (deviceType) {
+ case AMAZFITBIP:
+ case AMAZFITCOR:
+ case AMAZFITCOR2:
+ case MIBAND:
+ case MIBAND2:
+ case MIBAND2_HRX:
+ case MIBAND3:
+ case MIBAND4:
+ newWearside = prefs.getString("mi_wearside", "left");
+ break;
+ case MIBAND5:
+ newWearside = prefs.getString("mi_wearside", "left");
+ break;
+ case HPLUS:
+ newWearside = prefs.getString("hplus_wrist", "left");
+ newTimeformat = prefs.getString("hplus_timeformat", "24h");
+ break;
+ case ID115:
+ newWearside = prefs.getString("id115_wrist", "left");
+ newOrientation = prefs.getString("id115_screen_orientation", "horizontal");
+ break;
+ case ZETIME:
+ newWearside = prefs.getString("zetime_wrist", "left");
+ newTimeformat = prefs.getInt("zetime_timeformat", 1) == 2 ? "am/pm" : "24h";
+ break;
+ }
+ if (newWearside != null) {
+ deviceSharedPrefsEdit.putString("wearlocation", newWearside);
+ }
+ if (newOrientation != null) {
+ deviceSharedPrefsEdit.putString("screen_orientation", newOrientation);
+ }
+ if (newTimeformat != null) {
+ deviceSharedPrefsEdit.putString("timeformat", newTimeformat);
+ }
+ deviceSharedPrefsEdit.apply();
+ }
+ }
+ editor.remove("hplus_timeformat");
+ editor.remove("hplus_wrist");
+ editor.remove("id115_wrist");
+ editor.remove("id115_screen_orientation");
+ editor.remove("mi_wearside");
+ editor.remove("zetime_timeformat");
+ editor.remove("zetime_wrist");
+
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 5", e);
+ }
+ }
+ if (oldVersion < 6) {
+ migrateBooleanPrefToPerDevicePref("mi2_enable_button_action", false, "button_action_enable", new ArrayList<>(Collections.singletonList(MIBAND2)));
+ migrateBooleanPrefToPerDevicePref("mi2_button_action_vibrate", false, "button_action_vibrate", new ArrayList<>(Collections.singletonList(MIBAND2)));
+ migrateStringPrefToPerDevicePref("mi_button_press_count", "6", "button_action_press_count", new ArrayList<>(Collections.singletonList(MIBAND2)));
+ migrateStringPrefToPerDevicePref("mi_button_press_count_max_delay", "2000", "button_action_press_max_interval", new ArrayList<>(Collections.singletonList(MIBAND2)));
+ migrateStringPrefToPerDevicePref("mi_button_press_count_match_delay", "0", "button_action_broadcast_delay", new ArrayList<>(Collections.singletonList(MIBAND2)));
+ migrateStringPrefToPerDevicePref("mi_button_press_broadcast", "nodomain.freeyourgadget.gadgetbridge.ButtonPressed", "button_action_broadcast", new ArrayList<>(Collections.singletonList(MIBAND2)));
+ }
+ if (oldVersion < 7) {
+ migrateStringPrefToPerDevicePref("mi_reserve_alarm_calendar", "0", "reserve_alarms_calendar", new ArrayList<>(Arrays.asList(MIBAND, MIBAND2)));
+ }
+
+ if (oldVersion < 8) {
+ for (int i = 1; i <= 16; i++) {
+ String message = prefs.getString("canned_message_dismisscall_" + i, null);
+ if (message != null) {
+ migrateStringPrefToPerDevicePref("canned_message_dismisscall_" + i, "", "canned_message_dismisscall_" + i, new ArrayList<>(Collections.singletonList(PEBBLE)));
+ }
+ }
+ for (int i = 1; i <= 16; i++) {
+ String message = prefs.getString("canned_reply_" + i, null);
+ if (message != null) {
+ migrateStringPrefToPerDevicePref("canned_reply_" + i, "", "canned_reply_" + i, new ArrayList<>(Collections.singletonList(PEBBLE)));
+ }
+ }
+ }
+ if (oldVersion < 9) {
+ try (DBHandler db = acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+ migrateBooleanPrefToPerDevicePref("transliteration", false, "pref_transliteration_enabled", (ArrayList) activeDevices);
+ Log.w(TAG, "migrating transliteration settings");
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 9", e);
+ }
+ }
+ if (oldVersion < 10) {
+ //migrate the string version of pref_galaxy_buds_ambient_volume to int due to transition to SeekBarPreference
+ try (DBHandler db = acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+ for (Device dbDevice : activeDevices) {
+ SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+
+ if (deviceType == GALAXY_BUDS) {
+ GB.log("migrating Galaxy Buds volume", GB.INFO, null);
+ String volume = deviceSharedPrefs.getString(DeviceSettingsPreferenceConst.PREF_GALAXY_BUDS_AMBIENT_VOLUME, "1");
+ deviceSharedPrefsEdit.putInt(DeviceSettingsPreferenceConst.PREF_GALAXY_BUDS_AMBIENT_VOLUME, Integer.parseInt(volume));
+ }
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 10", e);
+ }
+ }
+ if (oldVersion < 11) {
+ try (DBHandler db = acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+ for (Device dbDevice : activeDevices) {
+ SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+ if (deviceType == WATCHXPLUS || deviceType == FITPRO || deviceType == LEFUN) {
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_enable", deviceSharedPrefs.getBoolean("pref_longsit_switch", false));
+ deviceSharedPrefsEdit.remove("pref_longsit_switch");
+ }
+ if (deviceType == WATCHXPLUS || deviceType == FITPRO) {
+ deviceSharedPrefsEdit.putString("inactivity_warnings_start", deviceSharedPrefs.getString("pref_longsit_start", "06:00"));
+ deviceSharedPrefsEdit.putString("inactivity_warnings_end", deviceSharedPrefs.getString("pref_longsit_end", "23:00"));
+ deviceSharedPrefsEdit.remove("pref_longsit_start");
+ deviceSharedPrefsEdit.remove("pref_longsit_end");
+ }
+ if (deviceType == WATCHXPLUS || deviceType == LEFUN) {
+ deviceSharedPrefsEdit.putString("inactivity_warnings_threshold", deviceSharedPrefs.getString("pref_longsit_period", "60"));
+ deviceSharedPrefsEdit.remove("pref_longsit_period");
+ }
+ if (deviceType == TLW64) {
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_enable_noshed", deviceSharedPrefs.getBoolean("screen_longsit_noshed", false));
+ deviceSharedPrefsEdit.remove("screen_longsit_noshed");
+ }
+ if (dbDevice.getManufacturer().equals("Huami")) {
+ editor.putBoolean("inactivity_warnings_dnd", prefs.getBoolean("mi2_inactivity_warnings_dnd", false));
+ editor.putString("inactivity_warnings_dnd_start", prefs.getString("mi2_inactivity_warnings_dnd_start", "12:00"));
+ editor.putString("inactivity_warnings_dnd_end", prefs.getString("mi2_inactivity_warnings_dnd_end", "14:00"));
+ editor.putBoolean("inactivity_warnings_enable", prefs.getBoolean("mi2_inactivity_warnings", false));
+ editor.putInt("inactivity_warnings_threshold", prefs.getInt("mi2_inactivity_warnings_threshold", 60));
+ editor.putString("inactivity_warnings_start", prefs.getString("mi2_inactivity_warnings_start", "06:00"));
+ editor.putString("inactivity_warnings_end", prefs.getString("mi2_inactivity_warnings_end", "22:00"));
+ }
+ switch (deviceType) {
+ case LEFUN:
+ deviceSharedPrefsEdit.putString("language", deviceSharedPrefs.getString("pref_lefun_interface_language", "0"));
+ deviceSharedPrefsEdit.remove("pref_lefun_interface_language");
+ break;
+ case FITPRO:
+ deviceSharedPrefsEdit.putString("inactivity_warnings_threshold", deviceSharedPrefs.getString("pref_longsit_period", "4"));
+ deviceSharedPrefsEdit.remove("pref_longsit_period");
+ break;
+ case ZETIME:
+ editor.putString("do_not_disturb", prefs.getString("zetime_do_not_disturb", "off"));
+ editor.putString("do_not_disturb_start", prefs.getString("zetime_do_not_disturb_start", "22:00"));
+ editor.putString("do_not_disturb_end", prefs.getString("zetime_do_not_disturb_end", "07:00"));
+ editor.putBoolean("inactivity_warnings_enable", prefs.getBoolean("zetime_inactivity_warnings", false));
+ editor.putString("inactivity_warnings_start", prefs.getString("zetime_inactivity_warnings_start", "06:00"));
+ editor.putString("inactivity_warnings_end", prefs.getString("zetime_inactivity_warnings_end", "22:00"));
+ editor.putInt("inactivity_warnings_threshold", prefs.getInt("zetime_inactivity_warnings_threshold", 60));
+ editor.putBoolean("inactivity_warnings_mo", prefs.getBoolean("zetime_prefs_inactivity_repetitions_mo", false));
+ editor.putBoolean("inactivity_warnings_tu", prefs.getBoolean("zetime_prefs_inactivity_repetitions_tu", false));
+ editor.putBoolean("inactivity_warnings_we", prefs.getBoolean("zetime_prefs_inactivity_repetitions_we", false));
+ editor.putBoolean("inactivity_warnings_th", prefs.getBoolean("zetime_prefs_inactivity_repetitions_th", false));
+ editor.putBoolean("inactivity_warnings_fr", prefs.getBoolean("zetime_prefs_inactivity_repetitions_fr", false));
+ editor.putBoolean("inactivity_warnings_sa", prefs.getBoolean("zetime_prefs_inactivity_repetitions_sa", false));
+ editor.putBoolean("inactivity_warnings_su", prefs.getBoolean("zetime_prefs_inactivity_repetitions_su", false));
+ break;
+ }
+ deviceSharedPrefsEdit.apply();
+ }
+ editor.putInt("fitness_goal", prefs.getInt("mi_fitness_goal", 8000));
+
+ editor.remove("zetime_do_not_disturb");
+ editor.remove("zetime_do_not_disturb_start");
+ editor.remove("zetime_do_not_disturb_end");
+ editor.remove("zetime_inactivity_warnings");
+ editor.remove("zetime_inactivity_warnings_start");
+ editor.remove("zetime_inactivity_warnings_end");
+ editor.remove("zetime_inactivity_warnings_threshold");
+ editor.remove("zetime_prefs_inactivity_repetitions_mo");
+ editor.remove("zetime_prefs_inactivity_repetitions_tu");
+ editor.remove("zetime_prefs_inactivity_repetitions_we");
+ editor.remove("zetime_prefs_inactivity_repetitions_th");
+ editor.remove("zetime_prefs_inactivity_repetitions_fr");
+ editor.remove("zetime_prefs_inactivity_repetitions_sa");
+ editor.remove("zetime_prefs_inactivity_repetitions_su");
+ editor.remove("mi2_inactivity_warnings_dnd");
+ editor.remove("mi2_inactivity_warnings_dnd_start");
+ editor.remove("mi2_inactivity_warnings_dnd_end");
+ editor.remove("mi2_inactivity_warnings");
+ editor.remove("mi2_inactivity_warnings_threshold");
+ editor.remove("mi2_inactivity_warnings_start");
+ editor.remove("mi2_inactivity_warnings_end");
+ editor.remove("mi_fitness_goal");
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 11", e);
+ }
+ }
+ if (oldVersion < 12) {
+ // Convert preferences that were wrongly migrated to int, since Android saves them as Strings internally
+ editor.putString("inactivity_warnings_threshold", String.valueOf(prefs.getInt("inactivity_warnings_threshold", 60)));
+ editor.putString("fitness_goal", String.valueOf(prefs.getInt("fitness_goal", 8000)));
+ }
+
+ if (oldVersion < 13) {
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ if (dbDevice.getManufacturer().equals("Huami")) {
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_enable", prefs.getBoolean("inactivity_warnings_enable", false));
+ deviceSharedPrefsEdit.putString("inactivity_warnings_threshold", prefs.getString("inactivity_warnings_threshold", "60"));
+ deviceSharedPrefsEdit.putString("inactivity_warnings_start", prefs.getString("inactivity_warnings_start", "06:00"));
+ deviceSharedPrefsEdit.putString("inactivity_warnings_end", prefs.getString("inactivity_warnings_end", "22:00"));
+
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_dnd", prefs.getBoolean("inactivity_warnings_dnd", false));
+ deviceSharedPrefsEdit.putString("inactivity_warnings_dnd_start", prefs.getString("inactivity_warnings_dnd_start", "12:00"));
+ deviceSharedPrefsEdit.putString("inactivity_warnings_dnd_end", prefs.getString("inactivity_warnings_dnd_end", "14:00"));
+
+ deviceSharedPrefsEdit.putBoolean("fitness_goal_notification", prefs.getBoolean("mi2_goal_notification", false));
+ }
+
+ // Not removing the first 4 preferences since they're still used by some devices (ZeTime)
+ editor.remove("inactivity_warnings_dnd");
+ editor.remove("inactivity_warnings_dnd_start");
+ editor.remove("inactivity_warnings_dnd_end");
+ editor.remove("mi2_goal_notification");
+
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 13", e);
+ }
+ }
+
+ if (oldVersion < 14) {
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ if (DeviceType.MIBAND.equals(dbDevice.getType()) || dbDevice.getManufacturer().equals("Huami")) {
+ deviceSharedPrefsEdit.putBoolean("heartrate_sleep_detection", prefs.getBoolean("mi_hr_sleep_detection", false));
+ deviceSharedPrefsEdit.putString("heartrate_measurement_interval", prefs.getString("heartrate_measurement_interval", "0"));
+ }
+
+ // Not removing heartrate_measurement_interval since it's still used by some devices (ZeTime)
+ editor.remove("mi_hr_sleep_detection");
+
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 14", e);
+ }
+ }
+
+ if (oldVersion < 15) {
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ if (DeviceType.FITPRO.equals(dbDevice.getType())) {
+ editor.remove("inactivity_warnings_threshold");
+ deviceSharedPrefsEdit.apply();
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 15", e);
+ }
+ }
+
+ if (oldVersion < 16) {
+ // If transliteration was enabled for a device, migrate it to the per-language setting
+ final String defaultLanguagesIfEnabled = "extended_ascii,common_symbols,scandinavian,german,russian,hebrew,greek,ukranian,arabic,persian,latvian,lithuanian,polish,estonian,icelandic,czech,turkish,bengali,korean,hungarian";
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ if (deviceSharedPrefs.getBoolean("pref_transliteration_enabled", false)) {
+ deviceSharedPrefsEdit.putString("pref_transliteration_languages", defaultLanguagesIfEnabled);
+ }
+
+ deviceSharedPrefsEdit.remove("pref_transliteration_enabled");
+
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 16", e);
+ }
+ }
+
+ if (oldVersion < 17) {
+ final HashSet<String> calendarBlacklist = (HashSet<String>) prefs.getStringSet(GBPrefs.CALENDAR_BLACKLIST, null);
+
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ deviceSharedPrefsEdit.putBoolean("sync_calendar", prefs.getBoolean("enable_calendar_sync", true));
+
+ if (calendarBlacklist != null) {
+ Prefs.putStringSet(deviceSharedPrefsEdit, GBPrefs.CALENDAR_BLACKLIST, calendarBlacklist);
+ }
+
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 17", e);
+ }
+
+ editor.remove(GBPrefs.CALENDAR_BLACKLIST);
+ }
+
+ if (oldVersion < 18) {
+ // Migrate the default value for Huami find band vibration pattern
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ if (!dbDevice.getManufacturer().equals("Huami")) {
+ continue;
+ }
+
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ deviceSharedPrefsEdit.putString("huami_vibration_profile_find_band", "long");
+ deviceSharedPrefsEdit.putString("huami_vibration_count_find_band", "1");
+
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 18", e);
+ }
+ }
+
+ if (oldVersion < 19) {
+ //remove old ble scanning prefences, now unsupported
+ editor.remove("disable_new_ble_scanning");
+ }
+
+ if (oldVersion < 20) {
+ // Add the new stress tab to all devices
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+
+ final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null);
+ if (chartsTabsValue == null) {
+ continue;
+ }
+
+ final String newPrefValue;
+ if (!StringUtils.isBlank(chartsTabsValue)) {
+ newPrefValue = chartsTabsValue + ",stress";
+ } else {
+ newPrefValue = "stress";
+ }
+
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue);
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 20", e);
+ }
+ }
+
+ if (oldVersion < 21) {
+ // Add the new PAI tab to all devices
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+
+ final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null);
+ if (chartsTabsValue == null) {
+ continue;
+ }
+
+ final String newPrefValue;
+ if (!StringUtils.isBlank(chartsTabsValue)) {
+ newPrefValue = chartsTabsValue + ",pai";
+ } else {
+ newPrefValue = "pai";
+ }
+
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue);
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 21", e);
+ }
+ }
+
+ if (oldVersion < 22) {
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ final DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+ if (deviceType == MIBAND2) {
+ final String name = dbDevice.getName();
+ if ("Mi Band HRX".equalsIgnoreCase(name) || "Mi Band 2i".equalsIgnoreCase(name)) {
+ dbDevice.setTypeName(DeviceType.MIBAND2_HRX.name());
+ daoSession.getDeviceDao().update(dbDevice);
+ }
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 22", e);
+ }
+ }
+
+ if (oldVersion < 26) {
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+
+ final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null);
+ if (chartsTabsValue == null) {
+ continue;
+ }
+
+ final String newPrefValue;
+ if (!StringUtils.isBlank(chartsTabsValue)) {
+ newPrefValue = chartsTabsValue + ",spo2";
+ } else {
+ newPrefValue = "spo2";
+ }
+
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue);
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 26", e);
+ }
+ }
+
+ if (oldVersion < 27) {
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ for (final Map.Entry<String, ?> entry : deviceSharedPrefs.getAll().entrySet()) {
+ final String key = entry.getKey();
+ if (key.startsWith("huami_2021_known_config_")) {
+ deviceSharedPrefsEdit.putString(
+ key.replace("huami_2021_known_config_", "") + "_is_known",
+ entry.getValue().toString()
+ );
+ } else if (key.endsWith("_huami_2021_possible_values")) {
+ deviceSharedPrefsEdit.putString(
+ key.replace("_huami_2021_possible_values", "") + "_possible_values",
+ entry.getValue().toString()
+ );
+ }
+ }
+
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 27", e);
+ }
+ }
+
+ if (oldVersion < 28) {
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ boolean shouldApply = false;
+
+ if (!"UNKNOWN".equals(deviceSharedPrefs.getString("events_forwarding_fellsleep_action_selection", "UNKNOWN"))) {
+ shouldApply = true;
+ deviceSharedPrefsEdit.putStringSet(
+ "events_forwarding_fellsleep_action_selections",
+ Collections.singleton(deviceSharedPrefs.getString("events_forwarding_fellsleep_action_selection", "UNKNOWN"))
+ );
+ }
+ if (!"UNKNOWN".equals(deviceSharedPrefs.getString("events_forwarding_wokeup_action_selection", "UNKNOWN"))) {
+ shouldApply = true;
+ deviceSharedPrefsEdit.putStringSet(
+ "events_forwarding_wokeup_action_selections",
+ Collections.singleton(deviceSharedPrefs.getString("events_forwarding_wokeup_action_selection", "UNKNOWN"))
+ );
+ }
+ if (!"UNKNOWN".equals(deviceSharedPrefs.getString("events_forwarding_startnonwear_action_selection", "UNKNOWN"))) {
+ shouldApply = true;
+ deviceSharedPrefsEdit.putStringSet(
+ "events_forwarding_startnonwear_action_selections",
+ Collections.singleton(deviceSharedPrefs.getString("events_forwarding_startnonwear_action_selection", "UNKNOWN"))
+ );
+ }
+
+ if (shouldApply) {
+ deviceSharedPrefsEdit.apply();
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 28", e);
+ }
+ }
+
+ if (oldVersion < 29) {
+ // Migrate HPlus preferences to device-specific
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ final DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+ if (deviceType == DeviceType.HPLUS) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ deviceSharedPrefsEdit.putString("hplus_screentime", sharedPrefs.getString("hplus_screentime", "5"));
+ deviceSharedPrefsEdit.putBoolean("hplus_alldayhr", sharedPrefs.getBoolean("hplus_alldayhr", true));
+ deviceSharedPrefsEdit.apply();
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 29", e);
+ }
+ }
+
+ if (oldVersion < 30) {
+ // Migrate QHybrid preferences to device-specific
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ final DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+ if (deviceType == DeviceType.FOSSILQHYBRID) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ deviceSharedPrefsEdit.putInt("QHYBRID_TIME_OFFSET", sharedPrefs.getInt("QHYBRID_TIME_OFFSET", 0));
+ deviceSharedPrefsEdit.putInt("QHYBRID_TIMEZONE_OFFSET", sharedPrefs.getInt("QHYBRID_TIMEZONE_OFFSET", 0));
+ deviceSharedPrefsEdit.apply();
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 30", e);
+ }
+ }
+
+ if (oldVersion < 31) {
+ // Add the new HRV Status tab to all devices
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+
+ final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null);
+ if (chartsTabsValue == null) {
+ continue;
+ }
+
+ final String newPrefValue;
+ if (!StringUtils.isBlank(chartsTabsValue)) {
+ newPrefValue = chartsTabsValue + ",hrvstatus";
+ } else {
+ newPrefValue = "hrvstatus";
+ }
+
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue);
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 31", e);
+ }
+ }
+
+ if (oldVersion < 32) {
+ // Add the new body energy tab to all devices
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+
+ final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null);
+ if (chartsTabsValue == null) {
+ continue;
+ }
+
+ final String newPrefValue;
+ if (!StringUtils.isBlank(chartsTabsValue)) {
+ newPrefValue = chartsTabsValue + ",bodyenergy";
+ } else {
+ newPrefValue = "bodyenergy";
+ }
+
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue);
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 32", e);
+ }
+ }
+
+ if (oldVersion < 33) {
+ // Remove sleep week tab from all devices, since it does not exist anymore
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+
+ final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null);
+ if (chartsTabsValue == null) {
+ continue;
+ }
+
+ final String newPrefValue;
+ if (!StringUtils.isBlank(chartsTabsValue)) {
+ newPrefValue = chartsTabsValue.replace(",sleepweek", "");
+ } else {
+ newPrefValue = chartsTabsValue;
+ }
+
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue);
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 33", e);
+ }
+ }
+
+ if (oldVersion < 34) {
+ // Migrate Mi Band preferences to device-specific
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ final DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+ if (deviceType == MIBAND || deviceType == MIBAND2 || deviceType == MIBAND2_HRX) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ deviceSharedPrefsEdit.putString("mi_vibration_profile_generic_sms", sharedPrefs.getString("mi_vibration_profile_generic_sms", "staccato"));
+ deviceSharedPrefsEdit.putString("mi_vibration_count_generic_sms", sharedPrefs.getString("mi_vibration_count_generic_sms", "3"));
+ deviceSharedPrefsEdit.putString("mi_vibration_profile_incoming_call", sharedPrefs.getString("mi_vibration_profile_incoming_call", "ring"));
+ deviceSharedPrefsEdit.putString("mi_vibration_count_incoming_call", sharedPrefs.getString("mi_vibration_count_incoming_call", "60"));
+ deviceSharedPrefsEdit.putString("mi_vibration_profile_generic_email", sharedPrefs.getString("mi_vibration_profile_generic_email", "medium"));
+ deviceSharedPrefsEdit.putString("mi_vibration_count_generic_email", sharedPrefs.getString("mi_vibration_count_generic_email", "2"));
+ deviceSharedPrefsEdit.putString("mi_vibration_profile_generic_chat", sharedPrefs.getString("mi_vibration_profile_generic_chat", "waterdrop"));
+ deviceSharedPrefsEdit.putString("mi_vibration_count_generic_chat", sharedPrefs.getString("mi_vibration_count_generic_chat", "3"));
+ deviceSharedPrefsEdit.putString("mi_vibration_profile_generic_social", sharedPrefs.getString("mi_vibration_profile_generic_social", "waterdrop"));
+ deviceSharedPrefsEdit.putString("mi_vibration_count_generic_social", sharedPrefs.getString("mi_vibration_count_generic_social", "3"));
+ deviceSharedPrefsEdit.putString("mi_vibration_profile_alarm_clock", sharedPrefs.getString("mi_vibration_profile_alarm_clock", "alarm_clock"));
+ deviceSharedPrefsEdit.putString("mi_vibration_count_alarm_clock", sharedPrefs.getString("mi_vibration_count_alarm_clock", "3"));
+ deviceSharedPrefsEdit.putString("mi_vibration_profile_generic_navigation", sharedPrefs.getString("mi_vibration_profile_generic_navigation", "waterdrop"));
+ deviceSharedPrefsEdit.putString("mi_vibration_count_generic_navigation", sharedPrefs.getString("mi_vibration_count_generic_navigation", "3"));
+ deviceSharedPrefsEdit.putString("mi_vibration_profile_generic", sharedPrefs.getString("mi_vibration_profile_generic", "waterdrop"));
+ deviceSharedPrefsEdit.putString("mi_vibration_count_generic", sharedPrefs.getString("mi_vibration_count_generic", "3"));
+
+ if (deviceType == MIBAND) {
+ deviceSharedPrefsEdit.putBoolean("keep_activity_data_on_device", sharedPrefs.getBoolean("mi_dont_ack_transfer", false));
+ }
+
+ deviceSharedPrefsEdit.apply();
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 34", e);
+ }
+ }
+
+ if (oldVersion < 35) {
+ // Migrate ZeTime preferences to device-specific
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ final DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+ if (deviceType == DeviceType.ZETIME) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ // Vibration Profiles
+ deviceSharedPrefsEdit.putString("zetime_vibration_profile_sms", sharedPrefs.getString("zetime_vibration_profile_sms", "2"));
+ deviceSharedPrefsEdit.putString("zetime_vibration_profile_incoming_call", sharedPrefs.getString("zetime_vibration_profile_incoming_call", "13"));
+ deviceSharedPrefsEdit.putString("zetime_vibration_profile_missed_call", sharedPrefs.getString("zetime_vibration_profile_missed_call", "12"));
+ deviceSharedPrefsEdit.putString("zetime_vibration_profile_generic_email", sharedPrefs.getString("zetime_vibration_profile_generic_email", "12"));
+ deviceSharedPrefsEdit.putString("zetime_vibration_profile_generic_social", sharedPrefs.getString("zetime_vibration_profile_generic_social", "12"));
+ deviceSharedPrefsEdit.putString("zetime_alarm_signaling", sharedPrefs.getString("zetime_alarm_signaling", "11"));
+ deviceSharedPrefsEdit.putString("zetime_vibration_profile_calendar", sharedPrefs.getString("zetime_vibration_profile_calendar", "12"));
+ deviceSharedPrefsEdit.putString("zetime_vibration_profile_inactivity", sharedPrefs.getString("zetime_vibration_profile_inactivity", "12"));
+ deviceSharedPrefsEdit.putString("zetime_vibration_profile_lowpower", sharedPrefs.getString("zetime_vibration_profile_lowpower", "4"));
+ deviceSharedPrefsEdit.putString("zetime_vibration_profile_antiloss", sharedPrefs.getString("zetime_vibration_profile_antiloss", "13"));
+ // DND
+ deviceSharedPrefsEdit.putString("do_not_disturb_no_auto", sharedPrefs.getString("do_not_disturb", "off"));
+ deviceSharedPrefsEdit.putString("do_not_disturb_no_auto_start", sharedPrefs.getString("do_not_disturb_start", "22:00"));
+ deviceSharedPrefsEdit.putString("do_not_disturb_no_auto_end", sharedPrefs.getString("do_not_disturb_end", "07:00"));
+ // HR
+ deviceSharedPrefsEdit.putString("heartrate_measurement_interval", sharedPrefs.getString("heartrate_measurement_interval", "0"));
+ deviceSharedPrefsEdit.putBoolean("zetime_heartrate_alarm_enable", sharedPrefs.getBoolean("zetime_heartrate_alarm_enable", false));
+ deviceSharedPrefsEdit.putString("alarm_max_heart_rate", sharedPrefs.getString("alarm_max_heart_rate", "180"));
+ deviceSharedPrefsEdit.putString("alarm_min_heart_rate", sharedPrefs.getString("alarm_min_heart_rate", "60"));
+ // Inactivity warnings
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_enable", sharedPrefs.getBoolean("inactivity_warnings_enable", false));
+ deviceSharedPrefsEdit.putString("inactivity_warnings_threshold", sharedPrefs.getString("inactivity_warnings_threshold", "60"));
+ deviceSharedPrefsEdit.putString("inactivity_warnings_start", sharedPrefs.getString("inactivity_warnings_start", "06:00"));
+ deviceSharedPrefsEdit.putString("inactivity_warnings_end", sharedPrefs.getString("inactivity_warnings_end", "22:00"));
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_mo", sharedPrefs.getBoolean("inactivity_warnings_mo", false));
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_tu", sharedPrefs.getBoolean("inactivity_warnings_tu", false));
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_we", sharedPrefs.getBoolean("inactivity_warnings_we", false));
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_th", sharedPrefs.getBoolean("inactivity_warnings_th", false));
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_fr", sharedPrefs.getBoolean("inactivity_warnings_fr", false));
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_sa", sharedPrefs.getBoolean("inactivity_warnings_sa", false));
+ deviceSharedPrefsEdit.putBoolean("inactivity_warnings_su", sharedPrefs.getBoolean("inactivity_warnings_su", false));
+ // Developer settings
+ deviceSharedPrefsEdit.putBoolean("keep_activity_data_on_device", sharedPrefs.getBoolean("zetime_dont_del_actdata", false));
+ // Activity info
+ deviceSharedPrefsEdit.putBoolean("zetime_activity_tracking", sharedPrefs.getBoolean("zetime_activity_tracking", false));
+ deviceSharedPrefsEdit.putString("zetime_calories_type", sharedPrefs.getString("zetime_calories_type", "0"));
+ // Display
+ deviceSharedPrefsEdit.putString("zetime_screentime", sharedPrefs.getString("zetime_screentime", "30"));
+ deviceSharedPrefsEdit.putBoolean("zetime_handmove_display", sharedPrefs.getBoolean("zetime_handmove_display", false));
+ deviceSharedPrefsEdit.putString("zetime_analog_mode", sharedPrefs.getString("zetime_analog_mode", "0"));
+ // Date format
+ deviceSharedPrefsEdit.putString("zetime_date_format", sharedPrefs.getString("zetime_date_format", "2"));
+ // Unused, but migrate it anyway
+ deviceSharedPrefsEdit.putString("zetime_shock_strength", sharedPrefs.getString("zetime_shock_strength", "255"));
+
+ deviceSharedPrefsEdit.apply();
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 35", e);
+ }
+ }
+ if (oldVersion < 36) {
+ // Migrate Pebble preferences to device-specific
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (Device dbDevice : activeDevices) {
+ final DeviceType deviceType = DeviceType.fromName(dbDevice.getTypeName());
+ if (deviceType == PEBBLE) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+
+ deviceSharedPrefsEdit.putBoolean("pebble_enable_outgoing_call", sharedPrefs.getBoolean("pebble_enable_outgoing_call", true));
+ deviceSharedPrefsEdit.putString("pebble_pref_privacy_mode", sharedPrefs.getString("pebble_pref_privacy_mode", getString(R.string.p_pebble_privacy_mode_off)));
+ deviceSharedPrefsEdit.putBoolean("send_sunrise_sunset", sharedPrefs.getBoolean("send_sunrise_sunset", false));
+ deviceSharedPrefsEdit.putString("pebble_activitytracker", sharedPrefs.getString("pebble_activitytracker", String.valueOf(SampleProvider.PROVIDER_PEBBLE_HEALTH)));
+ deviceSharedPrefsEdit.putBoolean("pebble_sync_health", sharedPrefs.getBoolean("pebble_sync_health", true));
+ deviceSharedPrefsEdit.putBoolean("pebble_health_store_raw", sharedPrefs.getBoolean("pebble_health_store_raw", true));
+ deviceSharedPrefsEdit.putBoolean("pebble_sync_misfit", sharedPrefs.getBoolean("pebble_sync_misfit", true));
+ deviceSharedPrefsEdit.putBoolean("pebble_sync_morpheuz", sharedPrefs.getBoolean("pebble_sync_morpheuz", true));
+ deviceSharedPrefsEdit.putBoolean("pebble_force_protocol", sharedPrefs.getBoolean("pebble_force_protocol", false));
+ deviceSharedPrefsEdit.putBoolean("pebble_force_untested", sharedPrefs.getBoolean("pebble_force_untested", false));
+ deviceSharedPrefsEdit.putBoolean("pebble_force_le", sharedPrefs.getBoolean("pebble_force_le", false));
+ deviceSharedPrefsEdit.putString("pebble_mtu_limit", sharedPrefs.getString("pebble_mtu_limit", "512"));
+ deviceSharedPrefsEdit.putBoolean("pebble_gatt_clientonly", sharedPrefs.getBoolean("pebble_gatt_clientonly", false));
+ deviceSharedPrefsEdit.putBoolean("pebble_enable_applogs", sharedPrefs.getBoolean("pebble_enable_applogs", false));
+ deviceSharedPrefsEdit.putBoolean("third_party_apps_set_settings", sharedPrefs.getBoolean("pebble_enable_pebblekit", false));
+ deviceSharedPrefsEdit.putBoolean("pebble_always_ack_pebblekit", sharedPrefs.getBoolean("pebble_always_ack_pebblekit", false));
+ deviceSharedPrefsEdit.putBoolean("pebble_enable_background_javascript", sharedPrefs.getBoolean("pebble_enable_background_javascript", false));
+
+ deviceSharedPrefsEdit.apply();
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 36", e);
+ }
+ }
+
+ if (oldVersion < 37) {
+ // Add new dashboard widgets
+ final String dashboardWidgetsOrder = sharedPrefs.getString("pref_dashboard_widgets_order", null);
+ if (!StringUtils.isBlank(dashboardWidgetsOrder) && !dashboardWidgetsOrder.contains("bodyenergy")) {
+ editor.putString("pref_dashboard_widgets_order", dashboardWidgetsOrder + ",bodyenergy,stress_segmented,hrv");
+ }
+ }
+
+ if (oldVersion < 38) {
+ // Migrate year of birth to date of birth
+ try {
+ final String yearOfBirth = sharedPrefs.getString("activity_user_year_of_birth", null);
+ if (StringUtils.isNotBlank(yearOfBirth)) {
+ final int yearOfBirthValue = Integer.parseInt(yearOfBirth);
+ if (yearOfBirthValue > 1800 && yearOfBirthValue < 3000) {
+ editor.putString("activity_user_date_of_birth", String.format(Locale.ROOT, "%s-01-01", yearOfBirth.trim()));
+ } else {
+ Log.e(TAG, "Year of birth out of range, not migrating - " + yearOfBirth);
+ }
+ }
+ } catch (final Exception e) {
+ Log.e(TAG, "Failed to migrate year of birth to date of birth in version 38", e);
+ }
+ }
+
+ if (oldVersion < 39) {
+ // Add the new Heart Rate tab to all devices
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+
+ final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null);
+ if (chartsTabsValue == null) {
+ continue;
+ }
+
+ String newPrefValue = chartsTabsValue;
+ if (!StringUtils.isBlank(chartsTabsValue)) {
+ if (!chartsTabsValue.contains("heartrate")) {
+ newPrefValue = newPrefValue + ",heartrate";
+ }
+ } else {
+ newPrefValue = "heartrate";
+ }
+
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue);
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 39", e);
+ }
+ }
+
+ if (oldVersion < 40) {
+ // Add the new VO2Max tab to all devices
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+
+ final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null);
+ if (chartsTabsValue == null) {
+ continue;
+ }
+
+ final String newPrefValue;
+ if (!StringUtils.isBlank(chartsTabsValue)) {
+ newPrefValue = chartsTabsValue + ",vo2max";
+ } else {
+ newPrefValue = "vo2max";
+ }
+
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue);
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 40", e);
+ }
+ }
+
+ if (oldVersion < 41) {
+ // Add vo2max widget.
+ final String dashboardWidgetsOrder = sharedPrefs.getString("pref_dashboard_widgets_order", null);
+ if (!StringUtils.isBlank(dashboardWidgetsOrder) && !dashboardWidgetsOrder.contains("vo2max")) {
+ editor.putString("pref_dashboard_widgets_order", dashboardWidgetsOrder + ",vo2max");
+ }
+ }
+
+ if (oldVersion < 42) {
+ // Enable crash notification by default on debug builds
+ if (!prefs.contains("crash_notification")) {
+ editor.putBoolean("crash_notification", isDebug());
+ }
+ }
+
+ if (oldVersion < 43) {
+ // Add the new calories tab to all devices.
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+
+ final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null);
+ if (chartsTabsValue == null) {
+ continue;
+ }
+
+ final String newPrefValue;
+ if (!StringUtils.isBlank(chartsTabsValue)) {
+ if (!chartsTabsValue.contains("calories")) {
+ newPrefValue = chartsTabsValue + ",calories";
+ } else {
+ newPrefValue = chartsTabsValue;
+ }
+ } else {
+ newPrefValue = "calories";
+ }
+
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue);
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 43", e);
+ }
+ }
+
+ if (oldVersion < 44) {
+ // Users upgrading to this version don't need to see the welcome screen
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ if (!activeDevices.isEmpty()) {
+ editor.putBoolean("first_run", false);
+ }
+ } catch (final Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 44", e);
+ }
+ }
+
+ if (oldVersion < 45) {
+ // Add the new respiratory rate tab to all devices.
+ try (DBHandler db = acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
+
+ for (final Device dbDevice : activeDevices) {
+ final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+
+ final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null);
+ if (chartsTabsValue == null) {
+ continue;
+ }
+
+ final String newPrefValue;
+ if (!StringUtils.isBlank(chartsTabsValue)) {
+ if (!chartsTabsValue.contains("respiratoryrate")) {
+ newPrefValue = chartsTabsValue + ",respiratoryrate";
+ } else {
+ newPrefValue = chartsTabsValue;
+ }
+ } else {
+ newPrefValue = "respiratoryrate";
+ }
+
+ final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
+ deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue);
+ deviceSharedPrefsEdit.apply();
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to migrate prefs to version 45", e);
+ }
+ }
+
+ editor.putString(PREFS_VERSION, Integer.toString(CURRENT_PREFS_VERSION));
+ editor.apply();
+ }
+
+ public static SharedPreferences getDeviceSpecificSharedPrefs(String deviceIdentifier) {
+ if (deviceIdentifier == null || deviceIdentifier.isEmpty()) {
+ return null;
+ }
+ return context.getSharedPreferences("devicesettings_" + deviceIdentifier, Context.MODE_PRIVATE);
+ }
+
+ public static DevicePrefs getDevicePrefs(GBDevice gbDevice) {
+ return new DevicePrefs(getDeviceSpecificSharedPrefs(gbDevice.getAddress()), gbDevice);
+ }
+
+ public static void deleteDeviceSpecificSharedPrefs(String deviceIdentifier) {
+ if (deviceIdentifier == null || deviceIdentifier.isEmpty()) {
+ return;
+ }
+ context.getSharedPreferences("devicesettings_" + deviceIdentifier, Context.MODE_PRIVATE).edit().clear().apply();
+ }
+
+
+ public static void setLanguage(String lang) {
+ if (lang.equals("default")) {
+ language = Resources.getSystem().getConfiguration().locale;
+ } else if (lang.length() == 2) {
+ language = new Locale(lang);
+ } else {
+ final String[] split = lang.split("_");
+ if (split.length == 2) {
+ language = new Locale(split[0], split[1]);
+ } else {
+ // Unexpected format, fallback to system default
+ language = Resources.getSystem().getConfiguration().locale;
+ }
+ }
+ updateLanguage(language);
+ }
+
+ public static void updateLanguage(Locale locale) {
+ AndroidUtils.setLanguage(context, locale);
+
+ Intent intent = new Intent();
+ intent.setAction(ACTION_LANGUAGE_CHANGE);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
+ }
+
+ public static LimitedQueue<Integer, String> getIDSenderLookup() {
+ return mIDSenderLookup;
+ }
+
+ public static boolean isDarkThemeEnabled() {
+ String selectedTheme = prefs.getString("pref_key_theme", context.getString(R.string.pref_theme_value_system));
+ Resources resources = context.getResources();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
+ (selectedTheme.equals(context.getString(R.string.pref_theme_value_system)) || selectedTheme.equals(context.getString(R.string.pref_theme_value_dynamic)))) {
+ return (resources.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
+ } else {
+ return selectedTheme.equals(context.getString(R.string.pref_theme_value_dark));
+ }
+ }
+
+ public static boolean isAmoledBlackEnabled() {
+ return prefs.getBoolean("pref_key_theme_amoled_black", false);
+ }
+
+ public static boolean areDynamicColorsEnabled() {
+ String selectedTheme = prefs.getString("pref_key_theme", context.getString(R.string.pref_theme_value_system));
+ return selectedTheme.equals(context.getString(R.string.pref_theme_value_dynamic));
+ }
+
+ public static int getTextColor(Context context) {
+ if (GBApplication.isDarkThemeEnabled()) {
+ return context.getResources().getColor(R.color.primarytext_dark);
+ } else {
+ return context.getResources().getColor(R.color.primarytext_light);
+ }
+ }
+
+ public static int getSecondaryTextColor(Context context) {
+ return context.getResources().getColor(R.color.secondarytext);
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ updateLanguage(getLanguage());
+ }
+
+ public static int getBackgroundColor(Context context) {
+ TypedValue typedValue = new TypedValue();
+ Resources.Theme theme = context.getTheme();
+ theme.resolveAttribute(android.R.attr.background, typedValue, true);
+ return typedValue.data;
+ }
+
+ public static int getWindowBackgroundColor(Context context) {
+ TypedValue typedValue = new TypedValue();
+ Resources.Theme theme = context.getTheme();
+ theme.resolveAttribute(android.R.attr.windowBackground, typedValue, true);
+ return typedValue.data;
+ }
+
+ public static GBPrefs getPrefs() {
+ return prefs;
+ }
+
+ public DeviceManager getDeviceManager() {
+ return deviceManager;
+ }
+
+ public static GBApplication app() {
+ return app;
+ }
+
+ public static Locale getLanguage() {
+ return language;
+ }
+
+ public static boolean isNightly() {
+ //noinspection ConstantValue - false positive
+ return BuildConfig.APPLICATION_ID.contains("nightly");
+ }
+
+ public static boolean isDebug() {
+ return BuildConfig.DEBUG;
+ }
+
+ public String getVersion() {
+ try {
+ return getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_META_DATA).versionName;
+ } catch (PackageManager.NameNotFoundException e) {
+ GB.log("Unable to determine Gadgetbridge's version", GB.WARN, e);
+ return "0.0.0";
+ }
+ }
+
+ public String getNameAndVersion() {
+ try {
+ ApplicationInfo appInfo = getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA);
+ PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_META_DATA);
+ return String.format("%s %s", appInfo.name, packageInfo.versionName);
+ } catch (PackageManager.NameNotFoundException e) {
+ GB.log("Unable to determine Gadgetbridge's name/version", GB.WARN, e);
+ return "Gadgetbridge";
+ }
+ }
+
+ public void setOpenTracksObserver(OpenTracksContentObserver openTracksObserver) {
+ this.openTracksObserver = openTracksObserver;
+ }
+
+ public OpenTracksContentObserver getOpenTracksObserver() {
+ return openTracksObserver;
+ }
+
+ public long getLastAutoExportTimestamp() {
+ return lastAutoExportTimestamp;
+ }
+
+ public void setLastAutoExportTimestamp(long lastAutoExportTimestamp) {
+ this.lastAutoExportTimestamp = lastAutoExportTimestamp;
+ }
+
+ public long getAutoExportScheduledTimestamp() {
+ return autoExportScheduledTimestamp;
+ }
+
+ public void setAutoExportScheduledTimestamp(long autoExportScheduledTimestamp) {
+ this.autoExportScheduledTimestamp = autoExportScheduledTimestamp;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBEnvironment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBEnvironment.java
new file mode 100644
index 0000000..721d3c6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBEnvironment.java
@@ -0,0 +1,59 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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;
+
+/**
+ * Some more or less useful utility methods to aid local (non-device) testing.
+ */
+public class GBEnvironment {
+// DO NOT USE A LOGGER HERE. Will break LoggingTest!
+// private static final Logger LOG = LoggerFactory.getLogger(GBEnvironment.class);
+
+ private static GBEnvironment environment;
+ private boolean localTest;
+ private boolean deviceTest;
+
+ public static GBEnvironment createLocalTestEnvironment() {
+ GBEnvironment env = new GBEnvironment();
+ env.localTest = true;
+ return env;
+ }
+
+ static GBEnvironment createDeviceEnvironment() {
+ return new GBEnvironment();
+ }
+
+ public final boolean isTest() {
+ return localTest || deviceTest;
+ }
+
+ public boolean isLocalTest() {
+ return localTest;
+ }
+
+ public static synchronized GBEnvironment env() {
+ return environment;
+ }
+
+ static synchronized boolean isEnvironmentSetup() {
+ return environment != null;
+ }
+
+ public synchronized static void setupEnvironment(GBEnvironment env) {
+ environment = env;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBException.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBException.java
new file mode 100644
index 0000000..f7b14ce
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBException.java
@@ -0,0 +1,35 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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;
+
+public class GBException extends Exception {
+ public GBException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public GBException(String message) {
+ super(message);
+ }
+
+ public GBException(Throwable cause) {
+ super(cause);
+ }
+
+ public GBException() {
+ super();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBExceptionHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBExceptionHandler.java
new file mode 100644
index 0000000..4f74e62
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBExceptionHandler.java
@@ -0,0 +1,103 @@
+/* Copyright (C) 2015-2024 Carsten Pfeiffer, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge;
+
+
+import static nodomain.freeyourgadget.gadgetbridge.util.GB.NOTIFICATION_CHANNEL_HIGH_PRIORITY_ID;
+import static nodomain.freeyourgadget.gadgetbridge.util.GB.NOTIFICATION_ID_ERROR;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Random;
+
+import ch.qos.logback.classic.LoggerContext;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
+
+/**
+ * Catches otherwise uncaught exceptions, logs them and terminates the app.
+ */
+public class GBExceptionHandler implements Thread.UncaughtExceptionHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(GBExceptionHandler.class);
+ private final Thread.UncaughtExceptionHandler mDelegate;
+ private final boolean mNotifyOnCrash;
+
+ public GBExceptionHandler(Thread.UncaughtExceptionHandler delegate, final boolean notifyOnCrash) {
+ mDelegate = delegate;
+ mNotifyOnCrash = notifyOnCrash;
+ }
+
+ @Override
+ public void uncaughtException(@NonNull Thread thread, @NonNull Throwable ex) {
+ LOG.error("Uncaught exception", ex);
+ // flush the log buffers and stop logging
+ LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
+ loggerContext.stop();
+
+ if (mNotifyOnCrash) {
+ showNotification(ex);
+ }
+
+ if (mDelegate != null) {
+ mDelegate.uncaughtException(thread, ex);
+ } else {
+ System.exit(1);
+ }
+ }
+
+ private void showNotification(final Throwable e) {
+ final Context context = GBApplication.getContext();
+
+ final Intent shareIntent = new Intent();
+ shareIntent.setAction(Intent.ACTION_SEND);
+ shareIntent.putExtra(Intent.EXTRA_TEXT, Log.getStackTraceString(e));
+ shareIntent.setType("text/plain");
+
+ final PendingIntent pendingShareIntent = PendingIntentUtils.getActivity(
+ context,
+ 0,
+ Intent.createChooser(shareIntent, context.getString(R.string.app_crash_share_stacktrace)),
+ PendingIntent.FLAG_UPDATE_CURRENT,
+ false
+ );
+
+ final NotificationCompat.Action shareAction = new NotificationCompat.Action.Builder(android.R.drawable.ic_menu_share, context.getString(R.string.share), pendingShareIntent).build();
+
+ final Notification notification = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_HIGH_PRIORITY_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(context.getString(
+ R.string.app_crash_notification_title,
+ context.getString(R.string.app_name)
+ ))
+ .setContentText(e.getLocalizedMessage())
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .addAction(shareAction)
+ .build();
+
+ GB.notify(NOTIFICATION_ID_ERROR, notification, context);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/LockHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/LockHandler.java
new file mode 100644
index 0000000..cd13e53
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/LockHandler.java
@@ -0,0 +1,116 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Taavi Eom盲e
+
+ 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;
+
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoMaster;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+
+/**
+ * Provides low-level access to the database.
+ */
+public class LockHandler implements DBHandler {
+
+ private DaoMaster daoMaster = null;
+ private DaoSession session = null;
+ private SQLiteOpenHelper helper = null;
+
+ public LockHandler() {
+ }
+
+ public void init(DaoMaster daoMaster, DaoMaster.OpenHelper helper) {
+ if (isValid()) {
+ throw new IllegalStateException("DB must be closed before initializing it again");
+ }
+ if (daoMaster == null) {
+ throw new IllegalArgumentException("daoMaster must not be null");
+ }
+ if (helper == null) {
+ throw new IllegalArgumentException("helper must not be null");
+ }
+ this.daoMaster = daoMaster;
+ this.helper = helper;
+
+ session = daoMaster.newSession();
+ if (session == null) {
+ throw new RuntimeException("Unable to create database session");
+ }
+ }
+
+ @Override
+ public DaoMaster getDaoMaster() {
+ return daoMaster;
+ }
+
+ private boolean isValid() {
+ return daoMaster != null;
+ }
+
+ private void ensureValid() {
+ if (!isValid()) {
+ throw new IllegalStateException("LockHandler is not in a valid state");
+ }
+ }
+
+ @Override
+ public void close() {
+ ensureValid();
+ GBApplication.releaseDB();
+ }
+
+ @Override
+ public synchronized void openDb() {
+ if (session != null) {
+ throw new IllegalStateException("session must be null");
+ }
+ // this will create completely new db instances and in turn update this handler through #init()
+ GBApplication.app().setupDatabase();
+ }
+
+ @Override
+ public synchronized void closeDb() {
+ if (session == null) {
+ throw new IllegalStateException("session must not be null");
+ }
+ session.clear();
+ session.getDatabase().close();
+ session = null;
+ helper = null;
+ daoMaster = null;
+ }
+
+ @Override
+ public SQLiteOpenHelper getHelper() {
+ ensureValid();
+ return helper;
+ }
+
+ @Override
+ public DaoSession getDaoSession() {
+ ensureValid();
+ return session;
+ }
+
+ @Override
+ public SQLiteDatabase getDatabase() {
+ ensureValid();
+ return daoMaster.getDatabase();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Logging.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Logging.java
new file mode 100644
index 0000000..5ac336d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Logging.java
@@ -0,0 +1,208 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Jos茅 Rebelo, Pavel Elagin, Petr Van臎k
+
+ 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;
+
+import android.util.Log;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.encoder.PatternLayoutEncoder;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.Appender;
+import ch.qos.logback.core.FileAppender;
+import ch.qos.logback.core.rolling.RollingFileAppender;
+import ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy;
+import ch.qos.logback.core.util.FileSize;
+import ch.qos.logback.core.util.StatusPrinter;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+
+public abstract class Logging {
+ // Only used for tests
+ public static final String PROP_LOGFILES_DIR = "GB_LOGFILES_DIR";
+
+ private String logDirectory;
+ private FileAppender<ILoggingEvent> fileLogger;
+
+ public void setupLogging(boolean enable) {
+ try {
+ if (!isFileLoggerInitialized()) {
+ init();
+ }
+ if (enable) {
+ startFileLogger();
+ } else {
+ stopFileLogger();
+ }
+ getLogger().info("Gadgetbridge version: {}-{}", BuildConfig.VERSION_NAME, BuildConfig.GIT_HASH_SHORT);
+ } catch (Exception ex) {
+ Log.e("GBApplication", "External files dir not available, cannot log to file", ex);
+ stopFileLogger();
+ }
+ }
+
+ public String getLogPath() {
+ if (fileLogger != null)
+ return fileLogger.getFile();
+ else
+ return null;
+ }
+
+ public void setImmediateFlush(final boolean immediateFlush) {
+ if (fileLogger != null) {
+ fileLogger.setImmediateFlush(true);
+ }
+ }
+
+ public boolean isFileLoggerInitialized() {
+ return logDirectory != null;
+ }
+
+ public void debugLoggingConfiguration() {
+ // For debugging problems with the logback configuration
+ LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
+ // print logback's internal status
+ StatusPrinter.print(lc);
+// Logger logger = LoggerFactory.getLogger(Logging.class);
+ }
+
+ protected abstract String createLogDirectory() throws IOException;
+
+ protected void init() throws IOException {
+ Log.i("GBApplication", "Initializing logging");
+ logDirectory = createLogDirectory();
+ if (logDirectory == null) {
+ throw new IllegalArgumentException("log directory must not be null");
+ }
+ }
+
+ private Logger getLogger() {
+ return LoggerFactory.getLogger(Logging.class);
+ }
+
+ private void startFileLogger() {
+ if (fileLogger != null) {
+ Log.w("GBApplication", "Logger already started");
+ return;
+ }
+
+ if (logDirectory == null) {
+ Log.e("GBApplication", "Can't start file logging without a log directory");
+ return;
+ }
+
+ final FileAppender fileAppender = createFileAppender(logDirectory);
+ fileAppender.start();
+ attachLogger(fileAppender);
+ fileLogger = fileAppender;
+ }
+
+ private void stopFileLogger() {
+ if (fileLogger == null) {
+ return;
+ }
+
+ if (fileLogger.isStarted()) {
+ fileLogger.stop();
+ }
+
+ detachLogger(fileLogger);
+
+ fileLogger = null;
+ }
+
+ private void attachLogger(Appender<ILoggingEvent> logger) {
+ try {
+ ch.qos.logback.classic.Logger root = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
+ if (!root.isAttached(logger)) {
+ root.addAppender(logger);
+ }
+ } catch (Throwable ex) {
+ Log.e("GBApplication", "Error attaching logger appender", ex);
+ }
+ }
+
+ private void detachLogger(Appender<ILoggingEvent> logger) {
+ try {
+ ch.qos.logback.classic.Logger root = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
+ if (logger != null && root.isAttached(logger)) {
+ root.detachAppender(logger);
+ }
+ } catch (Throwable ex) {
+ Log.e("GBApplication", "Error detaching logger appender", ex);
+ }
+ }
+
+ public FileAppender<ILoggingEvent> getFileLogger() {
+ return fileLogger;
+ }
+
+ public static String formatBytes(byte[] bytes) {
+ if (bytes == null) {
+ return "(null)";
+ }
+ StringBuilder builder = new StringBuilder(bytes.length * 5);
+ for (byte b : bytes) {
+ builder.append(String.format("0x%02x", b));
+ builder.append(" ");
+ }
+ return builder.toString().trim();
+ }
+
+ public static void logBytes(Logger logger, byte[] value) {
+ if (value != null) {
+ logger.warn("DATA: " + GB.hexdump(value, 0, value.length));
+ }
+ }
+
+ public static FileAppender createFileAppender(final String logDirectory) {
+ final LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
+
+ final PatternLayoutEncoder ple = new PatternLayoutEncoder();
+ //ple.setPattern("%date %level [%thread] %logger{10} [%file:%line] %msg%n");
+ ple.setPattern("%d{HH:mm:ss.SSS} [%thread] %-5level %logger{1} - %msg%n");
+ ple.setContext(lc);
+ ple.start();
+
+ final SizeAndTimeBasedRollingPolicy rollingPolicy = new SizeAndTimeBasedRollingPolicy();
+ final RollingFileAppender<ILoggingEvent> fileAppender = new RollingFileAppender<ILoggingEvent>();
+
+ rollingPolicy.setContext(lc);
+ rollingPolicy.setFileNamePattern(logDirectory + "/gadgetbridge-%d{yyyy-MM-dd}.%i.log.zip");
+ rollingPolicy.setParent(fileAppender);
+ rollingPolicy.setMaxFileSize(FileSize.valueOf("2MB"));
+ rollingPolicy.setMaxHistory(10);
+ rollingPolicy.setTotalSizeCap(FileSize.valueOf("100MB"));
+ rollingPolicy.start();
+
+ fileAppender.setContext(lc);
+ fileAppender.setName("FILE");
+ fileAppender.setLazy(true);
+ fileAppender.setFile(logDirectory + "/gadgetbridge.log");
+ fileAppender.setEncoder(ple);
+ // to debug crashes, set immediateFlush to true, otherwise keep it false to improve throughput
+ fileAppender.setImmediateFlush(false);
+ fileAppender.setRollingPolicy(rollingPolicy);
+
+ return fileAppender;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/SleepAlarmWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/SleepAlarmWidget.java
new file mode 100644
index 0000000..85f0795
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/SleepAlarmWidget.java
@@ -0,0 +1,159 @@
+/* Copyright (C) 2016-2024 0nse, Andreas Shimokawa, Carsten Pfeiffer,
+ Dmitry Markin, Ganblejs, Jos茅 Rebelo, Petr Van臎k
+
+ 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;
+
+import android.annotation.TargetApi;
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.widget.RemoteViews;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureAlarms;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.WidgetPreferenceStorage;
+
+/**
+ * Implementation of SleepAlarmWidget functionality. When pressing the widget, an alarm will be set
+ * to trigger after a predefined number of hours. A toast will confirm the user about this. The
+ * value is retrieved using ActivityUser.().getSleepDuration().
+ */
+public class SleepAlarmWidget extends AppWidgetProvider {
+ /**
+ * This is our dedicated action to detect when the widget has been clicked.
+ */
+ public static final String ACTION_CLICK =
+ "nodomain.freeyourgadget.gadgetbridge.SLEEP_ALARM_WIDGET_CLICK";
+
+ static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
+ int appWidgetId) {
+
+ // Construct the RemoteViews object
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.sleep_alarm_widget);
+
+ // Add our own click intent
+ Intent intent = new Intent(context, SleepAlarmWidget.class);
+ intent.setPackage(BuildConfig.APPLICATION_ID);
+ intent.setAction(ACTION_CLICK);
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ PendingIntent clickPI = PendingIntentUtils.getBroadcast(
+ context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT, false);
+ views.setOnClickPendingIntent(R.id.sleepalarmwidget_text, clickPI);
+
+ // Instruct the widget manager to update the widget
+ appWidgetManager.updateAppWidget(appWidgetId, views);
+ }
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
+ // There may be multiple widgets active, so update all of them
+ for (int appWidgetId : appWidgetIds) {
+ updateAppWidget(context, appWidgetManager, appWidgetId);
+ }
+ }
+
+ @Override
+ public void onEnabled(Context context) {
+ // Enter relevant functionality for when the first widget is created
+ }
+
+ @Override
+ public void onDisabled(Context context) {
+ // Enter relevant functionality for when the last widget is disabled
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ super.onReceive(context, intent);
+ Bundle extras = intent.getExtras();
+ int appWidgetId = -1;
+ if (extras != null) {
+ appWidgetId = extras.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID);
+ }
+
+ if (ACTION_CLICK.equals(intent.getAction())) {
+ int userSleepDuration = new ActivityUser().getSleepDurationGoal();
+ // current timestamp
+ GregorianCalendar calendar = new GregorianCalendar();
+ // add preferred sleep duration
+ if (userSleepDuration > 0) {
+ calendar.add(Calendar.HOUR_OF_DAY, userSleepDuration);
+ } else { // probably testing
+ calendar.add(Calendar.MINUTE, 1);
+ }
+
+ // overwrite the first alarm and activate it, without
+
+ GBDevice deviceForWidget = new WidgetPreferenceStorage().getDeviceForWidget(appWidgetId);
+ if (deviceForWidget == null || !deviceForWidget.isInitialized()) {
+ GB.toast(context, context.getString(R.string.appwidget_not_connected),
+ Toast.LENGTH_SHORT, GB.WARN);
+ return;
+ }
+
+ int hours = calendar.get(Calendar.HOUR_OF_DAY);
+ int minutes = calendar.get(Calendar.MINUTE);
+
+ GB.toast(context,
+ context.getString(R.string.appwidget_setting_alarm, hours, minutes),
+ Toast.LENGTH_SHORT, GB.INFO);
+
+ Alarm alarm = AlarmUtils.createSingleShot(0, true, false, calendar);
+ ArrayList<Alarm> alarms = new ArrayList<>(1);
+ alarms.add(alarm);
+ GBApplication.deviceService(deviceForWidget).onSetAlarms(alarms);
+
+// setAlarmViaAlarmManager(context, calendar.getTimeInMillis());
+ }
+ }
+
+ /**
+ * Use the Android alarm manager to create the alarm icon in the status bar.
+ *
+ * @param packageContext {@code Context}: A Context of the application package implementing this
+ * class.
+ * @param triggerTime {@code long}: time at which the underlying alarm is triggered in wall time
+ * milliseconds since the epoch
+ */
+ private void setAlarmViaAlarmManager(Context packageContext, long triggerTime) {
+ AlarmManager am = (AlarmManager) packageContext.getSystemService(Context.ALARM_SERVICE);
+ // TODO: launch the alarm configuration activity when clicking the alarm in the status bar
+ Intent intent = new Intent(packageContext, ConfigureAlarms.class);
+ intent.setPackage(BuildConfig.APPLICATION_ID);
+ PendingIntent pi = PendingIntentUtils.getBroadcast(packageContext, 0, intent,
+ PendingIntent.FLAG_CANCEL_CURRENT, false);
+ am.setAlarmClock(new AlarmManager.AlarmClockInfo(triggerTime, pi), pi);
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Widget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Widget.java
new file mode 100644
index 0000000..56ab047
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Widget.java
@@ -0,0 +1,258 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Carsten Pfeiffer, Ganblejs,
+ Jos茅 Rebelo, Petr Van臎k
+
+ 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;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.RemoteViews;
+import android.widget.Toast;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.concurrent.TimeUnit;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2;
+import nodomain.freeyourgadget.gadgetbridge.activities.WidgetAlarmsActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.ActivityChartsActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.model.DailyTotals;
+import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FormatUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.WidgetPreferenceStorage;
+
+public class Widget extends AppWidgetProvider {
+ public static final String WIDGET_CLICK = "nodomain.freeyourgadget.gadgetbridge.WidgetClick";
+ public static final String APPWIDGET_DELETED = "android.appwidget.action.APPWIDGET_DELETED";
+
+ private static final Logger LOG = LoggerFactory.getLogger(Widget.class);
+ static BroadcastReceiver broadcastReceiver = null;
+
+
+ private DailyTotals getSteps(GBDevice gbDevice) {
+ Context context = GBApplication.getContext();
+ Calendar day = GregorianCalendar.getInstance();
+
+ if (!(context instanceof GBApplication)) {
+ return new DailyTotals();
+ }
+ return DailyTotals.getDailyTotalsForDevice(gbDevice, day);
+ }
+
+ private String getHM(long value) {
+ return DateTimeUtils.formatDurationHoursMinutes(value, TimeUnit.MINUTES);
+ }
+
+ private void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
+ int appWidgetId) {
+
+ GBDevice deviceForWidget = new WidgetPreferenceStorage().getDeviceForWidget(appWidgetId);
+ if (deviceForWidget == null) {
+ LOG.debug("Widget: no device, bailing out");
+ return;
+ }
+
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget);
+
+ //onclick refresh
+ Intent intent = new Intent(context, Widget.class);
+ intent.setPackage(BuildConfig.APPLICATION_ID);
+ intent.setAction(WIDGET_CLICK);
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+ PendingIntent refreshDataIntent = PendingIntentUtils.getBroadcast(
+ context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT, false);
+ views.setOnClickPendingIntent(R.id.todaywidget_header_container, refreshDataIntent);
+
+ //open GB main window
+ Intent startMainIntent = new Intent(context, ControlCenterv2.class);
+ startMainIntent.setPackage(BuildConfig.APPLICATION_ID);
+ PendingIntent startMainPIntent = PendingIntentUtils.getActivity(context, 0, startMainIntent, 0, false);
+ views.setOnClickPendingIntent(R.id.todaywidget_header_icon, startMainPIntent);
+
+ //alarms popup menu
+ Intent startAlarmListIntent = new Intent(context, WidgetAlarmsActivity.class);
+ startAlarmListIntent.setPackage(BuildConfig.APPLICATION_ID);
+ startAlarmListIntent.putExtra(GBDevice.EXTRA_DEVICE, deviceForWidget);
+ PendingIntent startAlarmListPIntent = PendingIntentUtils.getActivity(context, appWidgetId, startAlarmListIntent, PendingIntent.FLAG_UPDATE_CURRENT, false);
+ views.setOnClickPendingIntent(R.id.todaywidget_header_alarm_icon, startAlarmListPIntent);
+
+ //charts
+ Intent startChartsIntent = new Intent(context, ActivityChartsActivity.class);
+ startChartsIntent.setPackage(BuildConfig.APPLICATION_ID);
+ startChartsIntent.putExtra(GBDevice.EXTRA_DEVICE, deviceForWidget);
+ PendingIntent startChartsPIntent = PendingIntentUtils.getActivity(context, appWidgetId, startChartsIntent, PendingIntent.FLAG_CANCEL_CURRENT, false);
+ views.setOnClickPendingIntent(R.id.todaywidget_bottom_layout, startChartsPIntent);
+
+ DailyTotals dailyTotals = getSteps(deviceForWidget);
+ int steps = (int) dailyTotals.getSteps();
+ int sleep = (int) dailyTotals.getSleep();
+ int distanceCm = (int) dailyTotals.getDistance();
+ ActivityUser activityUser = new ActivityUser();
+ int stepGoal = activityUser.getStepsGoal();
+ int sleepGoal = activityUser.getSleepDurationGoal();
+ int sleepGoalMinutes = sleepGoal * 60;
+ int distanceGoal = activityUser.getDistanceGoalMeters() * 100;
+ int stepLength = activityUser.getStepLengthCm();
+ double distanceMeters = (distanceCm > 0 ? distanceCm : steps * stepLength) * 0.01;
+ String distanceFormatted = FormatUtils.getFormattedDistanceLabel(distanceMeters);
+
+ if (sleep < 1) {
+ views.setViewVisibility(R.id.todaywidget_sleep_layout, View.GONE);
+ } else {
+ views.setViewVisibility(R.id.todaywidget_sleep_layout, View.VISIBLE);
+ }
+
+ views.setTextViewText(R.id.todaywidget_steps, String.format("%1s", steps));
+ views.setTextViewText(R.id.todaywidget_sleep, String.format("%1s", getHM(sleep)));
+ views.setTextViewText(R.id.todaywidget_distance, distanceFormatted);
+ views.setProgressBar(R.id.todaywidget_steps_progress, stepGoal, steps, false);
+ views.setProgressBar(R.id.todaywidget_sleep_progress, sleepGoalMinutes, sleep, false);
+ views.setProgressBar(R.id.todaywidget_distance_progress, distanceGoal, steps * stepLength, false);
+ views.setViewVisibility(R.id.todaywidget_battery_icon, View.GONE);
+ if (deviceForWidget != null) {
+ String status = String.format("%1s", deviceForWidget.getStateString(context));
+ if (deviceForWidget.isConnected()) {
+ if (deviceForWidget.getBatteryLevel() > 1) {
+ views.setViewVisibility(R.id.todaywidget_battery_icon, View.VISIBLE);
+
+ status = String.format("%1s%%", deviceForWidget.getBatteryLevel());
+ }
+ }
+
+ String deviceName = deviceForWidget.getAlias() != null ? deviceForWidget.getAlias() : deviceForWidget.getName();
+ views.setTextViewText(R.id.todaywidget_device_status, status);
+ views.setTextViewText(R.id.todaywidget_device_name, deviceName);
+ }
+
+ // Instruct the widget manager to update the widget
+ appWidgetManager.updateAppWidget(appWidgetId, views);
+ }
+
+ public void refreshData(int appWidgetId) {
+ Context context = GBApplication.getContext();
+ GBDevice deviceForWidget = new WidgetPreferenceStorage().getDeviceForWidget(appWidgetId);
+
+ if (deviceForWidget == null || !deviceForWidget.isInitialized()) {
+ GB.toast(context,
+ context.getString(R.string.device_not_connected),
+ Toast.LENGTH_SHORT, GB.ERROR);
+ GBApplication.deviceService(deviceForWidget).connect();
+ GB.toast(context,
+ context.getString(R.string.connecting),
+ Toast.LENGTH_SHORT, GB.INFO);
+
+ return;
+ }
+ GB.toast(context,
+ context.getString(R.string.busy_task_fetch_activity_data),
+ Toast.LENGTH_SHORT, GB.INFO);
+
+ GBApplication.deviceService(deviceForWidget).onFetchRecordedData(RecordedDataTypes.TYPE_ACTIVITY);
+ }
+
+ public void updateWidget() {
+ Context context = GBApplication.getContext();
+ AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
+ ComponentName thisAppWidget = new ComponentName(context.getPackageName(), Widget.class.getName());
+ int[] appWidgetIds = appWidgetManager.getAppWidgetIds(thisAppWidget);
+ onUpdate(context, appWidgetManager, appWidgetIds);
+ }
+
+ public void removeWidget(Context context, int appWidgetId) {
+ WidgetPreferenceStorage widgetPreferenceStorage = new WidgetPreferenceStorage();
+ widgetPreferenceStorage.removeWidgetById(context, appWidgetId);
+ }
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
+ // There may be multiple widgets active, so update all of them
+ for (int appWidgetId : appWidgetIds) {
+ updateAppWidget(context, appWidgetManager, appWidgetId);
+ }
+ }
+
+ @Override
+ public void onEnabled(Context context) {
+ if (broadcastReceiver == null) {
+ LOG.debug("gbwidget BROADCAST receiver initialized.");
+ broadcastReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ LOG.debug("gbwidget BROADCAST, action" + intent.getAction());
+ updateWidget();
+ }
+ };
+ IntentFilter intentFilter = new IntentFilter();
+ intentFilter.addAction(GBApplication.ACTION_NEW_DATA);
+ intentFilter.addAction(GBDevice.ACTION_DEVICE_CHANGED);
+ LocalBroadcastManager.getInstance(context).registerReceiver(broadcastReceiver, intentFilter);
+ }
+ }
+
+ @Override
+ public void onDisabled(Context context) {
+ if (broadcastReceiver != null) {
+ AndroidUtils.safeUnregisterBroadcastReceiver(context, broadcastReceiver);
+ broadcastReceiver = null;
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ super.onReceive(context, intent);
+ LOG.debug("gbwidget LOCAL onReceive, action: " + intent.getAction() + intent);
+ Bundle extras = intent.getExtras();
+ int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
+ if (extras != null) {
+ appWidgetId = extras.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID);
+ }
+
+ //this handles widget re-connection after apk updates
+ if (WIDGET_CLICK.equals(intent.getAction())) {
+ if (broadcastReceiver == null) {
+ onEnabled(context);
+ }
+ refreshData(appWidgetId);
+ //updateWidget();
+ } else if (APPWIDGET_DELETED.equals(intent.getAction())) {
+ onDisabled(context);
+ removeWidget(context, appWidgetId);
+ }
+ }
+
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AboutActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AboutActivity.java
new file mode 100644
index 0000000..5782b5c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AboutActivity.java
@@ -0,0 +1,65 @@
+/* Copyright (C) 2020-2024 Petr Van臎k, Taavi Eom盲e
+
+ 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.activities;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.os.Bundle;
+import android.text.method.LinkMovementMethod;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class AboutActivity extends AbstractGBActivity {
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_about);
+ TextView about_version = findViewById(R.id.about_version);
+ TextView about_hash = findViewById(R.id.about_hash);
+ String versionName = BuildConfig.VERSION_NAME;
+ String versionHASH = BuildConfig.GIT_HASH_SHORT;
+ about_version.setText(String.format(getString(R.string.about_version), versionName));
+ about_version.setOnClickListener(this::copyVersionToClipboard);
+ about_hash.setText(String.format(getString(R.string.about_hash), versionHASH));
+ about_hash.setOnClickListener(this::copyVersionToClipboard);
+
+ TextView link1 = findViewById(R.id.links1);
+ link1.setMovementMethod(LinkMovementMethod.getInstance());
+ TextView link2 = findViewById(R.id.links2);
+ link2.setMovementMethod(LinkMovementMethod.getInstance());
+ TextView link3 = findViewById(R.id.links3);
+ link3.setMovementMethod(LinkMovementMethod.getInstance());
+ }
+
+ private void copyVersionToClipboard(View view) {
+ String versions = "Version: " + BuildConfig.VERSION_NAME +
+ "\nCommit: " + BuildConfig.GIT_HASH_SHORT +
+ "\nFlavor: " + BuildConfig.FLAVOR;
+ ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
+ ClipData clip = ClipData.newPlainText("Build data", versions);
+ clipboard.setPrimaryClip(clip);
+ GB.toast(getString(R.string.about_build_details_copied_to_clipboard), Toast.LENGTH_LONG, GB.INFO);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AboutUserPreferencesActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AboutUserPreferencesActivity.java
new file mode 100644
index 0000000..ca6dea5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AboutUserPreferencesActivity.java
@@ -0,0 +1,117 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_ACTIVETIME_MINUTES;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_CALORIES_BURNT;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_DATE_OF_BIRTH;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_DISTANCE_METERS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_GENDER;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_GOAL_FAT_BURN_TIME_MINUTES;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_GOAL_STANDING_TIME_HOURS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_GOAL_WEIGHT_KG;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_HEIGHT_CM;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_NAME;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_SLEEP_DURATION;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_STEPS_GOAL;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_STEP_LENGTH_CM;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_WEIGHT_KG;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.InputType;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager;
+
+public class AboutUserPreferencesActivity extends AbstractSettingsActivityV2 {
+ @Override
+ protected String fragmentTag() {
+ return AboutUserPreferencesFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return new AboutUserPreferencesFragment();
+ }
+
+ public static class AboutUserPreferencesFragment extends AbstractPreferenceFragment {
+ static final String FRAGMENT_TAG = "ABOUT_USER_PREFERENCES_FRAGMENT";
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ setPreferencesFromResource(R.xml.about_user, rootKey);
+
+ addPreferenceHandlerFor(PREF_USER_NAME, true, false);
+ addPreferenceHandlerFor(PREF_USER_DATE_OF_BIRTH, true, false);
+ addPreferenceHandlerFor(PREF_USER_HEIGHT_CM, true, true);
+ addPreferenceHandlerFor(PREF_USER_WEIGHT_KG, true, false);
+ addPreferenceHandlerFor(PREF_USER_GENDER, true, false);
+ addPreferenceHandlerFor(PREF_USER_STEPS_GOAL, true, true);
+ addPreferenceHandlerFor(PREF_USER_GOAL_WEIGHT_KG, true, true);
+ addPreferenceHandlerFor(PREF_USER_GOAL_STANDING_TIME_HOURS, true, true);
+ addPreferenceHandlerFor(PREF_USER_GOAL_FAT_BURN_TIME_MINUTES, true, true);
+ addPreferenceHandlerFor(PREF_USER_SLEEP_DURATION, false, true);
+ addPreferenceHandlerFor(PREF_USER_STEP_LENGTH_CM, false, true);
+ addPreferenceHandlerFor(PREF_USER_DISTANCE_METERS, false, true);
+
+ setInputTypeFor(PREF_USER_HEIGHT_CM, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(PREF_USER_WEIGHT_KG, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(PREF_USER_STEPS_GOAL, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(PREF_USER_GOAL_WEIGHT_KG, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(PREF_USER_GOAL_STANDING_TIME_HOURS, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(PREF_USER_GOAL_FAT_BURN_TIME_MINUTES, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(PREF_USER_SLEEP_DURATION, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(PREF_USER_CALORIES_BURNT, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(PREF_USER_ACTIVETIME_MINUTES, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(PREF_USER_STEP_LENGTH_CM, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(PREF_USER_DISTANCE_METERS, InputType.TYPE_CLASS_NUMBER);
+ }
+
+ /**
+ * @param prefKey the pref key that chagned
+ * @param sendToDevice notify all device support classes of the preference change
+ * @param refreshDeviceList Ensure that the Control center is re-rendered when user preferences change
+ */
+ private void addPreferenceHandlerFor(final String prefKey,
+ final boolean sendToDevice,
+ final boolean refreshDeviceList) {
+ final Preference pref = findPreference(prefKey);
+ if (pref == null) {
+ LOG.warn("Could not find preference {}", prefKey);
+ return;
+ }
+
+ pref.setOnPreferenceChangeListener((preference, newVal) -> {
+ if (sendToDevice) {
+ GBApplication.deviceService().onSendConfiguration(prefKey);
+ }
+ if (refreshDeviceList) {
+ final Intent refreshIntent = new Intent(DeviceManager.ACTION_REFRESH_DEVICELIST);
+ LocalBroadcastManager.getInstance(requireActivity().getApplicationContext()).sendBroadcast(refreshIntent);
+ return true;
+ }
+ return true;
+ });
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractFragmentPagerAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractFragmentPagerAdapter.java
new file mode 100644
index 0000000..227b067
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractFragmentPagerAdapter.java
@@ -0,0 +1,68 @@
+/* Copyright (C) 2015-2024 Carsten Pfeiffer, Daniele Gobbetti
+
+ 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.activities;
+
+import android.view.ViewGroup;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentStatePagerAdapter;
+
+public abstract class AbstractFragmentPagerAdapter extends FragmentStatePagerAdapter {
+ private final Set<AbstractGBFragment> fragments = new HashSet<>();
+ private Object primaryFragment;
+
+ public AbstractFragmentPagerAdapter(FragmentManager fm) {
+ super(fm);
+ }
+
+ @Override
+ public Object instantiateItem(ViewGroup container, int position) {
+ Object fragment = super.instantiateItem(container, position);
+ if (fragment instanceof AbstractGBFragment) {
+ fragments.add((AbstractGBFragment) fragment);
+ }
+ return fragment;
+ }
+
+ @Override
+ public void destroyItem(ViewGroup container, int position, Object object) {
+ super.destroyItem(container, position, object);
+ fragments.remove(object);
+ }
+
+ @Override
+ public void setPrimaryItem(ViewGroup container, int position, Object object) {
+ super.setPrimaryItem(container, position, object);
+ if (object != primaryFragment) {
+ primaryFragment = object;
+ setCurrentFragment(primaryFragment);
+ }
+ }
+
+ private void setCurrentFragment(Object newCurrentFragment) {
+ for (AbstractGBFragment frag : fragments) {
+ if (frag != newCurrentFragment) {
+ frag.onMadeInvisibleInActivity();
+ } else {
+ frag.onMadeVisibleInActivityInternal();
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBActivity.java
new file mode 100644
index 0000000..670bfd9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBActivity.java
@@ -0,0 +1,144 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniele Gobbetti, Petr Van臎k
+
+ 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.activities;
+
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+
+
+public abstract class AbstractGBActivity extends AppCompatActivity implements GBActivity {
+ private boolean isLanguageInvalid = false;
+
+ public static final int NONE = 0;
+ public static final int NO_ACTIONBAR = 1;
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action == null) {
+ return;
+ }
+ switch (action) {
+ case GBApplication.ACTION_LANGUAGE_CHANGE:
+ setLanguage(GBApplication.getLanguage(), true);
+ break;
+ case GBApplication.ACTION_THEME_CHANGE:
+ recreate();
+ break;
+ case GBApplication.ACTION_QUIT:
+ finish();
+ break;
+ }
+ }
+ };
+
+ public void setLanguage(Locale language, boolean invalidateLanguage) {
+ if (invalidateLanguage) {
+ isLanguageInvalid = true;
+ }
+ AndroidUtils.setLanguage(this, language);
+ }
+
+ public static void init(GBActivity activity) {
+ init(activity, NONE);
+ }
+
+ public static void init(GBActivity activity, int flags) {
+ if (GBApplication.areDynamicColorsEnabled()) {
+ if (GBApplication.isDarkThemeEnabled()) {
+ if ((flags & NO_ACTIONBAR) != 0) {
+ if (GBApplication.isAmoledBlackEnabled())
+ activity.setTheme(R.style.GadgetbridgeThemeDynamicDarkAmoled_NoActionBar);
+ else
+ activity.setTheme(R.style.GadgetbridgeThemeDynamicDark_NoActionBar);
+ } else {
+ if (GBApplication.isAmoledBlackEnabled())
+ activity.setTheme(R.style.GadgetbridgeThemeDynamicDarkAmoled);
+ else
+ activity.setTheme(R.style.GadgetbridgeThemeDynamicDark);
+ }
+ } else {
+ if ((flags & NO_ACTIONBAR) != 0) {
+ activity.setTheme(R.style.GadgetbridgeThemeDynamicLight_NoActionBar);
+ } else {
+ activity.setTheme(R.style.GadgetbridgeThemeDynamicLight);
+ }
+ }
+ } else if (GBApplication.isDarkThemeEnabled()) {
+ if ((flags & NO_ACTIONBAR) != 0) {
+ if (GBApplication.isAmoledBlackEnabled())
+ activity.setTheme(R.style.GadgetbridgeThemeBlack_NoActionBar);
+ else
+ activity.setTheme(R.style.GadgetbridgeThemeDark_NoActionBar);
+ } else {
+ if (GBApplication.isAmoledBlackEnabled())
+ activity.setTheme(R.style.GadgetbridgeThemeBlack);
+ else
+ activity.setTheme(R.style.GadgetbridgeThemeDark);
+ }
+ } else {
+ if ((flags & NO_ACTIONBAR) != 0) {
+ activity.setTheme(R.style.GadgetbridgeTheme_NoActionBar);
+ } else {
+ activity.setTheme(R.style.GadgetbridgeTheme);
+ }
+ }
+ activity.setLanguage(GBApplication.getLanguage(), false);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(GBApplication.ACTION_QUIT);
+ filterLocal.addAction(GBApplication.ACTION_LANGUAGE_CHANGE);
+ filterLocal.addAction(GBApplication.ACTION_THEME_CHANGE);
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filterLocal);
+
+ init(this);
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (isLanguageInvalid) {
+ isLanguageInvalid = false;
+ recreate();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
+ super.onDestroy();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBFragment.java
new file mode 100644
index 0000000..a1d448a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBFragment.java
@@ -0,0 +1,74 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, walkjivefly
+
+ 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.activities;
+
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+/**
+ * Abstract base class for fragments. Provides hooks that are called when
+ * the fragment is made visible and invisible in the activity. also allows
+ * the fragment to define the title to be shown in the activity.
+ *
+ * @see AbstractGBFragmentActivity
+ */
+public abstract class AbstractGBFragment extends Fragment {
+ private boolean mVisibleInActivity;
+
+ /**
+ * Called when this fragment has been fully scrolled into the activity.
+ *
+ * @see #isVisibleInActivity()
+ * @see #onMadeInvisibleInActivity()
+ */
+ protected void onMadeVisibleInActivity() {
+ }
+
+ /**
+ * Called when this fragment has been scrolled out of the activity.
+ *
+ * @see #isVisibleInActivity()
+ * @see #onMadeVisibleInActivity()
+ */
+ public void onMadeInvisibleInActivity() {
+ mVisibleInActivity = false;
+ }
+
+ /**
+ * Returns true if this fragment is currently visible in the hosting
+ * activity, not taking into account whether the screen is enabled at all.
+ */
+ public boolean isVisibleInActivity() {
+ return mVisibleInActivity;
+ }
+
+ @Nullable
+ protected abstract CharSequence getTitle();
+
+ /**
+ * Internal
+ *
+ * @hide
+ */
+ public void onMadeVisibleInActivityInternal() {
+ mVisibleInActivity = true;
+ if (isVisible()) {
+ onMadeVisibleInActivity();
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBFragmentActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBFragmentActivity.java
new file mode 100644
index 0000000..2da4bf9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractGBFragmentActivity.java
@@ -0,0 +1,72 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti
+
+ 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.activities;
+
+import android.os.Bundle;
+
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentPagerAdapter;
+import androidx.fragment.app.FragmentStatePagerAdapter;
+import androidx.viewpager.widget.PagerAdapter;
+
+/**
+ * A base activity that supports paging through fragments by swiping.
+ * Subclasses will have to add a ViewPager to their layout and add something
+ * like this to hook it to the fragments:
+ * <p/>
+ * <pre>
+ * // Set up the ViewPager with the sections adapter.
+ * ViewPager viewPager = (ViewPager) findViewById(R.id.pager);
+ * viewPager.setAdapter(getPagerAdapter());
+ * </pre>
+ *
+ * @see AbstractGBFragment
+ */
+public abstract class AbstractGBFragmentActivity extends AbstractGBActivity {
+ /**
+ * The {@link PagerAdapter} that will provide
+ * fragments for each of the sections. We use a
+ * {@link FragmentPagerAdapter} derivative, which will keep every
+ * loaded fragment in memory. If this becomes too memory intensive, it
+ * may be best to switch to a
+ * {@link FragmentStatePagerAdapter}.
+ */
+ private AbstractFragmentPagerAdapter mSectionsPagerAdapter;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Create the adapter that will return a fragment for each of the three
+ // primary sections of the activity.
+ mSectionsPagerAdapter = createFragmentPagerAdapter(getSupportFragmentManager());
+ }
+
+ public AbstractFragmentPagerAdapter getPagerAdapter() {
+ return mSectionsPagerAdapter;
+ }
+
+ /**
+ * Creates a PagerAdapter that will create the fragments to be used with this
+ * activity. The fragments should typically extend AbstractGBFragment
+ *
+ * @param fragmentManager
+ * @return
+ */
+ protected abstract AbstractFragmentPagerAdapter createFragmentPagerAdapter(FragmentManager fragmentManager);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractListActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractListActivity.java
new file mode 100644
index 0000000..be17e2d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractListActivity.java
@@ -0,0 +1,78 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer, Petr Van臎k
+
+ 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.activities;
+
+import android.os.Bundle;
+
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.adapter.AbstractActivityListingAdapter;
+
+public abstract class AbstractListActivity<T> extends AbstractGBActivity {
+ private AbstractActivityListingAdapter<T> itemAdapter;
+ private RecyclerView itemListView;
+
+ public void setItemAdapter(AbstractActivityListingAdapter<T> itemAdapter) {
+ this.itemAdapter = itemAdapter;
+ itemListView.setAdapter(itemAdapter);
+ }
+
+ protected void refresh() {
+ this.itemAdapter.loadItems();
+ }
+
+ public void setActivityKindFilter(int activityKind) {
+ this.itemAdapter.setActivityKindFilter(activityKind);
+ }
+
+ public void setDateFromFilter(long date) {
+ this.itemAdapter.setDateFromFilter(date);
+ }
+
+ public void setDateToFilter(long date) {
+ this.itemAdapter.setDateToFilter(date);
+ }
+
+ public void setNameContainsFilter(String name) {
+ this.itemAdapter.setNameContainsFilter(name);
+ }
+
+ public void setItemsFilter(List<Long> items) {
+ this.itemAdapter.setItemsFilter(items);
+ }
+
+ public void setDeviceFilter(long device) {
+ this.itemAdapter.setDeviceFilter(device);
+ }
+
+ public AbstractActivityListingAdapter<T> getItemAdapter() {
+ return itemAdapter;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_list);
+ itemListView = findViewById(R.id.itemListView);
+ itemListView.setLayoutManager(new LinearLayoutManager(this));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractPreferenceFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractPreferenceFragment.java
new file mode 100644
index 0000000..9824d13
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractPreferenceFragment.java
@@ -0,0 +1,259 @@
+/* Copyright (C) 2023-2024 Andreas Shimokawa, Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.text.InputType;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+import androidx.preference.EditTextPreference;
+import androidx.preference.ListPreference;
+import androidx.preference.MultiSelectListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceGroup;
+import androidx.preference.PreferenceScreen;
+import androidx.preference.SeekBarPreference;
+import androidx.preference.SwitchPreferenceCompat;
+
+import com.mobeta.android.dslv.DragSortListPreference;
+import com.mobeta.android.dslv.DragSortListPreferenceFragment;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.XDatePreference;
+import nodomain.freeyourgadget.gadgetbridge.util.XDatePreferenceFragment;
+import nodomain.freeyourgadget.gadgetbridge.util.XTimePreference;
+import nodomain.freeyourgadget.gadgetbridge.util.XTimePreferenceFragment;
+import nodomain.freeyourgadget.gadgetbridge.util.dialogs.MaterialEditTextPreferenceDialogFragment;
+import nodomain.freeyourgadget.gadgetbridge.util.dialogs.MaterialListPreferenceDialogFragment;
+import nodomain.freeyourgadget.gadgetbridge.util.dialogs.MaterialMultiSelectListPreferenceDialogFragment;
+import nodomain.freeyourgadget.gadgetbridge.util.preferences.MinMaxTextWatcher;
+
+public abstract class AbstractPreferenceFragment extends PreferenceFragmentCompat {
+ protected static final Logger LOG = LoggerFactory.getLogger(AbstractPreferenceFragment.class);
+
+ private final SharedPreferencesChangeHandler sharedPreferencesChangeHandler = new SharedPreferencesChangeHandler();
+
+ @Override
+ public void onStart() {
+ super.onStart();
+
+ final SharedPreferences sharedPreferences = getPreferenceManager().getSharedPreferences();
+
+ reloadPreferences(sharedPreferences, getPreferenceScreen());
+
+ sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferencesChangeHandler);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ updateActionBarTitle();
+ }
+
+ private void updateActionBarTitle() {
+ try {
+ CharSequence title = getPreferenceScreen().getTitle();
+ if (StringUtils.isBlank(title)) {
+ title = requireActivity().getTitle();
+ }
+ ((AbstractSettingsActivityV2) requireActivity()).setActionBarTitle(title);
+ } catch (final Exception e) {
+ LOG.error("Failed to update action bar title", e);
+ }
+ }
+
+ @Override
+ public void onStop() {
+ getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(sharedPreferencesChangeHandler);
+
+ super.onStop();
+ }
+
+ @Override
+ public void onDisplayPreferenceDialog(@NonNull final Preference preference) {
+ DialogFragment dialogFragment;
+ if (preference instanceof XTimePreference) {
+ dialogFragment = new XTimePreferenceFragment();
+ } else if (preference instanceof XDatePreference) {
+ dialogFragment = new XDatePreferenceFragment();
+ } else if (preference instanceof DragSortListPreference) {
+ dialogFragment = new DragSortListPreferenceFragment();
+ } else if (preference instanceof EditTextPreference) {
+ dialogFragment = MaterialEditTextPreferenceDialogFragment.newInstance(preference.getKey());
+ } else if (preference instanceof ListPreference) {
+ dialogFragment = MaterialListPreferenceDialogFragment.newInstance(preference.getKey());
+ } else if (preference instanceof MultiSelectListPreference) {
+ dialogFragment = MaterialMultiSelectListPreferenceDialogFragment.newInstance(preference.getKey());
+ } else {
+ super.onDisplayPreferenceDialog(preference);
+ return;
+ }
+ final Bundle bundle = new Bundle(1);
+ bundle.putString("key", preference.getKey());
+ dialogFragment.setArguments(bundle);
+ dialogFragment.setTargetFragment(this, 0);
+ if (getFragmentManager() != null) {
+ dialogFragment.show(getFragmentManager(), "androidx.preference.PreferenceFragment.DIALOG");
+ }
+ }
+
+ /**
+ * Keys of preferences which should print its values as a summary below the preference name.
+ */
+ protected Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ protected void onSharedPreferenceChanged(final Preference preference) {
+ // Nothing to do
+ }
+
+ public void setInputTypeFor(final String preferenceKey, final int editTypeFlags) {
+ final EditTextPreference textPreference = findPreference(preferenceKey);
+ if (textPreference != null) {
+ textPreference.setOnBindEditTextListener(editText -> editText.setInputType(editTypeFlags));
+ }
+ }
+
+ public void setNumericInputTypeWithRangeFor(final String preferenceKey, int min, int max, boolean allowEmpty) {
+ final EditTextPreference textPreference = findPreference(preferenceKey);
+ if (textPreference != null) {
+ textPreference.setOnBindEditTextListener(editText -> {
+ editText.setInputType(InputType.TYPE_CLASS_NUMBER);
+ editText.addTextChangedListener(new MinMaxTextWatcher(editText, min, max, allowEmpty));
+ editText.setSelection(editText.getText().length());
+ });
+ }
+ }
+
+ /**
+ * Reload the preferences in the current screen. This is needed when the user enters or exists a PreferenceScreen,
+ * otherwise the settings won't be reloaded by the {@link SharedPreferencesChangeHandler}, as the preferences return
+ * null, since they're not visible.
+ *
+ * @param sharedPreferences the {@link SharedPreferences} instance
+ * @param preferenceGroup the {@link PreferenceGroup} for which preferences will be reloaded
+ */
+ private void reloadPreferences(final SharedPreferences sharedPreferences, final PreferenceGroup preferenceGroup) {
+ if (preferenceGroup == null) {
+ return;
+ }
+
+ for (int i = 0; i < preferenceGroup.getPreferenceCount(); i++) {
+ final Preference preference = preferenceGroup.getPreference(i);
+
+ LOG.debug("Reloading {}", preference.getKey());
+
+ if (preference instanceof PreferenceCategory) {
+ reloadPreferences(sharedPreferences, (PreferenceCategory) preference);
+ continue;
+ }
+
+ sharedPreferencesChangeHandler.onSharedPreferenceChanged(sharedPreferences, preference.getKey());
+ }
+ }
+
+ /**
+ * Handler for preference changes, update UI accordingly (if device updates the preferences).
+ */
+ private class SharedPreferencesChangeHandler implements SharedPreferences.OnSharedPreferenceChangeListener {
+ @Override
+ public void onSharedPreferenceChanged(final SharedPreferences prefs, final String key) {
+ LOG.debug("Preference changed: {}", key);
+
+ if (key == null) {
+ LOG.warn("Preference null, ignoring");
+ return;
+ }
+
+ final Preference preference = findPreference(key);
+ if (preference == null) {
+ LOG.warn("Preference {} not found", key);
+
+ return;
+ }
+
+ if (preference instanceof SeekBarPreference) {
+ final SeekBarPreference seekBarPreference = (SeekBarPreference) preference;
+ seekBarPreference.setValue(prefs.getInt(key, seekBarPreference.getValue()));
+ } else if (preference instanceof SwitchPreferenceCompat) {
+ final SwitchPreferenceCompat switchPreference = (SwitchPreferenceCompat) preference;
+ switchPreference.setChecked(prefs.getBoolean(key, switchPreference.isChecked()));
+ } else if (preference instanceof ListPreference) {
+ final ListPreference listPreference = (ListPreference) preference;
+ listPreference.setValue(prefs.getString(key, listPreference.getValue()));
+ } else if (preference instanceof EditTextPreference) {
+ final EditTextPreference editTextPreference = (EditTextPreference) preference;
+ editTextPreference.setText(prefs.getString(key, editTextPreference.getText()));
+ } else if (preference instanceof PreferenceScreen) {
+ // Ignoring
+ } else {
+ LOG.warn("Unknown preference class {} for {}, ignoring", preference.getClass(), key);
+ }
+
+ if (getPreferenceKeysWithSummary().contains(key)) {
+ final String summary;
+
+ // For multi select preferences, let's set the summary to the values, comma-delimited
+ if (preference instanceof MultiSelectListPreference) {
+ final Set<String> prefSetValue = prefs.getStringSet(key, Collections.emptySet());
+ if (prefSetValue.isEmpty()) {
+ summary = requireContext().getString(R.string.not_set);
+ } else {
+ final MultiSelectListPreference multiSelectListPreference = (MultiSelectListPreference) preference;
+ final CharSequence[] entries = multiSelectListPreference.getEntries();
+ final CharSequence[] entryValues = multiSelectListPreference.getEntryValues();
+ final List<String> translatedEntries = new ArrayList<>();
+ for (int i = 0; i < entryValues.length; i++) {
+ if (prefSetValue.contains(entryValues[i].toString())) {
+ translatedEntries.add(entries[i].toString());
+ }
+ }
+ summary = TextUtils.join(", ", translatedEntries);
+ }
+ } else {
+ summary = prefs.getString(key, preference.getSummary() != null ? preference.getSummary().toString() : "");
+ }
+
+ if (preference.getSummaryProvider() != null) {
+ try {
+ preference.setSummary(summary);
+ } catch (final IllegalStateException e) {
+ LOG.error("Failed to set preference summary for {}", key, e);
+ }
+ }
+ }
+
+ AbstractPreferenceFragment.this.onSharedPreferenceChanged(preference);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractSettingsActivityV2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractSettingsActivityV2.java
new file mode 100644
index 0000000..5ea4b9d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AbstractSettingsActivityV2.java
@@ -0,0 +1,92 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.ActionBar;
+import androidx.fragment.app.Fragment;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceScreen;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public abstract class AbstractSettingsActivityV2 extends AbstractGBActivity implements
+ PreferenceFragmentCompat.OnPreferenceStartScreenCallback {
+
+ protected abstract String fragmentTag();
+ protected abstract PreferenceFragmentCompat newFragment();
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_device_settings);
+ if (savedInstanceState == null) {
+ Fragment fragment = getSupportFragmentManager().findFragmentByTag(fragmentTag());
+ if (fragment == null) {
+ fragment = newFragment();
+ }
+ getSupportFragmentManager()
+ .beginTransaction()
+ .replace(R.id.settings_container, fragment, fragmentTag())
+ .commit();
+ }
+ }
+
+ @Override
+ public boolean onPreferenceStartScreen(final PreferenceFragmentCompat caller, final PreferenceScreen preferenceScreen) {
+ final PreferenceFragmentCompat fragment = newFragment();
+ final Bundle args;
+ if (fragment.getArguments() != null) {
+ args = fragment.getArguments();
+ } else {
+ args = new Bundle();
+ }
+ args.putString(PreferenceFragmentCompat.ARG_PREFERENCE_ROOT, preferenceScreen.getKey());
+ fragment.setArguments(args);
+
+ getSupportFragmentManager()
+ .beginTransaction()
+ .replace(R.id.settings_container, fragment, preferenceScreen.getKey())
+ .addToBackStack(preferenceScreen.getKey())
+ .commit();
+
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // Simulate a back press, so that we don't actually exit the activity when
+ // in a nested PreferenceScreen
+ this.onBackPressed();
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ public void setActionBarTitle(final CharSequence title) {
+ final ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(title);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java
new file mode 100644
index 0000000..4acc2a7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesActivity.java
@@ -0,0 +1,472 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities;
+
+import android.app.DatePickerDialog;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.SparseBooleanArray;
+import android.util.TypedValue;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AbsListView;
+import android.widget.DatePicker;
+import android.widget.ListView;
+import android.widget.Toast;
+
+import androidx.core.content.FileProvider;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.adapter.ActivitySummariesAdapter;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
+import nodomain.freeyourgadget.gadgetbridge.util.ActivitySummaryUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+
+public class ActivitySummariesActivity extends AbstractListActivity<BaseActivitySummary> {
+ static final int ACTIVITY_FILTER = 1;
+ static final int ACTIVITY_DETAIL = 11;
+ HashMap<String, ActivityKind> activityKindMap = new HashMap<>(0);
+ int activityFilter = 0;
+ long dateFromFilter = 0;
+ long dateToFilter = 0;
+ long deviceFilter;
+ List<Long> itemsFilter;
+ String nameContainsFilter;
+ private GBDevice mGBDevice;
+ private BitSet selectedItems;
+ private SwipeRefreshLayout swipeLayout;
+ private ActionMode mActionMode;
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ switch (Objects.requireNonNull(action)) {
+ case GBDevice.ACTION_DEVICE_CHANGED:
+ GBDevice device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ mGBDevice = device;
+ if (device.isBusy()) {
+ swipeLayout.setRefreshing(true);
+ } else {
+ boolean wasBusy = swipeLayout.isRefreshing();
+ swipeLayout.setRefreshing(false);
+ if (wasBusy) {
+ refresh();
+ }
+ }
+ break;
+ }
+ }
+ };
+ private int subtrackDashboard = 0;
+
+ public static int getBackgroundColor(Context context) {
+ TypedValue typedValue = new TypedValue();
+ Resources.Theme theme = context.getTheme();
+ theme.resolveAttribute(R.attr.sports_activity_summary_background, typedValue, true);
+ return typedValue.data;
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+
+ getMenuInflater().inflate(R.menu.activity_list_menu, menu);
+
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button, close drawer if open, otherwise exit
+ finish();
+ return true;
+ } else if (itemId == R.id.activity_action_manage_timestamp) {
+ resetFetchTimestampToChosenDate();
+ return true;
+ } else if (itemId == R.id.activity_action_filter) {
+ runFilterActivity();
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
+ super.onActivityResult(requestCode, resultCode, resultData);
+ if (requestCode == ACTIVITY_FILTER && resultData != null) {
+ Bundle bundle = resultData.getExtras();
+ activityFilter = bundle.getInt("activityFilter", 0);
+ dateFromFilter = bundle.getLong("dateFromFilter", 0);
+ dateToFilter = bundle.getLong("dateToFilter", 0);
+ deviceFilter = bundle.getLong("deviceFilter", 0);
+ nameContainsFilter = bundle.getString("nameContainsFilter");
+ itemsFilter = (List<Long>) bundle.getSerializable("itemsFilter");
+ setActivityKindFilter(activityFilter);
+ setDateFromFilter(dateFromFilter);
+ setDateToFilter(dateToFilter);
+ setNameContainsFilter(nameContainsFilter);
+ setItemsFilter(itemsFilter);
+ setDeviceFilter(deviceFilter);
+ refresh();
+ }
+ if (requestCode == ACTIVITY_DETAIL) {
+ refresh();
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ mGBDevice = extras.getParcelable(GBDevice.EXTRA_DEVICE);
+ } else {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+ deviceFilter = getDeviceId(mGBDevice);
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(GBDevice.ACTION_DEVICE_CHANGED);
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filterLocal);
+
+ super.onCreate(savedInstanceState);
+
+ ActivitySummariesAdapter activitySummariesAdapter = new ActivitySummariesAdapter(this, mGBDevice, activityFilter, dateFromFilter, dateToFilter, nameContainsFilter, deviceFilter, itemsFilter);
+ selectedItems = activitySummariesAdapter.getSelectedItems();
+
+ activitySummariesAdapter.setOnItemClickListener(position -> {
+ if (!selectedItems.isEmpty()) {
+ selectedItems.set(position, !selectedItems.get(position));
+ activitySummariesAdapter.notifyItemChanged(position);
+ if (!selectedItems.isEmpty()) {
+ startActionMode();
+ } else {
+ stopActionMode();
+ }
+ return;
+ }
+ if (position == 0) return; // item 0 is empty for dashboard
+ ActivitySummary summary = activitySummariesAdapter.getItem(position);
+ if (summary != null) {
+ try {
+ showActivityDetail(position);
+ } catch (Exception e) {
+ GB.toast(getApplicationContext(), "Unable to display Activity Detail, maybe the activity is not available yet: " + e.getMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+ });
+ activitySummariesAdapter.setOnItemLongClickListener(position -> {
+ selectedItems.set(position, !selectedItems.get(position));
+ activitySummariesAdapter.notifyItemChanged(position);
+
+ if (!selectedItems.isEmpty()) {
+ startActionMode();
+ } else {
+ stopActionMode();
+ }
+ });
+ setItemAdapter(activitySummariesAdapter);
+
+ swipeLayout = findViewById(R.id.list_activity_swipe_layout);
+ swipeLayout.setOnRefreshListener(this::fetchTrackData);
+
+ FloatingActionButton fab = findViewById(R.id.fab);
+ fab.setOnClickListener(v -> fetchTrackData());
+
+ activityKindMap = fillKindMap();
+ }
+
+ private void stopActionMode() {
+ if (mActionMode != null) {
+ mActionMode.finish();
+ mActionMode = null;
+ }
+ }
+
+ private void startActionMode() {
+ int[] numSelected = new int[]{0};
+ for (int i = 0; i < selectedItems.length(); i++) {
+ if (selectedItems.get(i)) {
+ numSelected[0]++;
+ }
+ }
+ if (mActionMode != null) {
+ // already in action mode
+ mActionMode.setTitle(getString(R.string.number_selected_items, numSelected[0]));
+ return;
+ }
+
+ mActionMode = startActionMode(new ActionMode.Callback() {
+ @Override
+ public boolean onCreateActionMode(final ActionMode mode, final Menu menu) {
+ mode.setTitle(getString(R.string.number_selected_items, numSelected[0]));
+
+ getMenuInflater().inflate(R.menu.activity_list_context_menu, menu);
+ findViewById(R.id.fab).setVisibility(View.INVISIBLE);
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode mode, final Menu menu) {
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ boolean processed = false;
+ final int itemId = menuItem.getItemId();
+ if (itemId == R.id.activity_action_delete) {
+ final List<BaseActivitySummary> toDelete = new ArrayList<>();
+ for (int i = 0; i < selectedItems.length(); i++) {
+ if (selectedItems.get(i)) {
+ toDelete.add(getItemAdapter().getItem(i));
+ }
+ }
+
+ new MaterialAlertDialogBuilder(ActivitySummariesActivity.this)
+ .setTitle(ActivitySummariesActivity.this.getString(R.string.sports_activity_confirm_delete_title, toDelete.size()))
+ .setMessage(ActivitySummariesActivity.this.getString(R.string.sports_activity_confirm_delete_description, toDelete.size()))
+ .setIcon(R.drawable.ic_delete_forever)
+ .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
+ public void onClick(final DialogInterface dialog, final int whichButton) {
+ deleteItems(toDelete);
+ }
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+
+ processed = true;
+ } else if (itemId == R.id.activity_action_export) {
+ final List<String> paths = new ArrayList<>();
+
+ for (int i = 0; i < selectedItems.length(); i++) {
+ if (selectedItems.get(i)) {
+ BaseActivitySummary summary = getItemAdapter().getItem(i);
+ if (summary != null) {
+ File gpxTrack = ActivitySummaryUtils.getGpxFile(summary);
+ if (gpxTrack != null) {
+ paths.add(gpxTrack.getPath());
+ }
+ }
+ }
+ }
+ shareMultiple(paths);
+ processed = true;
+ } else if (itemId == R.id.activity_action_select_all) {
+ for (int i = 1; i < getItemAdapter().getItemCount() - 1; i++) {
+ if (!selectedItems.get(i)) {
+ numSelected[0]++;
+ selectedItems.set(i, true);
+ getItemAdapter().notifyItemChanged(i);
+ }
+ }
+ actionMode.setTitle(getString(R.string.number_selected_items, numSelected[0]));
+ return true; //don't finish actionMode in this case!
+ } else if (itemId == R.id.activity_action_addto_filter) {
+ final List<Long> toFilter = new ArrayList<>();
+ for (int i = 0; i < selectedItems.length(); i++) {
+ if (selectedItems.get(i)) {
+ BaseActivitySummary summary = getItemAdapter().getItem(i);
+ if (summary != null && summary.getId() != null) {
+ Long id = summary.getId();
+ toFilter.add(id);
+ }
+ }
+ }
+ itemsFilter = toFilter;
+ setItemsFilter(itemsFilter);
+ refresh();
+
+ processed = true;
+ }
+ actionMode.finish();
+ mActionMode = null;
+ return processed;
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode mode) {
+ mActionMode = null;
+ for (int i = 0; i < selectedItems.length(); i++) {
+ if (selectedItems.get(i)) {
+ selectedItems.set(i, false);
+ getItemAdapter().notifyItemChanged(i);
+ }
+ }
+ findViewById(R.id.fab).setVisibility(View.VISIBLE);
+ }
+ });
+ }
+
+ private LinkedHashMap<String, ActivityKind> fillKindMap() {
+ LinkedHashMap<String, ActivityKind> newMap = new LinkedHashMap<>(0); //reset
+
+ newMap.put(getString(R.string.activity_summaries_all_activities), ActivityKind.UNKNOWN);
+ for (BaseActivitySummary item : getItemAdapter().getItems()) {
+ String activityName = ActivityKind.fromCode(item.getActivityKind()).getLabel(this);
+ if (!newMap.containsKey(activityName) && item.getActivityKind() != 0) {
+ newMap.put(activityName, ActivityKind.fromCode(item.getActivityKind()));
+ }
+ }
+ return newMap;
+ }
+
+ public void resetFetchTimestampToChosenDate() {
+ final Calendar currentDate = Calendar.getInstance();
+ new DatePickerDialog(this, new DatePickerDialog.OnDateSetListener() {
+ @Override
+ public void onDateSet(DatePicker view, int year, int monthOfYear, int dayOfMonth) {
+ Calendar date = Calendar.getInstance();
+ date.set(year, monthOfYear, dayOfMonth);
+
+ long timestamp = date.getTimeInMillis() - 1000;
+ SharedPreferences.Editor editor = GBApplication.getDeviceSpecificSharedPrefs(mGBDevice.getAddress()).edit();
+ editor.remove("lastSportsActivityTimeMillis"); //FIXME: key reconstruction is BAD
+ editor.putLong("lastSportsActivityTimeMillis", timestamp);
+ editor.apply();
+ }
+ }, currentDate.get(Calendar.YEAR), currentDate.get(Calendar.MONTH), currentDate.get(Calendar.DATE)).show();
+ }
+
+ @Override
+ protected void onDestroy() {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
+ super.onDestroy();
+ }
+
+ private void deleteItems(List<BaseActivitySummary> items) {
+ for (BaseActivitySummary item : items) {
+ try {
+ item.delete();
+ } catch (Exception e) {
+ //pass delete error
+ }
+ }
+ // Adapter is fully reloaded after refresh
+ refresh();
+ }
+
+ private void fetchTrackData() {
+ if (mGBDevice.isInitialized() && !mGBDevice.isBusy()) {
+ swipeLayout.setRefreshing(true);
+ GBApplication.deviceService(mGBDevice).onFetchRecordedData(RecordedDataTypes.TYPE_GPS_TRACKS);
+ } else {
+ swipeLayout.setRefreshing(false);
+ if (!mGBDevice.isInitialized()) {
+ GB.toast(this, getString(R.string.device_not_connected), Toast.LENGTH_SHORT, GB.ERROR);
+ }
+ }
+ }
+
+ private void shareMultiple(List<String> paths) {
+
+ ArrayList<Uri> uris = new ArrayList<>();
+ for (String path : paths) {
+ File file = new File(path);
+ uris.add(FileProvider.getUriForFile(this, getApplicationContext().getPackageName() + ".screenshot_provider", file));
+ }
+
+ if (!uris.isEmpty()) {
+ final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
+ intent.setType("application/gpx+xml");
+ intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
+ startActivity(Intent.createChooser(intent, "SHARE"));
+ } else {
+ GB.toast(this, "No selected activity contains a GPX track to share", Toast.LENGTH_SHORT, GB.ERROR);
+ }
+ }
+
+ private void showActivityDetail(int position) {
+ Intent ActivitySummaryDetailIntent = new Intent(this, ActivitySummaryDetail.class);
+ Bundle bundle = new Bundle();
+
+ bundle.putInt("position", position);
+ bundle.putSerializable("activityKindMap", activityKindMap);
+ bundle.putSerializable("itemsFilter", (Serializable) itemsFilter);
+ bundle.putInt("activityFilter", activityFilter);
+ bundle.putLong("dateFromFilter", dateFromFilter);
+ bundle.putLong("dateToFilter", dateToFilter);
+ bundle.putLong("deviceFilter", deviceFilter);
+ bundle.putLong("initial_deviceFilter", getDeviceId(mGBDevice));
+ bundle.putString("nameContainsFilter", nameContainsFilter);
+ ActivitySummaryDetailIntent.putExtras(bundle);
+
+ ActivitySummaryDetailIntent.putExtra(GBDevice.EXTRA_DEVICE, mGBDevice);
+ startActivityForResult(ActivitySummaryDetailIntent, ACTIVITY_DETAIL);
+ }
+
+ private void runFilterActivity() {
+ Intent filterIntent = new Intent(this, ActivitySummariesFilter.class);
+ Bundle bundle = new Bundle();
+ bundle.putSerializable("activityKindMap", activityKindMap);
+ bundle.putSerializable("itemsFilter", (Serializable) itemsFilter);
+ bundle.putInt("activityFilter", activityFilter);
+ bundle.putLong("dateFromFilter", dateFromFilter);
+ bundle.putLong("dateToFilter", dateToFilter);
+ bundle.putLong("deviceFilter", deviceFilter);
+ bundle.putLong("initial_deviceFilter", getDeviceId(mGBDevice));
+ bundle.putString("nameContainsFilter", nameContainsFilter);
+ filterIntent.putExtras(bundle);
+ startActivityForResult(filterIntent, ACTIVITY_FILTER);
+ }
+
+ private long getDeviceId(GBDevice device) {
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ Device dbDevice = DBHelper.findDevice(device, handler.getDaoSession());
+ return dbDevice.getId();
+ } catch (Exception e) {
+ }
+ return 0;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesChartFragment.java
new file mode 100644
index 0000000..256a499
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesChartFragment.java
@@ -0,0 +1,313 @@
+/* Copyright (C) 2020-2024 Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.Legend;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.AbstractActivityChartFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsData;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsHost;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.DefaultChartsData;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.SampleXLabelFormatter;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.TimestampTranslation;
+import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+
+
+public class ActivitySummariesChartFragment extends AbstractActivityChartFragment<ChartsData> {
+ private static final Logger LOG = LoggerFactory.getLogger(ActivitySummariesChartFragment.class);
+
+ private LineChart mChart;
+ private View view;
+
+ // If a track file is being used (takes precedence over activity data)
+ private File trackFile;
+
+ // If activity data is being used
+ private GBDevice gbDevice;
+ private int startTime;
+ private int endTime;
+
+ public void setDateAndGetData(@Nullable File trackFile, GBDevice gbDevice, long startTime, long endTime) {
+ this.trackFile = trackFile;
+ this.startTime = (int) startTime;
+ this.endTime = (int) endTime;
+ this.gbDevice = gbDevice;
+ if (this.view != null) {
+ createLocalRefreshTask("getting hr and activity", getActivity()).execute();
+ }
+ }
+
+ protected RefreshTask createLocalRefreshTask(String task, Context context) {
+ return new RefreshTask(task, context);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_charts, container, false);
+ mChart = rootView.findViewById(R.id.activitysleepchart);
+ return rootView;
+ }
+
+ @Override
+ public void onViewCreated(final View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ init();
+ this.view = view;
+ if (this.trackFile != null || this.gbDevice != null) {
+ setupChart();
+ createLocalRefreshTask("getting hr and activity", getActivity()).execute();
+ }
+ }
+
+
+ @Override
+ public String getTitle() {
+ return "";
+ }
+
+ private void setupChart() {
+ mChart.setBackgroundColor(BACKGROUND_COLOR);
+ mChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ configureBarLineChartDefaults(mChart);
+
+ XAxis x = mChart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setDrawLimitLinesBehindData(true);
+
+ YAxis y = mChart.getAxisLeft();
+ y.setDrawGridLines(false);
+// y.setDrawLabels(false);
+ // TODO: make fixed max value optional
+ y.setAxisMaximum(1f);
+ y.setAxisMinimum(0);
+ y.setDrawTopYLabelEntry(false);
+ y.setTextColor(CHART_TEXT_COLOR);
+
+// y.setLabelCount(5);
+ y.setEnabled(true);
+
+ YAxis yAxisRight = mChart.getAxisRight();
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setEnabled(supportsHeartrate(gbDevice));
+ yAxisRight.setDrawLabels(true);
+ yAxisRight.setDrawTopYLabelEntry(true);
+ yAxisRight.setTextColor(CHART_TEXT_COLOR);
+ yAxisRight.setAxisMaximum(HeartRateUtils.getInstance().getMaxHeartRate());
+ yAxisRight.setAxisMinimum(HeartRateUtils.getInstance().getMinHeartRate());
+
+ }
+
+ @Override
+ protected List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ return getAllSamples(db, device, tsFrom, tsTo);
+ }
+
+ @Override
+ protected List<? extends ActivitySample> getSamplesHighRes(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ return getAllSamplesHighRes(db, device, tsFrom, tsTo);
+ }
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {
+ List<LegendEntry> legendEntries = new ArrayList<>(5);
+
+ LegendEntry activityEntry = new LegendEntry();
+ activityEntry.label = akActivity.label;
+ activityEntry.formColor = akActivity.color;
+ legendEntries.add(activityEntry);
+
+ if (supportsHeartrate(gbDevice)) {
+ LegendEntry hrEntry = new LegendEntry();
+ hrEntry.label = HEARTRATE_LABEL;
+ hrEntry.formColor = HEARTRATE_COLOR;
+ legendEntries.add(hrEntry);
+ }
+
+ chart.getLegend().setCustom(legendEntries);
+ chart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ chart.getLegend().setWordWrapEnabled(true);
+ chart.getLegend().setHorizontalAlignment(Legend.LegendHorizontalAlignment.CENTER);
+ }
+
+ @Override
+ protected ChartsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ return null;
+ }
+
+ @Override
+ protected void renderCharts() {
+ }
+
+ protected Entry createLineEntry(float value, int xValue) {
+ return new Entry(xValue, value);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(ChartsData chartsData) {
+ }
+
+ public class RefreshTask extends DBAccess {
+
+ public RefreshTask(String task, Context context) {
+ super(task, context);
+ }
+
+ @Override
+ protected void doInBackground(DBHandler handler) {
+ final DefaultChartsData<?> dcd;
+ final DefaultChartsData<LineData> activitySamplesData = buildChartFromSamples(handler);
+
+ if (trackFile != null) {
+ final List<ActivityPoint> activityPoints = ActivitySummariesGpsFragment.getActivityPoints(trackFile)
+ .stream()
+ .filter(ap -> ap.getHeartRate() > 0)
+ .collect(Collectors.toList());
+
+ if (!activityPoints.isEmpty()) {
+ dcd = buildHeartRateChart(activityPoints, activitySamplesData);
+ } else {
+ dcd = activitySamplesData;
+ }
+ } else {
+ dcd = activitySamplesData;
+ }
+
+ if (dcd != null) {
+ mChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
+ mChart.getXAxis().setValueFormatter(dcd.getXValueFormatter());
+ mChart.setData((LineData) dcd.getData());
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Object o) {
+ mChart.invalidate();
+ }
+
+ private DefaultChartsData<LineData> buildChartFromSamples(DBHandler handler) {
+ final List<? extends ActivitySample> samples = getAllSamples(handler, gbDevice, startTime, endTime);
+ final List<? extends ActivitySample> highResSamples = getAllSamplesHighRes(handler, gbDevice, startTime, endTime);
+
+ try {
+ if (highResSamples == null)
+ return refresh(gbDevice, samples);
+ return refresh(gbDevice, samples, highResSamples);
+ } catch (Exception e) {
+ LOG.error("Unable to get charts data right now", e);
+ }
+
+ return null;
+ }
+
+ private DefaultChartsData<LineData> buildHeartRateChart(final List<ActivityPoint> activityPoints,
+ final DefaultChartsData<LineData> activitySamplesData) {
+ // If we have data from activity samples, we need to use the same TimestampTranslation so
+ // that the HR chart is aligned
+ // This is not ideal...
+ final TimestampTranslation tsTranslation;
+ if (activitySamplesData != null) {
+ final ValueFormatter xValueFormatter = activitySamplesData.getXValueFormatter();
+ if (xValueFormatter instanceof SampleXLabelFormatter) {
+ tsTranslation = ((SampleXLabelFormatter) xValueFormatter).getTsTranslation();
+ } else {
+ LOG.error("Unable to get TimestampTranslation from x value formatter - class changed?");
+ tsTranslation = new TimestampTranslation();
+ }
+ } else {
+ tsTranslation = new TimestampTranslation();
+ }
+
+ final List<Entry> heartRateEntries = new ArrayList<>(activityPoints.size());
+ final List<ILineDataSet> heartRateDataSets = new ArrayList<>();
+ int lastTsShorten = 0;
+ for (final ActivityPoint activityPoint : activityPoints) {
+ int tsShorten = tsTranslation.shorten((int) (activityPoint.getTime().getTime() / 1000));
+ if (lastTsShorten == 0 || (tsShorten - lastTsShorten) <= 60 * HeartRateUtils.MAX_HR_MEASUREMENTS_GAP_MINUTES) {
+ heartRateEntries.add(new Entry(tsShorten, activityPoint.getHeartRate()));
+ } else {
+ if (!heartRateEntries.isEmpty()) {
+ List<Entry> clone = new ArrayList<>(heartRateEntries.size());
+ clone.addAll(heartRateEntries);
+ heartRateDataSets.add(createHeartrateSet(clone, "Heart Rate"));
+ heartRateEntries.clear();
+ }
+ }
+ lastTsShorten = tsShorten;
+ heartRateEntries.add(new Entry(tsShorten, activityPoint.getHeartRate()));
+ }
+ if (!heartRateEntries.isEmpty()) {
+ heartRateDataSets.add(createHeartrateSet(heartRateEntries, "Heart Rate"));
+ }
+
+ if (activitySamplesData != null) {
+ // if we have activity samples, replace the heart rate dataset
+ LineData data = activitySamplesData.getData();
+ List<ILineDataSet> dataSets = data.getDataSets();
+ for (final ILineDataSet dataSet : dataSets) {
+ if ("Heart Rate".equals(dataSet.getLabel())) {
+ dataSets.remove(dataSet);
+ dataSets.addAll(heartRateDataSets);
+ return activitySamplesData;
+ }
+ }
+ // We failed to find a heart rate dataset. We can't append ours, or it will crash
+ //dataSets.add(heartRateSet);
+ return activitySamplesData;
+ } else {
+ final LineData lineData = new LineData(heartRateDataSets);
+ final ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm");
+ return new DefaultChartsData<>(lineData, xValueFormatter);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesFilter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesFilter.java
new file mode 100644
index 0000000..17521a4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesFilter.java
@@ -0,0 +1,481 @@
+/* Copyright (C) 2020-2024 Daniel Dakhno, Petr Van臎k
+
+ 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.activities;
+
+import android.app.DatePickerDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Color;
+import android.graphics.LightingColorFilter;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.Pair;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.DatePicker;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.adapter.SpinnerWithIconAdapter;
+import nodomain.freeyourgadget.gadgetbridge.adapter.SpinnerWithIconItem;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+
+public class ActivitySummariesFilter extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(ActivitySummariesFilter.class);
+ private static final String DATE_FILTER_FROM = "dateFromFilter";
+ private static final String DATE_FILTER_TO = "dateToFilter";
+ public static long ALL_DEVICES = 999;
+ int activityFilter = 0;
+ long dateFromFilter = 0;
+ long dateToFilter = 0;
+ String nameContainsFilter;
+ HashMap<String, ActivityKind> activityKindMap = new HashMap<>(1);
+ List<Long> itemsFilter;
+ long deviceFilter;
+ long initial_deviceFilter;
+ int BACKGROUND_COLOR;
+ LinkedHashMap<String, Pair<Long, Integer>> allDevices;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Bundle bundle = this.getIntent().getExtras();
+
+ activityKindMap = (HashMap<String, ActivityKind>) bundle.getSerializable("activityKindMap");
+ itemsFilter = (List<Long>) bundle.getSerializable("itemsFilter");
+ activityFilter = bundle.getInt("activityFilter", 0);
+ dateFromFilter = bundle.getLong("dateFromFilter", 0);
+ dateToFilter = bundle.getLong("dateToFilter", 0);
+ initial_deviceFilter = bundle.getLong("initial_deviceFilter", 0);
+ deviceFilter = bundle.getLong("deviceFilter", 0);
+ nameContainsFilter = bundle.getString("nameContainsFilter");
+
+ Context appContext = this.getApplicationContext();
+ if (appContext instanceof GBApplication) {
+ setContentView(R.layout.sport_activity_filter);
+ }
+ BACKGROUND_COLOR = GBApplication.getBackgroundColor(appContext);
+
+ allDevices = getAllDevices(appContext);
+
+ //device filter spinner
+ final Spinner deviceFilterSpinner = findViewById(R.id.select_device);
+ ArrayList<SpinnerWithIconItem> filterDevicesArray = new ArrayList<>();
+ for (Map.Entry<String, Pair<Long, Integer>> item : allDevices.entrySet()) {
+ filterDevicesArray.add(new SpinnerWithIconItem(item.getKey(), item.getValue().first, item.getValue().second));
+ }
+ final SpinnerWithIconAdapter filterDevicesAdapter = new SpinnerWithIconAdapter(this,
+ R.layout.spinner_with_image_layout, R.id.spinner_item_text, filterDevicesArray);
+ deviceFilterSpinner.setAdapter(filterDevicesAdapter);
+ deviceFilterSpinner.setSelection(filterDevicesAdapter.getItemPositionForSelection(getDeviceById(deviceFilter)));
+ addListenerOnSpinnerDeviceSelection();
+
+ //Kind filter spinner - assign data, set selected item...
+ final Spinner filterKindSpinner = findViewById(R.id.select_kind);
+ ArrayList<SpinnerWithIconItem> kindArray = new ArrayList<>();
+
+ for (Map.Entry<String, ActivityKind> item : activityKindMap.entrySet()) {
+ if (item.getValue() == ActivityKind.UNKNOWN) continue; //do not put here All devices, but we do need them in the array
+ kindArray.add(new SpinnerWithIconItem(item.getKey(), (long) item.getValue().getCode(), item.getValue().getIcon()));
+ }
+
+ kindArray.sort((o1, o2) -> o1.getText().compareToIgnoreCase(o2.getText()));
+
+ //ensure that all items is always first in the list, this is an issue on old android
+ SpinnerWithIconItem allActivities = new SpinnerWithIconItem(getString(R.string.activity_summaries_all_activities), (long) ActivityKind.UNKNOWN.getCode(), ActivityKind.UNKNOWN.getIcon());
+ kindArray.add(0, allActivities);
+
+ SpinnerWithIconAdapter adapter = new SpinnerWithIconAdapter(this,
+ R.layout.spinner_with_image_layout, R.id.spinner_item_text, kindArray);
+
+ SpinnerWithIconItem selectedActivity = getKindByValue(activityFilter);
+ int selectedPosition = adapter.getItemPositionForSelection(selectedActivity);
+
+ filterKindSpinner.setAdapter(adapter);
+ filterKindSpinner.setSelection(selectedPosition);
+ addListenerOnSpinnerKindSelection();
+
+
+ //quick date filter selection
+ final Spinner quick_filter_period_select = findViewById(R.id.quick_filter_period_select);
+
+ ArrayList<String> activity_filter_quick_filter_period_items = new ArrayList<>(Arrays.asList(getResources().getStringArray(R.array.activity_filter_quick_filter_period_items)));
+ ArrayAdapter<String> filterDateAdapter = new ArrayAdapter<>(this,
+ R.layout.simple_spinner_item_themed, activity_filter_quick_filter_period_items);
+ quick_filter_period_select.setAdapter(filterDateAdapter);
+ addListenerOnQuickFilterSelection();
+
+ //set current values coming from parent
+ update_filter_fields();
+
+ final LinearLayout filterfrom = findViewById(R.id.filterfrom);
+ final LinearLayout filterto = findViewById(R.id.filterto);
+ final EditText nameContainsFilterdata = findViewById(R.id.textViewNameData);
+
+ final Button reset_filter_button = findViewById(R.id.reset_filter_button);
+ final Button apply_filter_button = findViewById(R.id.apply_filter_button);
+
+ nameContainsFilterdata.addTextChangedListener(new TextWatcher() {
+
+ public void afterTextChanged(Editable s) {
+
+ }
+
+ public void beforeTextChanged(CharSequence s, int start,
+ int count, int after) {
+ }
+
+ public void onTextChanged(CharSequence s, int start,
+ int before, int count) {
+ nameContainsFilter = s.toString();
+ update_filter_fields();
+ }
+ });
+
+
+ reset_filter_button.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ activityFilter = 0;
+ dateFromFilter = 0;
+ dateToFilter = 0;
+ nameContainsFilter = "";
+ filterKindSpinner.setSelection(0);
+ itemsFilter = null;
+ deviceFilterSpinner.setSelection(filterDevicesAdapter.getItemPositionForSelection(getDeviceById(initial_deviceFilter)));
+ quick_filter_period_select.setSelection(0);
+ update_filter_fields();
+ }
+ });
+
+ apply_filter_button.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ String text = nameContainsFilterdata.getText().toString();
+ if (!text.isEmpty()) {
+ nameContainsFilter = text;
+ }
+ Intent intent = new Intent();
+ Bundle bundle = new Bundle();
+ bundle.putInt("activityFilter", activityFilter);
+ bundle.putSerializable("itemsFilter", (Serializable) itemsFilter);
+ bundle.putLong("dateFromFilter", dateFromFilter);
+ bundle.putLong("dateToFilter", dateToFilter);
+ bundle.putLong("deviceFilter", deviceFilter);
+ bundle.putString("nameContainsFilter", nameContainsFilter);
+ intent.putExtras(bundle);
+ setResult(1, intent);
+ finish();
+ }
+ });
+
+
+ filterfrom.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ getDate(DATE_FILTER_FROM, dateFromFilter);
+ quick_filter_period_select.setSelection(0);
+
+
+ }
+ });
+
+ filterto.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ getDate(DATE_FILTER_TO, dateToFilter);
+ quick_filter_period_select.setSelection(0);
+ }
+ });
+
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void addListenerOnSpinnerDeviceSelection() {
+ Spinner spinner = findViewById(R.id.select_device);
+ spinner.setOnItemSelectedListener(new CustomOnDeviceSelectedListener());
+ }
+
+ public void addListenerOnSpinnerKindSelection() {
+ Spinner spinner = findViewById(R.id.select_kind);
+ spinner.setOnItemSelectedListener(new CustomOnKindSelectedListener());
+ }
+
+ public void addListenerOnQuickFilterSelection() {
+ Spinner spinner = findViewById(R.id.quick_filter_period_select);
+ spinner.setOnItemSelectedListener(new CustomQuickFilterSelectionListener());
+ }
+
+ public void update_filter_fields() {
+ TextView filterDateFromDataView = findViewById(R.id.textViewFromData);
+ TextView filterDateToDataView = findViewById(R.id.textViewToData);
+ Button reset_filter_button = findViewById(R.id.reset_filter_button);
+ TextView textViewItemsData = findViewById(R.id.textViewItemsData);
+
+ final EditText nameContainsFilterdata = findViewById(R.id.textViewNameData);
+
+ if (dateFromFilter > 0) {
+ filterDateFromDataView.setText(DateTimeUtils.formatDate(new Date(dateFromFilter)));
+ } else {
+ filterDateFromDataView.setText("");
+ }
+
+ if (dateToFilter > 0) {
+ filterDateToDataView.setText(DateTimeUtils.formatDate(new Date(dateToFilter)));
+ } else {
+ filterDateToDataView.setText("");
+ }
+
+ if (dateToFilter < dateFromFilter && dateToFilter > 0) {
+ filterDateFromDataView.setBackgroundColor(Color.RED);
+ filterDateToDataView.setBackgroundColor(Color.RED);
+ } else {
+ filterDateFromDataView.setBackgroundColor(BACKGROUND_COLOR);
+ filterDateToDataView.setBackgroundColor(BACKGROUND_COLOR);
+ }
+
+ if (itemsFilter != null) {
+ textViewItemsData.setText(String.format("%s", itemsFilter.size()));
+ } else {
+ textViewItemsData.setText("0");
+ }
+
+ if (nameContainsFilter != null && !nameContainsFilter.equals(nameContainsFilterdata.getText().toString())) {
+ nameContainsFilterdata.setText(nameContainsFilter);
+ }
+ if (dateToFilter != 0 || dateFromFilter != 0 || activityFilter != 0 || nameContainsFilterdata.length() > 0 || itemsFilter != null || deviceFilter != initial_deviceFilter) {
+ reset_filter_button.getBackground().clearColorFilter();
+
+ } else {
+ reset_filter_button.getBackground().setColorFilter(new LightingColorFilter(0x0, 0x00888888));
+ }
+ }
+
+ public void getDate(final String filter, long currentDatemillis) {
+ Calendar currentDate = Calendar.getInstance();
+ if (currentDatemillis > 0) {
+ currentDate = GregorianCalendar.getInstance();
+ currentDate.setTimeInMillis(currentDatemillis);
+ }
+
+ new DatePickerDialog(this, new DatePickerDialog.OnDateSetListener() {
+ @Override
+ public void onDateSet(DatePicker view, int year, int monthOfYear, int dayOfMonth) {
+ Calendar date = Calendar.getInstance();
+
+ if (filter.equals(DATE_FILTER_FROM)) {
+ date.set(year, monthOfYear, dayOfMonth, 0, 0);
+ dateFromFilter = date.getTimeInMillis();
+ } else {
+ date.set(year, monthOfYear, dayOfMonth, 23, 59);
+ dateToFilter = date.getTimeInMillis();
+ }
+ update_filter_fields();
+ }
+ }, currentDate.get(Calendar.YEAR), currentDate.get(Calendar.MONTH), currentDate.get(Calendar.DATE)).show();
+ }
+
+ private void setTimePeriodFilter(String selection) {
+ Calendar date = Calendar.getInstance();
+ date.set(Calendar.HOUR_OF_DAY, 0);
+ date.set(Calendar.MINUTE, 0);
+ date.set(Calendar.SECOND, 0);
+ long firstdate;
+ long lastdate;
+
+ switch (selection) {
+ case "thisweek":
+ date.set(Calendar.DAY_OF_WEEK, date.getFirstDayOfWeek());
+ firstdate = date.getTimeInMillis();
+ lastdate = Calendar.getInstance().getTimeInMillis();
+ break;
+ case "thismonth":
+ date.set(Calendar.DAY_OF_MONTH, 1);
+ firstdate = date.getTimeInMillis();
+ lastdate = Calendar.getInstance().getTimeInMillis();
+ break;
+ case "lastweek":
+ int i = date.get(Calendar.DAY_OF_WEEK) - date.getFirstDayOfWeek();
+ date.add(Calendar.DATE, -i - 7);
+ firstdate = date.getTimeInMillis();
+ date.add(Calendar.DATE, 6);
+ lastdate = date.getTimeInMillis();
+ break;
+ case "lastmonth":
+ date.set(Calendar.DATE, 1);
+ date.add(Calendar.DAY_OF_MONTH, -1);
+ lastdate = date.getTimeInMillis();
+ date.set(Calendar.DATE, 1);
+ firstdate = date.getTimeInMillis();
+ break;
+ case "7days":
+ date.add(Calendar.DATE, -7);
+ firstdate = date.getTimeInMillis();
+ lastdate = Calendar.getInstance().getTimeInMillis();
+ break;
+ case "30days":
+ date.add(Calendar.DATE, -30);
+ firstdate = date.getTimeInMillis();
+ lastdate = Calendar.getInstance().getTimeInMillis();
+ break;
+ default:
+ return;
+ }
+ dateFromFilter = firstdate;
+ dateToFilter = lastdate;
+ update_filter_fields();
+ }
+
+ public LinkedHashMap<String, Pair<Long, Integer>> getAllDevices(Context appContext) {
+ DaoSession daoSession;
+ GBApplication gbApp = (GBApplication) appContext;
+ LinkedHashMap<String, Pair<Long, Integer>> newMap = new LinkedHashMap<>(1);
+ List<? extends GBDevice> devices = gbApp.getDeviceManager().getDevices();
+ newMap.put(getString(R.string.activity_summaries_all_devices), new Pair<>(ALL_DEVICES, R.drawable.ic_device_default_disabled));
+
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ daoSession = handler.getDaoSession();
+ for (GBDevice device : devices) {
+ DeviceCoordinator coordinator = device.getType().getDeviceCoordinator();
+ Device dbDevice = DBHelper.findDevice(device, daoSession);
+ int icon = device.getEnabledDisabledIconResource();
+ if (dbDevice != null && coordinator != null
+ && coordinator.supportsActivityTracks()
+ && !newMap.containsKey(device.getAliasOrName())) {
+ newMap.put(device.getAliasOrName(), new Pair<>(dbDevice.getId(), icon));
+ }
+ }
+
+ } catch (Exception e) {
+ LOG.debug("Error getting list of all devices: " + e);
+ }
+ return newMap;
+ }
+
+ public SpinnerWithIconItem getKindByValue(int value) {
+ for (Map.Entry<String, ActivityKind> entry : activityKindMap.entrySet()) {
+ if (value == entry.getValue().getCode()) {
+ return new SpinnerWithIconItem(
+ entry.getKey(),
+ (long) entry.getValue().getCode(),
+ entry.getValue().getIcon()
+ );
+ }
+ }
+ return null;
+ }
+
+ public SpinnerWithIconItem getDeviceById(long id) {
+ for (Map.Entry<String, Pair<Long, Integer>> device : allDevices.entrySet()) {
+ if (Objects.equals(id, device.getValue().first)) {
+ return new SpinnerWithIconItem(device.getKey(),
+ device.getValue().first,
+ device.getValue().second);
+ }
+ }
+ return null;
+ }
+
+ public class CustomOnKindSelectedListener implements AdapterView.OnItemSelectedListener {
+
+ public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
+ SpinnerWithIconItem selectedItem = (SpinnerWithIconItem) parent.getItemAtPosition(pos);
+ String activity = selectedItem.getText();
+ activityFilter = activityKindMap.get(activity).getCode();
+ update_filter_fields();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> arg0) {
+ // TODO Auto-generated method stub
+ }
+
+ }
+
+ public class CustomOnDeviceSelectedListener implements AdapterView.OnItemSelectedListener {
+
+ public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
+ SpinnerWithIconItem selectedItem = (SpinnerWithIconItem) parent.getItemAtPosition(pos);
+ deviceFilter = selectedItem.getId();
+ update_filter_fields();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> arg0) {
+ // TODO Auto-generated method stub
+ }
+
+ }
+
+ public class CustomQuickFilterSelectionListener implements AdapterView.OnItemSelectedListener {
+ ArrayList<String> activity_filter_quick_filter_period_values = new ArrayList<>(Arrays.asList(getResources().getStringArray(R.array.activity_filter_quick_filter_period_values)));
+ String selection;
+
+ public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
+ selection = activity_filter_quick_filter_period_values.get(pos);
+ setTimePeriodFilter(selection);
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> arg0) {
+ // TODO Auto-generated method stub
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesGpsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesGpsFragment.java
new file mode 100644
index 0000000..24138af
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesGpsFragment.java
@@ -0,0 +1,180 @@
+/* Copyright (C) 2020-2024 Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
+import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord;
+import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParseException;
+import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParser;
+
+import static android.graphics.Bitmap.createBitmap;
+
+
+public class ActivitySummariesGpsFragment extends AbstractGBFragment {
+ private static final Logger LOG = LoggerFactory.getLogger(ActivitySummariesGpsFragment.class);
+ private ImageView gpsView;
+ private final int CANVAS_SIZE = 360;
+ private File inputFile;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_gps, container, false);
+ gpsView = rootView.findViewById(R.id.activitygpsview);
+ if (inputFile != null) {
+ processInBackgroundThread();
+ }
+ return rootView;
+ }
+
+ public void set_data(File inputFile) {
+ this.inputFile = inputFile;
+ if (gpsView != null) { //first fragment inflate is AFTER this is called
+ processInBackgroundThread();
+ }
+ }
+
+ private void processInBackgroundThread() {
+ final Canvas canvas = createCanvas(gpsView);
+ new Thread(() -> {
+ final List<GPSCoordinate> points = getActivityPoints(inputFile)
+ .stream()
+ .map(ActivityPoint::getLocation)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+
+ if (!points.isEmpty()) {
+ drawTrack(canvas, points);
+ }
+ }).start();
+ }
+
+ public static List<ActivityPoint> getActivityPoints(final File trackFile) {
+ final List<ActivityPoint> points = new ArrayList<>();
+ if (trackFile.getName().endsWith(".gpx")) {
+ try (FileInputStream inputStream = new FileInputStream(trackFile)) {
+ final GpxParser gpxParser = new GpxParser(inputStream);
+ points.addAll(gpxParser.getGpxFile().getActivityPoints());
+ } catch (final IOException e) {
+ LOG.error("Failed to open {}", trackFile, e);
+ } catch (final GpxParseException e) {
+ LOG.error("Failed to parse gpx file", e);
+ }
+ } else if (trackFile.getName().endsWith(".fit")) {
+ try {
+ FitFile fitFile = FitFile.parseIncoming(trackFile);
+ for (final RecordData record : fitFile.getRecords()) {
+ if (record instanceof FitRecord) {
+ points.add(((FitRecord) record).toActivityPoint());
+ }
+ }
+ } catch (final IOException e) {
+ LOG.error("Failed to open {}", trackFile, e);
+ } catch (final Exception e) {
+ LOG.error("Failed to parse fit file", e);
+ }
+ } else {
+ LOG.warn("Unknown file type {}", trackFile.getName());
+ }
+
+ return points;
+ }
+
+ private void drawTrack(Canvas canvas, List<? extends GPSCoordinate> trackPoints) {
+ double maxLat = (Collections.max(trackPoints, new GPSCoordinate.compareLatitude())).getLatitude();
+ double minLat = (Collections.min(trackPoints, new GPSCoordinate.compareLatitude())).getLatitude();
+ double maxLon = (Collections.max(trackPoints, new GPSCoordinate.compareLongitude())).getLongitude();
+ double minLon = (Collections.min(trackPoints, new GPSCoordinate.compareLongitude())).getLongitude();
+ double maxAlt = (Collections.max(trackPoints, new GPSCoordinate.compareElevation())).getAltitude();
+ double minAlt = (Collections.min(trackPoints, new GPSCoordinate.compareElevation())).getAltitude();
+ float scale_factor_w = (float) ((maxLon - minLon) / (maxLat - minLat));
+ float scale_factor_h = (float) ((maxLat - minLat) / (maxLon - minLon));
+
+ if (scale_factor_h > scale_factor_w) { //scaling to draw proportionally
+ scale_factor_h = 1;
+ } else {
+ scale_factor_w = 1;
+ }
+
+
+ Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ paint.setStrokeWidth(1);
+ paint.setColor(getResources().getColor(R.color.chart_activity_light));
+
+ for (GPSCoordinate p : trackPoints) {
+ float lat = (float) ((p.getLatitude() - minLat) / (maxLat - minLat));
+ float lon = (float) ((p.getLongitude() - minLon) / (maxLon - minLon));
+ float alt = (float) ((p.getAltitude() - minAlt) / (maxAlt - minAlt));
+ paint.setStrokeWidth(1 + alt); //make thicker with higher altitude, we could do more here
+ canvas.drawPoint(CANVAS_SIZE * lon * scale_factor_w, CANVAS_SIZE * lat * scale_factor_h, paint);
+ }
+ }
+
+ private Canvas createCanvas(ImageView imageView) {
+ Bitmap bitmap = createBitmap(CANVAS_SIZE, CANVAS_SIZE, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ canvas.drawColor(GBApplication.getWindowBackgroundColor(requireActivity()));
+ //frame around, but it doesn't look so nice
+ /*
+ Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ paint.setStrokeWidth(0);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setColor(getResources().getColor(R.color.chart_activity_light));
+ canvas.drawRect(0,0,360,360,paint);
+ */
+ imageView.setImageBitmap(bitmap);
+ imageView.setScaleY(-1f); //flip the canvas
+
+ return canvas;
+ }
+
+ @Nullable
+ @Override
+ protected CharSequence getTitle() {
+ return null;
+ }
+
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java
new file mode 100644
index 0000000..ee700c5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java
@@ -0,0 +1,729 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Arjan Schrijver, Daniel Dakhno,
+ Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.*;
+
+import android.annotation.SuppressLint;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Typeface;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.InputType;
+import android.text.format.DateUtils;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.SubMenu;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.ArrayAdapter;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+
+import androidx.appcompat.app.ActionBar;
+import androidx.gridlayout.widget.GridLayout;
+
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.TableRow;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.content.FileProvider;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.Pair;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryItems;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryJsonSummary;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.util.ActivitySummaryUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.SwipeEvents;
+
+public class ActivitySummaryDetail extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(ActivitySummaryDetail.class);
+ BaseActivitySummary currentItem = null;
+ private GBDevice gbDevice;
+ private Menu mOptionsMenu;
+ List<String> filesGpxList = new ArrayList<>();
+ int selectedGpxIndex;
+ String selectedGpxFile;
+ File export_path = null;
+
+ private ActivitySummariesChartFragment activitySummariesChartFragment;
+ private ActivitySummariesGpsFragment activitySummariesGpsFragment;
+
+ private final WorkoutValueFormatter workoutValueFormatter = new WorkoutValueFormatter();
+
+ public static int getAlternateColor(Context context) {
+ TypedValue typedValue = new TypedValue();
+ Resources.Theme theme = context.getTheme();
+ theme.resolveAttribute(R.attr.alternate_row_background, typedValue, true);
+ return typedValue.data;
+ }
+
+ public static Bitmap getScreenShot(View view, int height, int width, Context context) {
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ canvas.drawColor(GBApplication.getWindowBackgroundColor(context));
+ view.draw(canvas);
+ return bitmap;
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Context appContext = this.getApplicationContext();
+ if (appContext instanceof GBApplication) {
+ setContentView(R.layout.activity_summary_details);
+ }
+
+
+ Intent intent = getIntent();
+
+ Bundle bundle = intent.getExtras();
+ gbDevice = bundle.getParcelable(GBDevice.EXTRA_DEVICE);
+ final int position = bundle.getInt("position", 0);
+ final int activityFilter = bundle.getInt("activityFilter", 0);
+ final long dateFromFilter = bundle.getLong("dateFromFilter", 0);
+ final long dateToFilter = bundle.getLong("dateToFilter", 0);
+ final long deviceFilter = bundle.getLong("deviceFilter", 0);
+ final String nameContainsFilter = bundle.getString("nameContainsFilter");
+ final List<Long> itemsFilter = (List<Long>) bundle.getSerializable("itemsFilter");
+
+ final ActivitySummaryItems items = new ActivitySummaryItems(this, gbDevice, activityFilter, dateFromFilter, dateToFilter, nameContainsFilter, deviceFilter, itemsFilter);
+ final ScrollView layout = findViewById(R.id.activity_summary_detail_scroll_layout);
+ //final LinearLayout layout = findViewById(R.id.activity_summary_detail_relative_layout);
+
+ final Animation animFadeRight;
+ final Animation animFadeLeft;
+ final Animation animBounceLeft;
+ final Animation animBounceRight;
+
+ animFadeRight = AnimationUtils.loadAnimation(
+ this,
+ R.anim.flyright);
+ animFadeLeft = AnimationUtils.loadAnimation(
+ this,
+ R.anim.flyleft);
+ animBounceLeft = AnimationUtils.loadAnimation(
+ this,
+ R.anim.bounceleft);
+ animBounceRight = AnimationUtils.loadAnimation(
+ this,
+ R.anim.bounceright);
+
+ activitySummariesChartFragment = new ActivitySummariesChartFragment();
+ activitySummariesGpsFragment = new ActivitySummariesGpsFragment();
+
+ getSupportFragmentManager()
+ .beginTransaction()
+ .replace(R.id.chartsFragmentHolder, activitySummariesChartFragment)
+ .replace(R.id.gpsFragmentHolder, activitySummariesGpsFragment)
+ .commit();
+
+ layout.setOnTouchListener(new SwipeEvents(this) {
+ @Override
+ public void onSwipeRight() {
+ BaseActivitySummary newItem = items.getNextItem();
+ if (newItem != null) {
+ currentItem = newItem;
+ refreshFromCurrentItem();
+ layout.startAnimation(animFadeRight);
+ } else {
+ layout.startAnimation(animBounceRight);
+ }
+ }
+
+ @Override
+ public void onSwipeLeft() {
+ BaseActivitySummary newItem = items.getPrevItem();
+ if (newItem != null) {
+ currentItem = newItem;
+ refreshFromCurrentItem();
+ layout.startAnimation(animFadeLeft);
+ } else {
+ layout.startAnimation(animBounceLeft);
+ }
+ }
+ });
+
+ currentItem = items.getItem(position);
+ if (currentItem != null) {
+ refreshFromCurrentItem();
+ }
+
+ //allows long-press.switch of data being in raw form or recalculated
+ ImageView activity_icon = findViewById(R.id.item_image);
+ activity_icon.setOnLongClickListener(new View.OnLongClickListener() {
+ public boolean onLongClick(View v) {
+ workoutValueFormatter.toggleRawData();
+ if (currentItem != null) {
+ makeSummaryHeader(currentItem);
+ makeSummaryContent(currentItem);
+ }
+ return false;
+ }
+ });
+ }
+
+ private void makeSummaryHeader(BaseActivitySummary item) {
+ //make view of data from main part of item
+ String activitykindname = ActivityKind.fromCode(item.getActivityKind()).getLabel(getApplicationContext());
+ String activityname = item.getName();
+ Date starttime = item.getStartTime();
+ Date endtime = item.getEndTime();
+ String durationhms = DateTimeUtils.formatDurationHoursMinutes((endtime.getTime() - starttime.getTime()), TimeUnit.MILLISECONDS);
+
+ ImageView activity_icon = findViewById(R.id.item_image);
+ activity_icon.setImageResource(ActivityKind.fromCode(item.getActivityKind()).getIcon());
+
+ final ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(activitykindname);
+ }
+
+ TextView activity_name = findViewById(R.id.activityname);
+ activity_name.setText(activityname);
+ if (StringUtils.isBlank(activityname)) {
+ activity_name.setVisibility(View.GONE);
+ } else {
+ activity_name.setVisibility(View.VISIBLE);
+ }
+
+ TextView activityDateTextView = findViewById(R.id.activitydate);
+
+ final Context context = activityDateTextView.getContext();
+ final String timeString;
+ if (DateTimeUtils.isSameDay(starttime, endtime)) {
+ timeString = context.getString(
+ R.string.date_placeholders__start_time__end_time,
+ DateTimeUtils.formatDateTimeRelative(context, starttime),
+ DateTimeUtils.formatTime(endtime.getHours(), endtime.getMinutes())
+ );
+ } else {
+ timeString = context.getString(
+ R.string.date_placeholders__start_time__end_time,
+ DateTimeUtils.formatDateTimeRelative(context, starttime),
+ DateTimeUtils.formatDateTimeRelative(context, endtime)
+ );
+ }
+ activityDateTextView.setText(timeString);
+
+ TextView activityTimeTextView = findViewById(R.id.activityduration);
+ activityTimeTextView.setText(durationhms);
+ }
+
+ private void refreshFromCurrentItem() {
+ makeSummaryHeader(currentItem);
+ makeSummaryContent(currentItem);
+
+ activitySummariesChartFragment.setDateAndGetData(
+ getTrackFile(),
+ getGBDevice(currentItem.getDevice()),
+ currentItem.getStartTime().getTime() / 1000,
+ currentItem.getEndTime().getTime() / 1000
+ );
+
+ if (itemHasGps()) {
+ showGpsCanvas();
+ activitySummariesGpsFragment.set_data(getTrackFile());
+ } else {
+ hideGpsCanvas();
+ }
+
+ updateMenuItems();
+ }
+
+ private File get_path() {
+ File path = null;
+ try {
+ path = FileUtils.getExternalFilesDir();
+ } catch (IOException e) {
+ LOG.error("Error getting path", e);
+ }
+ return path;
+ }
+
+ private List<String> get_gpx_file_list() {
+ List<String> list = new ArrayList<>();
+
+ File[] fileListing = export_path.listFiles(new FileFilter() {
+ @Override
+ public boolean accept(File file) {
+ return file.getPath().toLowerCase().endsWith(".gpx");
+ }
+ });
+
+ if (fileListing != null && fileListing.length > 1) {
+ Arrays.sort(fileListing, new Comparator<File>() {
+ @Override
+ public int compare(File fileA, File fileB) {
+ if (fileA.lastModified() < fileB.lastModified()) {
+ return 1;
+ }
+ if (fileA.lastModified() > fileB.lastModified()) {
+ return -1;
+ }
+ return 0;
+ }
+ });
+ }
+
+ list.add(getString(R.string.activity_summary_detail_clear_gpx_track));
+
+ for (File file : fileListing) {
+ list.add(file.getName());
+ }
+ return list;
+ }
+
+ private void makeSummaryContent(BaseActivitySummary item) {
+ final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
+ final ActivitySummaryParser summaryParser = coordinator.getActivitySummaryParser(gbDevice, this);
+
+ //make view of data from summaryData of item
+ LinearLayout fieldLayout = findViewById(R.id.summaryDetails);
+ fieldLayout.removeAllViews(); //remove old widgets
+ ActivitySummaryJsonSummary activitySummaryJsonSummary = new ActivitySummaryJsonSummary(summaryParser, item);
+ Map<String, List<Pair<String, ActivitySummaryEntry>>> data = activitySummaryJsonSummary.getSummaryGroupedList(); //get list, grouped by groups
+ if (data == null) return;
+
+ for (final Map.Entry<String, List<Pair<String, ActivitySummaryEntry>>> group : data.entrySet()) {
+ final String groupKey = group.getKey();
+ final List<Pair<String, ActivitySummaryEntry>> entries = group.getValue();
+
+ TableRow label_row = new TableRow(ActivitySummaryDetail.this);
+ TextView label_field = new TextView(ActivitySummaryDetail.this);
+ label_field.setId(View.generateViewId());
+ label_field.setTextSize(18);
+ label_field.setPaddingRelative(dpToPx(16), dpToPx(16), 0, dpToPx(16));
+ label_field.setTypeface(null, Typeface.BOLD);
+ label_field.setText(workoutValueFormatter.getStringResourceByName(groupKey));
+ label_row.addView(label_field);
+ fieldLayout.addView(label_row);
+
+ GridLayout gridLayout = new GridLayout(ActivitySummaryDetail.this);
+ gridLayout.setBackgroundColor(getResources().getColor(R.color.gauge_line_color));
+ gridLayout.setColumnCount(2);
+ gridLayout.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ int totalCells = entries.stream().mapToInt(e -> e.getRight().getColumnSpan()).sum();
+ totalCells += totalCells & 1; // round up to nearest even number, since we have 2 columns
+ int cellNumber = 0;
+ for (final Pair<String, ActivitySummaryEntry> entry : entries) {
+ final int columnSpan = entry.getRight().getColumnSpan();
+ if (columnSpan == 2 && cellNumber % 2 != 0) {
+ // This entry needs 2 columns, so let's move to the next row
+ cellNumber++;
+ }
+ LinearLayout linearLayout = generateLinearLayout(cellNumber, cellNumber + 2 >= totalCells, columnSpan);
+ entry.getRight().populate(entry.getLeft(), linearLayout, workoutValueFormatter);
+ gridLayout.addView(linearLayout);
+ cellNumber += columnSpan;
+ }
+ if (gridLayout.getChildCount() > 0) {
+ if (cellNumber % 2 != 0) {
+ final LinearLayout emptyLayout = generateLinearLayout(cellNumber, true, 1);
+ new ActivitySummarySimpleEntry(null, "", "string").populate("", emptyLayout, workoutValueFormatter);
+ gridLayout.addView(emptyLayout);
+ }
+ fieldLayout.addView(gridLayout);
+ }
+ }
+ }
+
+ public LinearLayout generateLinearLayout(int i, boolean lastRow, int columnSize) {
+ LinearLayout linearLayout = new LinearLayout(ActivitySummaryDetail.this);
+ GridLayout.LayoutParams columnParams = new GridLayout.LayoutParams();
+ columnParams.columnSpec = GridLayout.spec(i % 2 == 0 ? 0 : 1, 1);
+ GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(
+ GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f),
+ GridLayout.spec(GridLayout.UNDEFINED, columnSize, GridLayout.FILL, 1f)
+ );
+ layoutParams.width = 0;
+ linearLayout.setLayoutParams(layoutParams);
+ linearLayout.setOrientation(LinearLayout.VERTICAL);
+ linearLayout.setGravity(Gravity.CENTER);
+ linearLayout.setPadding(dpToPx(15), dpToPx(15), dpToPx(15), dpToPx(15));
+ linearLayout.setBackgroundColor(GBApplication.getWindowBackgroundColor(ActivitySummaryDetail.this));
+ int marginLeft = 0;
+ int marginTop = 0;
+ int marginBottom = 0;
+ int marginRight = 0;
+ if (i % 2 == 0) {
+ marginTop = 2;
+ marginRight = 1;
+ }
+ if (i % 2 == 1) {
+ marginTop = 2;
+ marginLeft = 1;
+ }
+ if (lastRow) {
+ marginBottom = 2;
+ }
+ layoutParams.setMargins(dpToPx(marginLeft), dpToPx(marginTop), dpToPx(marginRight), dpToPx(marginBottom));
+
+ return linearLayout;
+ }
+
+ public int dpToPx(int dp) {
+ float density = getResources().getDisplayMetrics().density;
+ return Math.round(dp * density);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ finish();
+ return true;
+ } else if (itemId == R.id.activity_action_take_screenshot) {
+ take_share_screenshot(ActivitySummaryDetail.this);
+ return true;
+ } else if (itemId == R.id.activity_action_show_gpx) {
+ viewGpxTrack(ActivitySummaryDetail.this);
+ return true;
+ } else if (itemId == R.id.activity_action_share_gpx) {
+ shareGpxTrack(ActivitySummaryDetail.this);
+ return true;
+ } else if (itemId == R.id.activity_action_dev_share_raw_summary) {
+ shareRawSummary(ActivitySummaryDetail.this, currentItem);
+ return true;
+ } else if (itemId == R.id.activity_action_dev_share_raw_details) {
+ shareRawDetails(ActivitySummaryDetail.this, currentItem);
+ return true;
+ } else if (itemId == R.id.activity_action_dev_share_json_details) {
+ shareJsonDetails(ActivitySummaryDetail.this, currentItem);
+ return true;
+ } else if (itemId == R.id.activity_summary_detail_action_edit_name) {
+ editLabel();
+ return true;
+ } else if (itemId == R.id.activity_summary_detail_action_edit_gps) {
+ editGps();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void editLabel() {
+ final EditText input = new EditText(ActivitySummaryDetail.this);
+ input.setInputType(InputType.TYPE_CLASS_TEXT);
+ String name = currentItem.getName();
+ input.setText((name != null) ? name : "");
+ FrameLayout container = new FrameLayout(ActivitySummaryDetail.this);
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.leftMargin = getResources().getDimensionPixelSize(R.dimen.dialog_margin);
+ params.rightMargin = getResources().getDimensionPixelSize(R.dimen.dialog_margin);
+ input.setLayoutParams(params);
+ container.addView(input);
+
+ new MaterialAlertDialogBuilder(ActivitySummaryDetail.this)
+ .setView(container)
+ .setCancelable(true)
+ .setTitle(ActivitySummaryDetail.this.getString(R.string.activity_summary_edit_name_title))
+ .setPositiveButton(R.string.ok, (dialog, which) -> {
+ String name1 = input.getText().toString();
+ if (name1.isEmpty()) name1 = null;
+ currentItem.setName(name1);
+ currentItem.update();
+ makeSummaryHeader(currentItem);
+ makeSummaryContent(currentItem);
+ })
+ .setNegativeButton(R.string.Cancel, (dialog, which) -> {
+ // do nothing
+ })
+ .show();
+ }
+
+ private void editGps() {
+ export_path = get_path();
+ filesGpxList = get_gpx_file_list();
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(ActivitySummaryDetail.this);
+ builder.setTitle(R.string.activity_summary_detail_select_gpx_track);
+ ArrayAdapter<String> directory_listing = new ArrayAdapter<>(ActivitySummaryDetail.this, android.R.layout.simple_list_item_1, filesGpxList);
+ builder.setSingleChoiceItems(directory_listing, 0, (dialog, which) -> {
+ selectedGpxIndex = which;
+ selectedGpxFile = export_path + "/" + filesGpxList.get(selectedGpxIndex);
+ String message = String.format("%s %s?", getString(R.string.set), filesGpxList.get(selectedGpxIndex));
+ if (selectedGpxIndex == 0) {
+ selectedGpxFile = null;
+ message = String.format("%s?", getString(R.string.activity_summary_detail_clear_gpx_track));
+ }
+
+ new MaterialAlertDialogBuilder(ActivitySummaryDetail.this)
+ .setCancelable(true)
+ .setIcon(R.drawable.ic_warning)
+ .setTitle(R.string.activity_summary_detail_editing_gpx_track)
+ .setMessage(message)
+ .setPositiveButton(R.string.ok, (dialog1, which1) -> {
+ currentItem.setGpxTrack(selectedGpxFile);
+ currentItem.update();
+ if (itemHasGps()) {
+ showGpsCanvas();
+ activitySummariesGpsFragment.set_data(getTrackFile());
+ } else {
+ hideGpsCanvas();
+ }
+ })
+ .setNegativeButton(R.string.Cancel, (dialog2, which2) -> {
+ })
+ .show();
+ dialog.dismiss();
+ });
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ }
+
+ private void take_share_screenshot(Context context) {
+ final ScrollView layout = findViewById(R.id.activity_summary_detail_scroll_layout);
+ final int width = layout.getChildAt(0).getWidth();
+ final int height = layout.getChildAt(0).getHeight();
+ final Bitmap screenShot = getScreenShot(layout, height, width, context);
+
+ final String fileName = FileUtils.makeValidFileName("Screenshot-" + ActivityKind.fromCode(currentItem.getActivityKind()).getLabel(context).toLowerCase() + "-" + DateTimeUtils.formatIso8601(currentItem.getStartTime()) + ".png");
+ try {
+ final File targetFile = new File(FileUtils.getExternalFilesDir(), fileName);
+ final FileOutputStream fOut = new FileOutputStream(targetFile);
+ screenShot.compress(Bitmap.CompressFormat.PNG, 85, fOut);
+ fOut.flush();
+ fOut.close();
+ shareScreenshot(targetFile, context);
+ GB.toast(getApplicationContext(), "Screenshot saved", Toast.LENGTH_LONG, GB.INFO);
+ } catch (IOException e) {
+ LOG.error("Error getting screenshot", e);
+ }
+ }
+
+ private void shareScreenshot(File targetFile, Context context) {
+ Uri contentUri = FileProvider.getUriForFile(context,
+ context.getApplicationContext().getPackageName() + ".screenshot_provider", targetFile);
+ Intent sharingIntent = new Intent(android.content.Intent.ACTION_SEND);
+ sharingIntent.setType("image/*");
+ String shareBody = "Sports Activity";
+ sharingIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, "Sports Activity");
+ sharingIntent.putExtra(android.content.Intent.EXTRA_TEXT, shareBody);
+ sharingIntent.putExtra(Intent.EXTRA_STREAM, contentUri);
+
+ try {
+ startActivity(Intent.createChooser(sharingIntent, "Share via"));
+ } catch (ActivityNotFoundException e) {
+ Toast.makeText(context, R.string.activity_error_no_app_for_png, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private void viewGpxTrack(Context context) {
+ final File gpxFile = ActivitySummaryUtils.getGpxFile(currentItem);
+ if (gpxFile == null) {
+ GB.toast(getApplicationContext(), "No GPX track in this activity", Toast.LENGTH_LONG, GB.INFO);
+ return;
+ }
+
+ try {
+ AndroidUtils.viewFile(gpxFile.getPath(), "application/gpx+xml", context);
+ } catch (final Exception e) {
+ GB.toast(getApplicationContext(), "Unable to display GPX track: " + e.getMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ private void shareGpxTrack(final Context context) {
+ final File gpxFile = ActivitySummaryUtils.getGpxFile(currentItem);
+ if (gpxFile == null) {
+ GB.toast(getApplicationContext(), "No GPX track in this activity", Toast.LENGTH_LONG, GB.INFO);
+ return;
+ }
+
+ try {
+ AndroidUtils.shareFile(context, gpxFile);
+ } catch (final Exception e) {
+ GB.toast(context, "Unable to share GPX track: " + e.getMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ private static void shareRawSummary(final Context context, final BaseActivitySummary summary) {
+ if (summary.getRawSummaryData() == null) {
+ GB.toast(context, "No raw summary in this activity", Toast.LENGTH_LONG, GB.WARN);
+ return;
+ }
+
+ final String rawSummaryFilename = FileUtils.makeValidFileName(String.format("%s_summary.bin", DateTimeUtils.formatIso8601(summary.getStartTime())));
+
+ try {
+ AndroidUtils.shareBytesAsFile(context, rawSummaryFilename, summary.getRawSummaryData());
+ } catch (final Exception e) {
+ GB.toast(context, "Unable to share GPX track: " + e.getMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ private static void shareRawDetails(final Context context, final BaseActivitySummary summary) {
+ if (summary.getRawDetailsPath() == null) {
+ GB.toast(context, "No raw details in this activity", Toast.LENGTH_LONG, GB.WARN);
+ return;
+ }
+
+ try {
+ AndroidUtils.shareFile(context, new File(summary.getRawDetailsPath()));
+ } catch (final Exception e) {
+ GB.toast(context, "Unable to share raw details: " + e.getMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ private static void shareJsonDetails(final Context context, final BaseActivitySummary summary) {
+ if (summary.getSummaryData() == null) {
+ GB.toast(context, "No json details in this activity", Toast.LENGTH_LONG, GB.WARN);
+ return;
+ }
+
+ final String jsonSummaryFilename = FileUtils.makeValidFileName(String.format("%s.json", DateTimeUtils.formatIso8601(summary.getStartTime())));
+
+ try {
+ AndroidUtils.shareBytesAsFile(context, jsonSummaryFilename, summary.getSummaryData().getBytes(StandardCharsets.UTF_8));
+ } catch (final Exception e) {
+ GB.toast(context, "Unable to share json details: " + e.getMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ private void updateMenuItems() {
+ boolean hasGpx = false;
+ boolean hasRawSummary = false;
+ boolean hasRawDetails = false;
+
+ if (currentItem != null) {
+ hasGpx = itemHasGps();
+ hasRawSummary = currentItem.getRawSummaryData() != null;
+
+ final String rawDetailsPath = currentItem.getRawDetailsPath();
+ if (rawDetailsPath != null) {
+ hasRawDetails = new File(rawDetailsPath).exists();
+ }
+ }
+
+ if (mOptionsMenu != null) {
+ final SubMenu overflowMenu = mOptionsMenu.findItem(R.id.activity_detail_overflowMenu).getSubMenu();
+ if (overflowMenu != null) {
+ overflowMenu.findItem(R.id.activity_action_show_gpx).setVisible(hasGpx);
+ overflowMenu.findItem(R.id.activity_action_share_gpx).setVisible(hasGpx);
+ overflowMenu.findItem(R.id.activity_action_dev_share_raw_summary).setVisible(hasRawSummary);
+ overflowMenu.findItem(R.id.activity_action_dev_share_raw_details).setVisible(hasRawDetails);
+ final MenuItem devToolsMenu = overflowMenu.findItem(R.id.activity_action_dev_tools);
+ final SubMenu devToolsSubMenu = devToolsMenu.getSubMenu();
+ devToolsMenu.setVisible(devToolsSubMenu != null && devToolsSubMenu.hasVisibleItems());
+ }
+ }
+ }
+
+ private void showGpsCanvas() {
+ final View gpsView = findViewById(R.id.gpsFragmentHolder);
+ final ViewGroup.LayoutParams params = gpsView.getLayoutParams();
+ params.height = (int) (300 * getApplicationContext().getResources().getDisplayMetrics().density);
+ gpsView.setLayoutParams(params);
+ }
+
+ private void hideGpsCanvas() {
+ final View gpsView = findViewById(R.id.gpsFragmentHolder);
+ final ViewGroup.LayoutParams params = gpsView.getLayoutParams();
+ params.height = 0;
+ gpsView.setLayoutParams(params);
+ }
+
+ private boolean itemHasGps() {
+ if (currentItem.getGpxTrack() != null) {
+ final File existing = FileUtils.tryFixPath(new File(currentItem.getGpxTrack()));
+ if (existing != null && existing.canRead()) {
+ return true;
+ }
+ }
+ final String summaryDataJson = currentItem.getSummaryData();
+ if (summaryDataJson != null && summaryDataJson.contains(INTERNAL_HAS_GPS)) {
+ final ActivitySummaryData summaryData = ActivitySummaryData.fromJson(summaryDataJson);
+ return summaryData != null && summaryData.getBoolean(INTERNAL_HAS_GPS, false);
+ }
+
+ return false;
+ }
+
+ @Nullable
+ private File getTrackFile() {
+ return ActivitySummaryUtils.getTrackFile(currentItem);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ mOptionsMenu = menu;
+ getMenuInflater().inflate(R.menu.activity_take_screenshot_menu, menu);
+ updateMenuItems();
+ return true;
+ }
+
+ @Nullable
+ private static GBDevice getGBDevice(final Device findDevice) {
+ return GBApplication.app().getDeviceManager().getDevices()
+ .stream()
+ .filter(d -> d.getAddress().equalsIgnoreCase(findDevice.getIdentifier()))
+ .findFirst()
+ .orElse(null);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java
new file mode 100644
index 0000000..276c120
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java
@@ -0,0 +1,297 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
+ Dakhno, Daniele Gobbetti, Dmitry Markin, Lem Dulfo, Taavi Eom盲e, Martin.JM
+
+ 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.activities;
+
+import android.os.Bundle;
+import android.text.InputFilter;
+import android.text.Spanned;
+import android.text.format.DateFormat;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.CheckedTextView;
+import android.widget.EditText;
+import android.widget.TimePicker;
+
+import java.text.NumberFormat;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+
+public class AlarmDetails extends AbstractGBActivity {
+
+ private Alarm alarm;
+ private TimePicker timePicker;
+ private CheckedTextView cbSmartWakeup;
+ private CheckedTextView cbSnooze;
+ private CheckedTextView cbMonday;
+ private CheckedTextView cbTuesday;
+ private CheckedTextView cbWednesday;
+ private CheckedTextView cbThursday;
+ private CheckedTextView cbFriday;
+ private CheckedTextView cbSaturday;
+ private CheckedTextView cbSunday;
+ private EditText title;
+ private EditText description;
+ private EditText smartWakeupInterval;
+ private GBDevice device;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_alarm_details);
+
+ alarm = (Alarm) getIntent().getSerializableExtra(nodomain.freeyourgadget.gadgetbridge.model.Alarm.EXTRA_ALARM);
+ device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+
+ title = findViewById(R.id.alarm_title);
+ description = findViewById(R.id.alarm_description);
+
+ timePicker = findViewById(R.id.alarm_time_picker);
+ cbSmartWakeup = findViewById(R.id.alarm_cb_smart_wakeup);
+ smartWakeupInterval = findViewById(R.id.alarm_cb_smart_wakeup_interval);
+ cbSnooze = findViewById(R.id.alarm_cb_snooze);
+ cbMonday = findViewById(R.id.alarm_cb_monday);
+ cbTuesday = findViewById(R.id.alarm_cb_tuesday);
+ cbWednesday = findViewById(R.id.alarm_cb_wednesday);
+ cbThursday = findViewById(R.id.alarm_cb_thursday);
+ cbFriday = findViewById(R.id.alarm_cb_friday);
+ cbSaturday = findViewById(R.id.alarm_cb_saturday);
+ cbSunday = findViewById(R.id.alarm_cb_sunday);
+
+
+ cbSmartWakeup.setOnClickListener(new View.OnClickListener() {
+ public void onClick(View v) {
+ ((CheckedTextView) v).toggle();
+ smartWakeupInterval.setEnabled(((CheckedTextView) v).isChecked());
+ }
+ });
+ cbSnooze.setOnClickListener(new View.OnClickListener() {
+ public void onClick(View v) {
+ ((CheckedTextView) v).toggle();
+ }
+ });
+ cbMonday.setOnClickListener(new View.OnClickListener() {
+ public void onClick(View v) {
+ ((CheckedTextView) v).toggle();
+ }
+ });
+ cbTuesday.setOnClickListener(new View.OnClickListener() {
+ public void onClick(View v) {
+ ((CheckedTextView) v).toggle();
+ }
+ });
+ cbWednesday.setOnClickListener(new View.OnClickListener() {
+ public void onClick(View v) {
+ ((CheckedTextView) v).toggle();
+ }
+ });
+ cbThursday.setOnClickListener(new View.OnClickListener() {
+ public void onClick(View v) {
+ ((CheckedTextView) v).toggle();
+ }
+ });
+ cbFriday.setOnClickListener(new View.OnClickListener() {
+ public void onClick(View v) {
+ ((CheckedTextView) v).toggle();
+ }
+ });
+ cbSaturday.setOnClickListener(new View.OnClickListener() {
+ public void onClick(View v) {
+ ((CheckedTextView) v).toggle();
+ }
+ });
+ cbSunday.setOnClickListener(new View.OnClickListener() {
+ public void onClick(View v) {
+ ((CheckedTextView) v).toggle();
+ }
+ });
+
+ timePicker.setIs24HourView(DateFormat.is24HourFormat(GBApplication.getContext()));
+ timePicker.setCurrentHour(alarm.getHour());
+ timePicker.setCurrentMinute(alarm.getMinute());
+
+ boolean smartAlarmSupported = supportsSmartWakeup(alarm.getPosition());
+ boolean smartAlarmForced = forcedSmartWakeup(alarm.getPosition());
+ boolean smartAlarmIntervalSupported = supportsSmartWakeupInterval(alarm.getPosition());
+
+ cbSmartWakeup.setChecked(alarm.getSmartWakeup() || smartAlarmForced);
+ cbSmartWakeup.setVisibility(smartAlarmSupported ? View.VISIBLE : View.GONE);
+ if (smartAlarmForced) {
+ cbSmartWakeup.setEnabled(false);
+ // Force the text to be visible for the "interval" part
+ // Enabled or not can still be seen in the checkmark
+ // TODO: I'd like feedback on this
+ if (GBApplication.isDarkThemeEnabled())
+ cbSmartWakeup.setTextColor(getResources().getColor(android.R.color.secondary_text_dark));
+ else
+ cbSmartWakeup.setTextColor(getResources().getColor(android.R.color.secondary_text_light));
+ }
+ if (smartAlarmIntervalSupported)
+ cbSmartWakeup.setText(R.string.alarm_smart_wakeup_interval);
+
+ smartWakeupInterval.setVisibility(smartAlarmSupported && smartAlarmIntervalSupported ? View.VISIBLE : View.GONE);
+ smartWakeupInterval.setEnabled(alarm.getSmartWakeup() || smartAlarmForced);
+ if (alarm.getSmartWakeupInterval() != null)
+ smartWakeupInterval.setText(NumberFormat.getInstance().format(alarm.getSmartWakeupInterval()));
+ smartWakeupInterval.setFilters(new InputFilter[] {
+ new InputFilter() {
+ @Override
+ public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
+ if (dend >= 3) // Limit length
+ return "";
+
+ String strValue = dest.subSequence(0, dstart) + source.subSequence(start, end).toString() + dest.subSequence(dend, dest.length());
+ try {
+ int value = Integer.parseInt(strValue);
+ if (value > 255) {
+ smartWakeupInterval.setText("255");
+ smartWakeupInterval.setSelection(3); // Move cursor to end
+ }
+ } catch (NumberFormatException e) {
+ return "";
+ }
+ return null;
+ }
+ }
+ });
+
+ cbSnooze.setChecked(alarm.getSnooze());
+ int snoozeVisibility = supportsSnoozing() ? View.VISIBLE : View.GONE;
+ cbSnooze.setVisibility(snoozeVisibility);
+
+ title.setVisibility(supportsTitle() ? View.VISIBLE : View.GONE);
+ title.setText(alarm.getTitle());
+
+ final int titleLimit = getAlarmTitleLimit();
+ if (titleLimit > 0) {
+ title.setFilters(new InputFilter[]{new InputFilter.LengthFilter(titleLimit)});
+ }
+
+ description.setVisibility(supportsDescription() ? View.VISIBLE : View.GONE);
+ description.setText(alarm.getDescription());
+
+ cbMonday.setChecked(alarm.getRepetition(Alarm.ALARM_MON));
+ cbTuesday.setChecked(alarm.getRepetition(Alarm.ALARM_TUE));
+ cbWednesday.setChecked(alarm.getRepetition(Alarm.ALARM_WED));
+ cbThursday.setChecked(alarm.getRepetition(Alarm.ALARM_THU));
+ cbFriday.setChecked(alarm.getRepetition(Alarm.ALARM_FRI));
+ cbSaturday.setChecked(alarm.getRepetition(Alarm.ALARM_SAT));
+ cbSunday.setChecked(alarm.getRepetition(Alarm.ALARM_SUN));
+ }
+
+ private boolean supportsSmartWakeup(int position) {
+ if (device != null) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.supportsSmartWakeup(device, position);
+ }
+ return false;
+ }
+
+ private boolean supportsSmartWakeupInterval(int position) {
+ if (device != null) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.supportsSmartWakeupInterval(device, position);
+ }
+ return false;
+ }
+
+ /**
+ * The alarm at this position *must* be a smart alarm
+ */
+ private boolean forcedSmartWakeup(int position) {
+ if (device != null) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.forcedSmartWakeup(device, position);
+ }
+ return false;
+ }
+
+ private boolean supportsTitle() {
+ if (device != null) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.supportsAlarmTitle(device);
+ }
+ return false;
+ }
+
+ private int getAlarmTitleLimit() {
+ if (device != null) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.getAlarmTitleLimit(device);
+ }
+ return -1;
+ }
+
+ private boolean supportsDescription() {
+ if (device != null) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.supportsAlarmDescription(device);
+ }
+ return false;
+ }
+
+ private boolean supportsSnoozing() {
+ if (device != null) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.supportsAlarmSnoozing();
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void updateAlarm() {
+ // Set alarm as used and enabled if time has changed
+ if (alarm.getUnused() && alarm.getHour() != timePicker.getCurrentHour() || alarm.getMinute() != timePicker.getCurrentMinute()) {
+ alarm.setUnused(false);
+ alarm.setEnabled(true);
+ }
+ alarm.setSmartWakeup(supportsSmartWakeup(alarm.getPosition()) && cbSmartWakeup.isChecked());
+ String interval = smartWakeupInterval.getText().toString();
+ alarm.setSmartWakeupInterval(interval.equals("") ? null : Integer.parseInt(interval));
+ alarm.setSnooze(supportsSnoozing() && cbSnooze.isChecked());
+ int repetitionMask = AlarmUtils.createRepetitionMask(cbMonday.isChecked(), cbTuesday.isChecked(), cbWednesday.isChecked(), cbThursday.isChecked(), cbFriday.isChecked(), cbSaturday.isChecked(), cbSunday.isChecked());
+ alarm.setRepetition(repetitionMask);
+ alarm.setHour(timePicker.getCurrentHour());
+ alarm.setMinute(timePicker.getCurrentMinute());
+ alarm.setTitle(title.getText().toString());
+ alarm.setDescription(description.getText().toString());
+ DBHelper.store(alarm);
+ }
+
+ @Override
+ protected void onPause() {
+ updateAlarm();
+ super.onPause();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AndroidPairingActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AndroidPairingActivity.java
new file mode 100644
index 0000000..92452ca
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AndroidPairingActivity.java
@@ -0,0 +1,30 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Lem Dulfo
+
+ 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.activities;
+
+import android.os.Bundle;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class AndroidPairingActivity extends AbstractGBActivity {
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_android_pairing);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AppBlacklistActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AppBlacklistActivity.java
new file mode 100644
index 0000000..a7ba373
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AppBlacklistActivity.java
@@ -0,0 +1,86 @@
+/* Copyright (C) 2015-2024 abettenburg, Andreas Shimokawa, Carsten Pfeiffer,
+ Daniele Gobbetti, Lem Dulfo, Petr Van臎k
+
+ 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.activities;
+
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+
+import androidx.appcompat.widget.SearchView;
+import androidx.core.app.NavUtils;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.adapter.AppBlacklistAdapter;
+
+
+public class AppBlacklistActivity extends AbstractGBActivity {
+ private AppBlacklistAdapter appBlacklistAdapter;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_appblacklist);
+ final RecyclerView appListView = (RecyclerView) findViewById(R.id.appListView);
+ appListView.setLayoutManager(new LinearLayoutManager(this));
+
+ appBlacklistAdapter = new AppBlacklistAdapter(R.layout.item_app_blacklist, this);
+
+ appListView.setAdapter(appBlacklistAdapter);
+
+ final SearchView searchView = findViewById(R.id.appListViewSearch);
+ searchView.setIconifiedByDefault(false);
+ searchView.setIconified(false);
+ searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(final String query) {
+ return false;
+ }
+
+ @Override
+ public boolean onQueryTextChange(final String newText) {
+ appBlacklistAdapter.getFilter().filter(newText);
+ return true;
+ }
+ });
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ final MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.app_blacklist_menu, menu);
+ return super.onCreateOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ } else if (itemId == R.id.check_all_applications) {
+ appBlacklistAdapter.checkAllApplications();
+ return true;
+ } else if (itemId == R.id.uncheck_all_applications) {
+ appBlacklistAdapter.uncheckAllApplications();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AppCompatPreferenceActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AppCompatPreferenceActivity.java
new file mode 100644
index 0000000..89372fc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AppCompatPreferenceActivity.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.preference.PreferenceActivity;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.LayoutRes;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.appcompat.widget.Toolbar;
+
+/**
+ * A {@link android.preference.PreferenceActivity} which implements and proxies the necessary calls
+ * to be used with AppCompat.
+ *
+ * This technique can be used with an {@link android.app.Activity} class, not just
+ * {@link android.preference.PreferenceActivity}.
+ */
+public abstract class AppCompatPreferenceActivity extends PreferenceActivity {
+
+ private AppCompatDelegate mDelegate;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ getDelegate().installViewFactory();
+ getDelegate().onCreate(savedInstanceState);
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ protected void onPostCreate(Bundle savedInstanceState) {
+ super.onPostCreate(savedInstanceState);
+ getDelegate().onPostCreate(savedInstanceState);
+ }
+
+ public ActionBar getSupportActionBar() {
+ return getDelegate().getSupportActionBar();
+ }
+
+ public void setSupportActionBar(@Nullable Toolbar toolbar) {
+ getDelegate().setSupportActionBar(toolbar);
+ }
+
+ @Override
+ public MenuInflater getMenuInflater() {
+ return getDelegate().getMenuInflater();
+ }
+
+ @Override
+ public void setContentView(@LayoutRes int layoutResID) {
+ getDelegate().setContentView(layoutResID);
+ }
+
+ @Override
+ public void setContentView(View view) {
+ getDelegate().setContentView(view);
+ }
+
+ @Override
+ public void setContentView(View view, ViewGroup.LayoutParams params) {
+ getDelegate().setContentView(view, params);
+ }
+
+ @Override
+ public void addContentView(View view, ViewGroup.LayoutParams params) {
+ getDelegate().addContentView(view, params);
+ }
+
+ @Override
+ protected void onPostResume() {
+ super.onPostResume();
+ getDelegate().onPostResume();
+ }
+
+ @Override
+ protected void onTitleChanged(CharSequence title, int color) {
+ super.onTitleChanged(title, color);
+ getDelegate().setTitle(title);
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ getDelegate().onConfigurationChanged(newConfig);
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ getDelegate().onStop();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ getDelegate().onDestroy();
+ }
+
+ @Override
+ public void invalidateOptionsMenu() {
+ getDelegate().invalidateOptionsMenu();
+ }
+
+ private AppCompatDelegate getDelegate() {
+ if (mDelegate == null) {
+ mDelegate = AppCompatDelegate.create(this, null);
+ }
+ return mDelegate;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java
new file mode 100644
index 0000000..2d9f002
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BackupRestoreProgressActivity.java
@@ -0,0 +1,241 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import androidx.activity.OnBackPressedCallback;
+import androidx.annotation.Nullable;
+import androidx.documentfile.provider.DocumentFile;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.backup.AbstractZipBackupJob;
+import nodomain.freeyourgadget.gadgetbridge.util.backup.ZipBackupCallback;
+import nodomain.freeyourgadget.gadgetbridge.util.backup.ZipBackupExportJob;
+import nodomain.freeyourgadget.gadgetbridge.util.backup.ZipBackupImportJob;
+
+public class BackupRestoreProgressActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(BackupRestoreProgressActivity.class);
+
+ public static final String EXTRA_URI = "uri";
+ public static final String EXTRA_ACTION = "action"; // import/export
+
+ private boolean jobFinished = false;
+ private Uri uri;
+ private String action;
+ private Thread mThread;
+ private AbstractZipBackupJob mZipBackupJob;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_backup_restore_progress);
+
+ final Bundle extras = getIntent().getExtras();
+ if (extras == null) {
+ LOG.error("No extras");
+ finish();
+ return;
+ }
+
+ uri = extras.getParcelable(EXTRA_URI);
+ if (uri == null) {
+ LOG.error("No uri");
+ finish();
+ return;
+ }
+
+ action = extras.getString(EXTRA_ACTION);
+ if (action == null) {
+ LOG.error("No action");
+ finish();
+ return;
+ }
+
+ final TextView backupRestoreHint = findViewById(R.id.backupRestoreHint);
+ final ProgressBar backupRestoreProgressBar = findViewById(R.id.backupRestoreProgressBar);
+ final TextView backupRestoreProgressText = findViewById(R.id.backupRestoreProgressText);
+ final TextView backupRestoreProgressPercentage = findViewById(R.id.backupRestoreProgressPercentage);
+
+ final ZipBackupCallback zipBackupCallback = new ZipBackupCallback() {
+ @Override
+ public void onProgress(final int progress, final String message) {
+ backupRestoreProgressBar.setIndeterminate(progress == 0);
+ backupRestoreProgressBar.setProgress(progress);
+ backupRestoreProgressText.setText(message);
+ backupRestoreProgressPercentage.setText(getString(R.string.battery_percentage_str, String.valueOf(progress)));
+ }
+
+ @Override
+ public void onSuccess(final String warnings) {
+ jobFinished = true;
+ backupRestoreHint.setVisibility(View.GONE);
+ backupRestoreProgressBar.setProgress(100);
+ backupRestoreProgressPercentage.setText(getString(R.string.battery_percentage_str, "100"));
+
+ switch (action) {
+ case "import":
+ backupRestoreProgressText.setText(R.string.backup_restore_import_complete);
+
+ final StringBuilder message = new StringBuilder();
+
+ message.append(getString(R.string.backup_restore_restart_summary, getString(R.string.app_name)));
+
+ if (warnings != null) {
+ message.append("\n\n").append(warnings);
+ }
+
+ new MaterialAlertDialogBuilder(BackupRestoreProgressActivity.this)
+ .setCancelable(false)
+ .setIcon(R.drawable.ic_sync)
+ .setTitle(R.string.backup_restore_restart_title)
+ .setMessage(message.toString())
+ .setOnCancelListener((dialog -> {
+ finish();
+ GBApplication.restart();
+ }))
+ .setPositiveButton(R.string.ok, (dialog, which) -> {
+ finish();
+ GBApplication.restart();
+ }).show();
+ break;
+ case "export":
+ backupRestoreProgressText.setText(R.string.backup_restore_export_complete);
+ break;
+ }
+ }
+
+ @Override
+ public void onFailure(@Nullable final String errorMessage) {
+ jobFinished = true;
+
+ switch (action) {
+ case "import":
+ backupRestoreHint.setText(R.string.backup_restore_error_import);
+ break;
+ case "export":
+ backupRestoreHint.setText(R.string.backup_restore_error_export);
+ break;
+ }
+
+ backupRestoreProgressText.setText(errorMessage);
+ backupRestoreProgressPercentage.setVisibility(View.GONE);
+
+ if ("export".equals(action)) {
+ final DocumentFile documentFile = DocumentFile.fromSingleUri(BackupRestoreProgressActivity.this, uri);
+ if (documentFile != null) {
+ documentFile.delete();
+ }
+ }
+ }
+ };
+
+ switch (action) {
+ case "import":
+ backupRestoreHint.setText(getString(R.string.backup_restore_do_not_exit, getString(R.string.backup_restore_importing)));
+ mZipBackupJob = new ZipBackupImportJob(GBApplication.getContext(), zipBackupCallback, uri);
+ break;
+ case "export":
+ backupRestoreHint.setText(getString(R.string.backup_restore_do_not_exit, getString(R.string.backup_restore_exporting)));
+ mZipBackupJob = new ZipBackupExportJob(GBApplication.getContext(), zipBackupCallback, uri);
+ break;
+ default:
+ LOG.error("Unknown action {}", action);
+ finish();
+ return;
+ }
+
+ mThread = new Thread(mZipBackupJob, "gb-backup-restore");
+ mThread.start();
+
+ getOnBackPressedDispatcher().addCallback(new OnBackPressedCallback(true) {
+ @Override
+ public void handleOnBackPressed() {
+ confirmExit();
+ }
+ });
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ confirmExit();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void confirmExit() {
+ if (jobFinished) {
+ finish();
+ return;
+ }
+
+ final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this)
+ .setCancelable(true)
+ .setIcon(R.drawable.ic_warning)
+ .setTitle(R.string.backup_restore_abort_title)
+ .setPositiveButton(R.string.backup_restore_abort_title, (dialog, which) -> {
+ if (mZipBackupJob != null) {
+ LOG.info("Aborting {}", action);
+ final Handler handler = new Handler(getMainLooper());
+ mZipBackupJob.abort();
+ new Thread(() -> {
+ try {
+ mThread.join(60_000);
+ } catch (final InterruptedException ignored) {
+ }
+ handler.post(() -> {
+ LOG.info("Aborted {}", action);
+ if ("export".equals(action)) {
+ // Delete the incomplete export file
+ final DocumentFile documentFile = DocumentFile.fromSingleUri(BackupRestoreProgressActivity.this, uri);
+ if (documentFile != null) {
+ documentFile.delete();
+ }
+ }
+ finish();
+ });
+ }).start();
+ }
+ })
+ .setNegativeButton(R.string.Cancel, (dialog, which) -> {
+ });
+
+ if ("import".equals(action)) {
+ builder.setMessage(R.string.backup_restore_abort_import_confirmation);
+ } else {
+ builder.setMessage(R.string.backup_restore_abort_export_confirmation);
+ }
+
+ builder.show();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BatteryInfoActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BatteryInfoActivity.java
new file mode 100644
index 0000000..dac71e1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BatteryInfoActivity.java
@@ -0,0 +1,241 @@
+/* Copyright (C) 2021-2024 Daniel Dakhno, Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities;
+
+import android.app.DatePickerDialog;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.DatePicker;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Calendar;
+import java.util.Date;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+
+public class BatteryInfoActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(BatteryInfoActivity.class);
+ GBDevice gbDevice;
+ private int timeFrom;
+ private int timeTo;
+ private int batteryIndex = 0;
+ TextView battery_status_battery_level_text;
+ TextView battery_status_battery_voltage;
+ LocalBroadcastManager localBroadcastManager;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+
+ super.onCreate(savedInstanceState);
+
+ final Context appContext = this.getApplicationContext();
+ if (appContext instanceof GBApplication) {
+ setContentView(R.layout.activity_battery_info);
+ }
+
+ Intent intent = getIntent();
+ Bundle bundle = intent.getExtras();
+ if (bundle != null) {
+ gbDevice = bundle.getParcelable(GBDevice.EXTRA_DEVICE);
+ batteryIndex = bundle.getInt(GBDevice.BATTERY_INDEX, 0);
+ } else {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+
+ localBroadcastManager = LocalBroadcastManager.getInstance(this);
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(GBDevice.ACTION_DEVICE_CHANGED);
+ localBroadcastManager.registerReceiver(commandReceiver, filter);
+
+ final BatteryInfoChartFragment batteryInfoChartFragment = new BatteryInfoChartFragment();
+
+ getSupportFragmentManager()
+ .beginTransaction()
+ .replace(R.id.batteryChartFragmentHolder, batteryInfoChartFragment)
+ .commit();
+
+ timeTo = (int) (System.currentTimeMillis() / 1000);
+
+ batteryInfoChartFragment.setDateAndGetData(gbDevice, batteryIndex, timeFrom, timeTo);
+
+ TextView battery_status_device_name_text = (TextView) findViewById(R.id.battery_status_device_name);
+ battery_status_battery_voltage = (TextView) findViewById(R.id.battery_status_battery_voltage);
+ TextView battery_status_extra_name = (TextView) findViewById(R.id.battery_status_extra_name);
+ final TextView battery_status_date_from_text = (TextView) findViewById(R.id.battery_status_date_from_text);
+ final TextView battery_status_date_to_text = (TextView) findViewById(R.id.battery_status_date_to_text);
+ final SeekBar battery_status_time_span_seekbar = (SeekBar) findViewById(R.id.battery_status_time_span_seekbar);
+ final TextView battery_status_time_span_text = (TextView) findViewById(R.id.battery_status_time_span_text);
+
+ LinearLayout battery_status_date_to_layout = (LinearLayout) findViewById(R.id.battery_status_date_to_layout);
+
+ battery_status_time_span_seekbar.setMax(5);
+ battery_status_time_span_seekbar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
+ String text;
+ switch (i) {
+ case 0:
+ text = getString(R.string.calendar_day);
+ timeFrom = DateTimeUtils.shiftDays(timeTo, -1);
+ break;
+ case 1:
+ text = getString(R.string.calendar_week);
+ timeFrom = DateTimeUtils.shiftDays(timeTo, -7);
+ break;
+ case 2:
+ text = getString(R.string.calendar_two_weeks);
+ timeFrom = DateTimeUtils.shiftDays(timeTo, -14);
+ break;
+ case 3:
+ text = getString(R.string.calendar_month);
+ timeFrom = DateTimeUtils.shiftMonths(timeTo, -1);
+ break;
+ case 4:
+ text = getString(R.string.calendar_six_months);
+ timeFrom = DateTimeUtils.shiftMonths(timeTo, -6);
+ break;
+ case 5:
+ text = getString(R.string.calendar_year);
+ timeFrom = DateTimeUtils.shiftMonths(timeTo, -12);
+ break;
+ default:
+ text = getString(R.string.calendar_two_weeks);
+ timeFrom = DateTimeUtils.shiftDays(timeTo, -14);
+ }
+
+ battery_status_time_span_text.setText(text);
+ battery_status_date_from_text.setText(DateTimeUtils.formatDate(new Date(timeFrom * 1000L)));
+ battery_status_date_to_text.setText(DateTimeUtils.formatDate(new Date(timeTo * 1000L)));
+ batteryInfoChartFragment.setDateAndGetData(gbDevice, batteryIndex, timeFrom, timeTo);
+ }
+
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+
+ }
+ });
+
+ //Button battery_status_calendar_button = findViewById(R.id.battery_status_calendar_button);
+ battery_status_date_to_layout.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+
+ final Calendar currentDate = Calendar.getInstance();
+ currentDate.setTimeInMillis(timeTo * 1000L);
+ Context context = getApplicationContext();
+
+ if (context instanceof GBApplication) {
+ new DatePickerDialog(BatteryInfoActivity.this, new DatePickerDialog.OnDateSetListener() {
+ @Override
+ public void onDateSet(DatePicker view, int year, int monthOfYear, int dayOfMonth) {
+
+ Calendar date = Calendar.getInstance();
+ date.set(year, monthOfYear, dayOfMonth);
+ timeTo = (int) (date.getTimeInMillis() / 1000);
+ battery_status_date_to_text.setText(DateTimeUtils.formatDate(new Date(timeTo * 1000L)));
+ battery_status_time_span_seekbar.setProgress(0);
+ battery_status_time_span_seekbar.setProgress(1);
+
+ batteryInfoChartFragment.setDateAndGetData(gbDevice, batteryIndex, timeFrom, timeTo);
+ }
+ }, currentDate.get(Calendar.YEAR), currentDate.get(Calendar.MONTH), currentDate.get(Calendar.DATE)).show();
+ }
+ }
+ });
+
+
+ battery_status_time_span_seekbar.setProgress(2);
+
+ DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
+
+ ImageView battery_status_device_icon = findViewById(R.id.battery_status_device_icon);
+ battery_status_device_icon.setImageResource(gbDevice.getEnabledDisabledIconResource());
+ battery_status_battery_level_text = (TextView) findViewById(R.id.battery_status_battery_level);
+ battery_status_device_name_text.setText(gbDevice.getAliasOrName());
+
+ setBatteryLabels();
+ for (BatteryConfig batteryConfig : coordinator.getBatteryConfig(gbDevice)) {
+ if (batteryConfig.getBatteryIndex() == batteryIndex) {
+ if (batteryConfig.getBatteryLabel() != GBDevice.BATTERY_LABEL_DEFAULT) {
+ battery_status_extra_name.setText(batteryConfig.getBatteryLabel());
+ }
+ if (batteryConfig.getBatteryIcon() != GBDevice.BATTERY_ICON_DEFAULT) {
+ battery_status_device_icon.setImageResource(batteryConfig.getBatteryIcon());
+ if (gbDevice.isInitialized()) {
+ battery_status_device_icon.setColorFilter(this.getResources().getColor(R.color.accent));
+ }
+ }
+ }
+ }
+ }
+
+ private void setBatteryLabels() {
+ String level = gbDevice.getBatteryLevel(batteryIndex) > 0 ? String.format("%1s%%", gbDevice.getBatteryLevel(batteryIndex)) : "";
+ String voltage = gbDevice.getBatteryVoltage(batteryIndex) > 0 ? String.format("%1sV", gbDevice.getBatteryVoltage(batteryIndex)) : "";
+ battery_status_battery_level_text.setText(level);
+ battery_status_battery_voltage.setText(voltage);
+ }
+
+ BroadcastReceiver commandReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ LOG.debug("device receiver received " + intent.getAction());
+ if (intent.getAction().equals(GBDevice.ACTION_DEVICE_CHANGED)) {
+ GBDevice newDevice = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (newDevice.equals(gbDevice)) {
+ gbDevice = newDevice;
+ setBatteryLabels();
+ }
+
+ }
+ }
+ };
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(commandReceiver);
+ }
+
+}
+
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BatteryInfoChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BatteryInfoChartFragment.java
new file mode 100644
index 0000000..b9ea582
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/BatteryInfoChartFragment.java
@@ -0,0 +1,292 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.Legend;
+import com.github.mikephil.charting.components.MarkerView;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.ChartData;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+import com.github.mikephil.charting.highlight.Highlight;
+import com.github.mikephil.charting.utils.MPPointF;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.List;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.DefaultChartsData;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.TimestampTranslation;
+import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.entities.BatteryLevel;
+import nodomain.freeyourgadget.gadgetbridge.entities.BatteryLevelDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+
+public class BatteryInfoChartFragment extends AbstractGBFragment {
+ private static final Logger LOG = LoggerFactory.getLogger(BatteryInfoChartFragment.class);
+ protected int BACKGROUND_COLOR;
+ protected int DESCRIPTION_COLOR;
+ protected int CHART_TEXT_COLOR;
+ protected int LEGEND_TEXT_COLOR;
+ protected String BATTERY_LABEL;
+
+ private LineChart mChart;
+ private int startTime;
+ private int endTime;
+ private GBDevice gbDevice;
+ private int batteryIndex;
+
+ public void setDateAndGetData(GBDevice gbDevice, int batteryIndex, long startTime, long endTime) {
+ this.startTime = (int) startTime;
+ this.endTime = (int) endTime;
+ this.gbDevice = gbDevice;
+ this.batteryIndex = batteryIndex;
+ try {
+ createRefreshTask("Visualizing data", getActivity()).execute();
+ } catch (Exception e) {
+ LOG.debug("Unable to fill charts data right now:", e);
+ }
+ }
+
+
+ protected RefreshTask createRefreshTask(String task, Context context) {
+ return new RefreshTask(task, context);
+ }
+
+ private DefaultBatteryChartsData fill_dcd(List<? extends BatteryLevel> samples) {
+ TimestampTranslation tsTranslation = new TimestampTranslation();
+ List<Entry> entries = new ArrayList<Entry>();
+ int firstTs = 0;
+
+ for (BatteryLevel sample : samples) {
+ entries.add(new Entry(tsTranslation.shorten(sample.getTimestamp()), sample.getLevel()));
+ if (firstTs == 0) {
+ firstTs = sample.getTimestamp();
+ }
+ }
+
+ LineDataSet dataSet = new LineDataSet(entries, BATTERY_LABEL);
+ dataSet.setLineWidth(2.2f);
+ dataSet.setMode(LineDataSet.Mode.HORIZONTAL_BEZIER);
+ dataSet.setCubicIntensity(0.1f);
+ dataSet.setDrawCircles(false);
+ dataSet.setCircleRadius(2f);
+ dataSet.setDrawValues(true);
+ dataSet.setValueTextColor(CHART_TEXT_COLOR);
+ dataSet.setHighlightEnabled(true);
+ dataSet.setHighlightEnabled(true);
+ LineData lineData = new LineData(dataSet);
+
+ return new DefaultBatteryChartsData(lineData, new customFormatter(tsTranslation), firstTs);
+
+ }
+
+ private void init() {
+ BACKGROUND_COLOR = GBApplication.getBackgroundColor(getContext());
+ LEGEND_TEXT_COLOR = DESCRIPTION_COLOR = GBApplication.getTextColor(getContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext());
+ BATTERY_LABEL = getString(R.string.battery_level);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ init();
+ View rootView = inflater.inflate(R.layout.fragment_charts, container, false);
+ mChart = rootView.findViewById(R.id.activitysleepchart);
+ if (this.gbDevice != null) {
+ setupChart();
+ createRefreshTask("Visualizing data", getActivity()).execute();
+ }
+ return rootView;
+ }
+
+ @Override
+ public String getTitle() {
+ return "";
+ }
+
+ private void setupChart() {
+ LEGEND_TEXT_COLOR = GBApplication.getTextColor(getContext());
+ mChart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ mChart.setBackgroundColor(BACKGROUND_COLOR);
+ mChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ mChart.setTouchEnabled(true);
+ mChart.getLegend().setHorizontalAlignment(Legend.LegendHorizontalAlignment.CENTER);
+ mChart.getDescription().setEnabled(false);
+
+ XAxis x = mChart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ mChart.getXAxis().setSpaceMin(0.5f);
+ x.setLabelCount(3);
+ x.setTextColor(CHART_TEXT_COLOR);
+
+ YAxis yAxisLeft = mChart.getAxisLeft();
+ yAxisLeft.setAxisMaximum(100L);
+ yAxisLeft.setAxisMinimum(0);
+ yAxisLeft.setEnabled(true);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+
+ YAxis yAxisRight = mChart.getAxisRight();
+ yAxisRight.setAxisMaximum(100L);
+ yAxisRight.setAxisMinimum(0);
+ yAxisRight.setEnabled(true);
+ yAxisRight.setTextColor(CHART_TEXT_COLOR);
+ }
+
+ private List<? extends BatteryLevel> getBatteryLevels(DBHandler db, GBDevice device, int batteryIndex, int tsFrom, int tsTo) {
+ BatteryLevelDao batteryLevelDao = db.getDaoSession().getBatteryLevelDao();
+ Device dbDevice = DBHelper.findDevice(device, db.getDaoSession());
+ QueryBuilder<BatteryLevel> qb = batteryLevelDao.queryBuilder();
+
+ qb.where(BatteryLevelDao.Properties.DeviceId.eq(dbDevice.getId())).orderAsc(BatteryLevelDao.Properties.Timestamp);
+ qb.where(BatteryLevelDao.Properties.BatteryIndex.eq(batteryIndex));
+ qb.where(BatteryLevelDao.Properties.Timestamp.gt(tsFrom));
+ qb.where(BatteryLevelDao.Properties.Timestamp.lt(tsTo));
+
+ List<BatteryLevel> levels = new ArrayList<>();
+ levels.addAll(qb.build().list());
+ return levels;
+ }
+
+ protected static class customFormatter extends ValueFormatter {
+ private final TimestampTranslation tsTranslation;
+ SimpleDateFormat annotationDateFormat = new SimpleDateFormat("dd.MM HH:mm");
+ Calendar cal = GregorianCalendar.getInstance();
+
+ public customFormatter(TimestampTranslation tsTranslation) {
+ this.tsTranslation = tsTranslation;
+ }
+
+ @Override
+ public String getFormattedValue(float value) {
+ cal.clear();
+ int ts = (int) value;
+ cal.setTimeInMillis(tsTranslation.toOriginalValue(ts) * 1000L);
+ Date date = cal.getTime();
+ return annotationDateFormat.format(date);
+ }
+ }
+
+ public class RefreshTask extends DBAccess {
+
+ public RefreshTask(String task, Context context) {
+ super(task, context);
+ }
+
+ @Override
+ protected void doInBackground(DBHandler handler) {
+ List<? extends BatteryLevel> samples = getBatteryLevels(handler, gbDevice, batteryIndex, startTime, endTime);
+ DefaultBatteryChartsData dcd = null;
+ try {
+ dcd = fill_dcd(samples);
+ } catch (Exception e) {
+ LOG.debug("Unable to get charts data right now:", e);
+ }
+ if (dcd != null && mChart != null) {
+ mChart.setTouchEnabled(true);
+ mChart.setMarker(new batteryValuesAndDateMarker(getContext(), R.layout.custom_chart_marker, dcd.firstTs));
+ mChart.getXAxis().setValueFormatter(dcd.getXValueFormatter());
+ mChart.setData((LineData) dcd.getData());
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Object o) {
+ mChart.invalidate();
+ }
+ }
+
+ private class DefaultBatteryChartsData extends DefaultChartsData {
+ public int firstTs;
+
+ public DefaultBatteryChartsData(ChartData data, ValueFormatter xValueFormatter, int ts) {
+ super(data, xValueFormatter);
+ firstTs = ts;
+ }
+ }
+
+
+ public class batteryValuesAndDateMarker extends MarkerView {
+
+ customFormatter formatter;
+ private TextView top_text;
+ private TextView bottom_text;
+ private MPPointF mOffset;
+ private int firstTs;
+
+ public batteryValuesAndDateMarker(Context context, int layoutResource, int ts) {
+ super(context, layoutResource);
+ TimestampTranslation tsTranslation = new TimestampTranslation();
+ formatter = new customFormatter(tsTranslation);
+ top_text = (TextView) findViewById(R.id.chart_marker_item_top);
+ bottom_text = (TextView) findViewById(R.id.chart_marker_item_bottom);
+ firstTs = ts;
+ }
+
+ // callbacks everytime the MarkerView is redrawn, can be used to update the
+ // content (user-interface)
+ @Override
+ public void refreshContent(Entry e, Highlight highlight) {
+
+ top_text.setText(String.format("%1s%%", (int) e.getY()));
+ bottom_text.setText(formatter.getFormattedValue(e.getX() + firstTs));
+
+ // this will perform necessary layouting
+ super.refreshContent(e, highlight);
+ }
+
+ @Override
+ public MPPointF getOffset() {
+
+ if (mOffset == null) {
+ // center the marker horizontally and vertically
+ mOffset = new MPPointF(-(getWidth() / 2) + 20, -getHeight() - 10);
+ }
+ return mOffset;
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CalBlacklistActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CalBlacklistActivity.java
new file mode 100644
index 0000000..6c849b4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CalBlacklistActivity.java
@@ -0,0 +1,171 @@
+/* Copyright (C) 2017-2024 Carsten Pfeiffer, Daniele Gobbetti, Jos茅 Rebelo,
+ Ludovic Jozeau
+
+ 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.activities;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.database.Cursor;
+import android.graphics.Paint;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.CalendarContract;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.CheckBox;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.app.ActivityCompat;
+import androidx.core.app.NavUtils;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarManager;
+
+
+public class CalBlacklistActivity extends AbstractGBActivity {
+
+ private final String[] EVENT_PROJECTION = new String[]{
+ CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
+ CalendarContract.Calendars.ACCOUNT_NAME,
+ CalendarContract.Calendars.CALENDAR_COLOR
+ };
+ private ArrayList<Calendar> calendarsArrayList;
+
+ private GBDevice gbDevice;
+ private CalendarManager calendarManager;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_calblacklist);
+ ListView calListView = (ListView) findViewById(R.id.calListView);
+
+ gbDevice = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ calendarManager = new CalendarManager(this, gbDevice.getAddress());
+
+ final Uri uri = CalendarContract.Calendars.CONTENT_URI;
+ if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) {
+ GB.toast(this, "Calendar permission not granted. Nothing to do.", Toast.LENGTH_SHORT, GB.WARN);
+ return;
+ }
+ try (Cursor cur = getContentResolver().query(uri, EVENT_PROJECTION, null, null, null)) {
+ calendarsArrayList = new ArrayList<>();
+ while (cur != null && cur.moveToNext()) {
+ calendarsArrayList.add(new Calendar(cur.getString(0), cur.getString(1), cur.getInt(2)));
+ }
+ }
+
+ ArrayAdapter<Calendar> calAdapter = new CalendarListAdapter(this, calendarsArrayList);
+ calListView.setAdapter(calAdapter);
+ calListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int i, long id) {
+ Calendar item = calendarsArrayList.get(i);
+ CheckBox selected = (CheckBox) view.findViewById(R.id.item_checkbox);
+ toggleEntry(view);
+ if (selected.isChecked()) {
+ calendarManager.addCalendarToBlacklist(item.getUniqueString());
+ } else {
+ calendarManager.removeFromCalendarBlacklist(item.getUniqueString());
+ }
+ }
+ });
+
+
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void toggleEntry(View view) {
+ TextView name = (TextView) view.findViewById(R.id.calendar_name);
+ CheckBox checked = (CheckBox) view.findViewById(R.id.item_checkbox);
+
+ name.setPaintFlags(name.getPaintFlags() ^ Paint.STRIKE_THRU_TEXT_FLAG);
+ checked.toggle();
+ }
+
+ class Calendar {
+ private final String displayName;
+ private final String accountName;
+ private final int color;
+
+ public Calendar(String displayName, String accountName, int color) {
+ this.displayName = displayName;
+ this.accountName = accountName;
+ this.color = color;
+ }
+
+ public String getUniqueString() {
+ return accountName + '/' + displayName;
+ }
+ }
+
+ private class CalendarListAdapter extends ArrayAdapter<Calendar> {
+
+ CalendarListAdapter(@NonNull Context context, @NonNull List<Calendar> calendars) {
+ super(context, 0, calendars);
+ }
+
+ @NonNull
+ @Override
+ public View getView(int position, @Nullable View view, @NonNull ViewGroup parent) {
+ Calendar item = getItem(position);
+
+ if (view == null) {
+ LayoutInflater inflater = (LayoutInflater) super.getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ view = inflater.inflate(R.layout.item_cal_blacklist, parent, false);
+ }
+
+ View color = view.findViewById(R.id.calendar_color);
+ TextView name = (TextView) view.findViewById(R.id.calendar_name);
+ TextView ownerAccount = (TextView) view.findViewById(R.id.calendar_owner_account);
+ CheckBox checked = (CheckBox) view.findViewById(R.id.item_checkbox);
+
+ if (calendarManager.calendarIsBlacklisted(item.getUniqueString()) && !checked.isChecked() ||
+ !calendarManager.calendarIsBlacklisted(item.getUniqueString()) && checked.isChecked()) {
+ toggleEntry(view);
+ }
+ color.setBackgroundColor(item.color);
+ name.setText(item.displayName);
+ ownerAccount.setText(item.accountName);
+
+ return view;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CameraActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CameraActivity.java
new file mode 100644
index 0000000..a481dc3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CameraActivity.java
@@ -0,0 +1,218 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.activities;
+
+import android.Manifest;
+import android.content.ContentValues;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.provider.MediaStore;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultCallback;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.camera.core.CameraSelector;
+import androidx.camera.core.ImageCapture;
+import androidx.camera.core.ImageCaptureException;
+import androidx.camera.core.Preview;
+import androidx.camera.lifecycle.ProcessCameraProvider;
+import androidx.camera.view.PreviewView;
+import androidx.core.content.ContextCompat;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.ExecutionException;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCameraRemote;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class CameraActivity extends AppCompatActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(CameraActivity.class);
+
+ public static final String intentExtraEvent = "EVENT";
+
+ private ListenableFuture<ProcessCameraProvider> cameraProviderListenableFuture;
+ private ImageCapture imageCapture;
+
+ private boolean reportClosing = true;
+
+ public static boolean supportsCamera() {
+ return GBApplication.getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY);
+ }
+
+ public static boolean hasCameraPermission() {
+ return GBApplication.getContext().checkCallingOrSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED;
+ }
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_camera);
+
+ if (!supportsCamera()) {
+ LOG.error("No camera support");
+ GB.toast(getString(R.string.toast_camera_support_required), Toast.LENGTH_SHORT, GB.ERROR);
+ GBApplication.deviceService().onCameraStatusChange(GBDeviceEventCameraRemote.Event.EXCEPTION, null);
+ reportClosing = false;
+ finish();
+ return;
+ }
+
+ if (!hasCameraPermission()) {
+ LOG.info("Requesting camera permission");
+
+ ActivityResultLauncher<String> requestPermissionLauncher =
+ registerForActivityResult(new ActivityResultContracts.RequestPermission(), new ActivityResultCallback<Boolean>() {
+ @Override
+ public void onActivityResult(Boolean isGranted) {
+ if (isGranted) {
+ initCamera();
+ } else {
+ LOG.error("Did not receive camera permission");
+ GB.toast(getString(R.string.toast_camera_permission_required), Toast.LENGTH_SHORT, GB.ERROR);
+ GBApplication.deviceService().onCameraStatusChange(GBDeviceEventCameraRemote.Event.EXCEPTION, null);
+ reportClosing = false;
+ finish();
+ }
+ }
+ });
+ requestPermissionLauncher.launch(Manifest.permission.CAMERA);
+ return;
+ }
+
+ initCamera();
+ }
+
+ private void initCamera() {
+ cameraProviderListenableFuture = ProcessCameraProvider.getInstance(this);
+ cameraProviderListenableFuture.addListener(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ ProcessCameraProvider cameraProvider = cameraProviderListenableFuture.get();
+
+ PreviewView previewView = findViewById(R.id.preview);
+
+ Preview preview = new Preview.Builder().build();
+
+ CameraSelector cameraSelector = new CameraSelector.Builder()
+ .requireLensFacing(CameraSelector.LENS_FACING_BACK) // TODO: make setting
+ .build();
+
+ preview.setSurfaceProvider(previewView.getSurfaceProvider());
+
+ imageCapture = new ImageCapture.Builder()
+ .setTargetRotation(preview.getTargetRotation())
+ .build();
+
+ cameraProvider.bindToLifecycle(
+ CameraActivity.this,
+ cameraSelector,
+ imageCapture,
+ preview
+ );
+ } catch (ExecutionException | InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }, ContextCompat.getMainExecutor(this));
+
+ handleIntent(getIntent());
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ handleIntent(intent);
+ }
+
+ private void handleIntent(Intent intent) {
+ if (!intent.hasExtra(intentExtraEvent)) {
+ this.finish();
+ return;
+ }
+
+ GBDeviceEventCameraRemote.Event event = GBDeviceEventCameraRemote.intToEvent(intent.getIntExtra(intentExtraEvent, 0));
+
+ LOG.info("Camera received event: " + event.name());
+
+ // Nothing to do for unknown events
+
+ if (event == GBDeviceEventCameraRemote.Event.CLOSE_CAMERA) {
+ finish();
+ } else if (event == GBDeviceEventCameraRemote.Event.OPEN_CAMERA) {
+ GBApplication.deviceService().onCameraStatusChange(GBDeviceEventCameraRemote.Event.OPEN_CAMERA, null);
+ } else if (event == GBDeviceEventCameraRemote.Event.TAKE_PICTURE) {
+ ContentValues contentValues = new ContentValues();
+ contentValues.put(MediaStore.Images.Media.TITLE, "Gadgetbridge photo");
+ contentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg");
+
+ ImageCapture.OutputFileOptions outputFileOptions = new ImageCapture.OutputFileOptions.Builder(
+ getContentResolver(),
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+ contentValues
+ ).build();
+ imageCapture.takePicture(outputFileOptions, ContextCompat.getMainExecutor(this), new ImageCapture.OnImageSavedCallback() {
+ @Override
+ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
+ if (outputFileResults.getSavedUri() == null) {
+ // Shouldn't ever happen
+ GBApplication.deviceService().onCameraStatusChange(GBDeviceEventCameraRemote.Event.EXCEPTION, null);
+ return;
+ }
+
+ // TODO: improve feedback that the photo has been taken
+ GB.toast(
+ String.format(getString(R.string.toast_camera_photo_taken),
+ outputFileResults.getSavedUri().getPath()),
+ Toast.LENGTH_LONG,
+ GB.INFO
+ );
+
+ GBApplication.deviceService().onCameraStatusChange(
+ GBDeviceEventCameraRemote.Event.TAKE_PICTURE,
+ outputFileResults.getSavedUri().getPath()
+ );
+ }
+
+ @Override
+ public void onError(@NonNull ImageCaptureException exception) {
+ LOG.error("Failed to save image", exception);
+ GBApplication.deviceService().onCameraStatusChange(GBDeviceEventCameraRemote.Event.EXCEPTION, null);
+ }
+ });
+ }
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+
+ if (reportClosing)
+ GBApplication.deviceService().onCameraStatusChange(GBDeviceEventCameraRemote.Event.CLOSE_CAMERA, null);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureAlarms.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureAlarms.java
new file mode 100644
index 0000000..7b3f545
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureAlarms.java
@@ -0,0 +1,194 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien
+ Gaignon, Daniel Dakhno, Daniele Gobbetti, Dmitry Markin, Jos茅 Rebelo,
+ Lem Dulfo, Petr Van臎k
+
+ 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.activities;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.annotation.NonNull;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.adapter.GBAlarmListAdapter;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+
+
+public class ConfigureAlarms extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(ConfigureAlarms.class);
+
+ private static final int REQ_CONFIGURE_ALARM = 1;
+
+ private GBAlarmListAdapter mGBAlarmListAdapter;
+ private boolean avoidSendAlarmsToDevice;
+ private GBDevice gbDevice;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_configure_alarms);
+
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(DeviceService.ACTION_SAVE_ALARMS);
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filterLocal);
+
+ gbDevice = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+
+ mGBAlarmListAdapter = new GBAlarmListAdapter(this);
+
+ RecyclerView alarmsRecyclerView = findViewById(R.id.alarm_list);
+ alarmsRecyclerView.setHasFixedSize(true);
+ alarmsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+ alarmsRecyclerView.setAdapter(mGBAlarmListAdapter);
+ updateAlarmsFromDB();
+ }
+
+ @Override
+ protected void onPause() {
+ if (!avoidSendAlarmsToDevice && gbDevice.isInitialized()) {
+ sendAlarmsToDevice();
+ }
+ super.onPause();
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == REQ_CONFIGURE_ALARM) {
+ avoidSendAlarmsToDevice = false;
+ updateAlarmsFromDB();
+ }
+ }
+
+ /**
+ * Reads the available alarms from the database and updates the view afterwards.
+ */
+ private void updateAlarmsFromDB() {
+ List<Alarm> alarms = DBHelper.getAlarms(getGbDevice());
+ if (alarms.isEmpty()) {
+ alarms = AlarmUtils.readAlarmsFromPrefs(getGbDevice());
+ storeMigratedAlarms(alarms);
+ }
+ addMissingAlarms(alarms);
+
+ mGBAlarmListAdapter.setAlarmList(alarms);
+ mGBAlarmListAdapter.notifyDataSetChanged();
+ }
+
+ private void storeMigratedAlarms(List<Alarm> alarms) {
+ for (Alarm alarm : alarms) {
+ DBHelper.store(alarm);
+ }
+ }
+
+ private void addMissingAlarms(List<Alarm> alarms) {
+ DeviceCoordinator coordinator = getGbDevice().getDeviceCoordinator();
+ int supportedNumAlarms = coordinator.getAlarmSlotCount(getGbDevice());
+ if (supportedNumAlarms > alarms.size()) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ for (int position = 0; position < supportedNumAlarms; position++) {
+ boolean found = false;
+ for (Alarm alarm : alarms) {
+ if (alarm.getPosition() == position) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ LOG.info("adding missing alarm at position " + position);
+ alarms.add(position, AlarmUtils.createDefaultAlarm(daoSession, getGbDevice(), position));
+ }
+ }
+ } catch (Exception e) {
+ LOG.error("Error accessing database", e);
+ }
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ public void configureAlarm(Alarm alarm) {
+ avoidSendAlarmsToDevice = true;
+ Intent startIntent = new Intent(getApplicationContext(), AlarmDetails.class);
+ startIntent.putExtra(Alarm.EXTRA_ALARM, alarm);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, getGbDevice());
+ startActivityForResult(startIntent, REQ_CONFIGURE_ALARM);
+ }
+
+ private GBDevice getGbDevice() {
+ return gbDevice;
+ }
+
+ private void sendAlarmsToDevice() {
+ GBApplication.deviceService(gbDevice).onSetAlarms(mGBAlarmListAdapter.getAlarmList());
+ }
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+
+ switch (action) {
+ case DeviceService.ACTION_SAVE_ALARMS: {
+ updateAlarmsFromDB();
+ break;
+ }
+ }
+ }
+ };
+
+ @Override
+ protected void onDestroy() {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
+ super.onDestroy();
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureContacts.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureContacts.java
new file mode 100644
index 0000000..c083e6c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureContacts.java
@@ -0,0 +1,173 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.activity.result.ActivityResult;
+import androidx.activity.result.ActivityResultCallback;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.adapter.GBContactListAdapter;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.Contact;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+
+public class ConfigureContacts extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(ConfigureContacts.class);
+
+ private GBContactListAdapter mGBContactListAdapter;
+ private GBDevice gbDevice;
+
+ private ActivityResultLauncher<Intent> configureContactLauncher;
+ private final ActivityResultCallback<ActivityResult> configureContactCallback = result -> {
+ if (result.getResultCode() == Activity.RESULT_OK) {
+ updateContactsFromDB();
+ sendContactsToDevice();
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_configure_contacts);
+
+ gbDevice = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+
+ configureContactLauncher = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ configureContactCallback
+ );
+
+ mGBContactListAdapter = new GBContactListAdapter(this);
+
+ final RecyclerView contactsRecyclerView = findViewById(R.id.contact_list);
+ contactsRecyclerView.setHasFixedSize(true);
+ contactsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+ contactsRecyclerView.setAdapter(mGBContactListAdapter);
+ updateContactsFromDB();
+
+ final FloatingActionButton fab = findViewById(R.id.fab);
+ fab.setOnClickListener(v -> {
+ final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
+
+ int deviceSlots = coordinator.getContactsSlotCount(gbDevice);
+
+ if (mGBContactListAdapter.getItemCount() >= deviceSlots) {
+ // No more free slots
+ new MaterialAlertDialogBuilder(v.getContext())
+ .setTitle(R.string.reminder_no_free_slots_title)
+ .setMessage(getBaseContext().getString(R.string.contact_no_free_slots_description, String.format(Locale.getDefault(), "%d", deviceSlots)))
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
+ })
+ .show();
+ return;
+ }
+
+ final Contact contact;
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final Device device = DBHelper.getDevice(gbDevice, daoSession);
+ final User user = DBHelper.getUser(daoSession);
+ contact = createDefaultContact(device, user);
+ } catch (final Exception e) {
+ LOG.error("Error accessing database", e);
+ return;
+ }
+
+ configureContact(contact);
+ });
+ }
+
+ private Contact createDefaultContact(@NonNull Device device, @NonNull User user) {
+ final Contact contact = new Contact();
+ contact.setName("");
+ contact.setNumber("");
+ contact.setDeviceId(device.getId());
+ contact.setUserId(user.getId());
+ contact.setContactId(UUID.randomUUID().toString());
+
+ return contact;
+ }
+
+ /**
+ * Reads the available contacts from the database and updates the view afterwards.
+ */
+ private void updateContactsFromDB() {
+ final List<Contact> contacts = DBHelper.getContacts(gbDevice);
+
+ mGBContactListAdapter.setContactList(contacts);
+ mGBContactListAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ public void configureContact(final Contact contact) {
+ final Intent startIntent = new Intent(getApplicationContext(), ContactDetails.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, gbDevice);
+ startIntent.putExtra(Contact.EXTRA_CONTACT, contact);
+ configureContactLauncher.launch(startIntent);
+ }
+
+ public void deleteContact(final Contact contact) {
+ DBHelper.delete(contact);
+ updateContactsFromDB();
+ sendContactsToDevice();
+ }
+
+ private void sendContactsToDevice() {
+ if (gbDevice.isInitialized()) {
+ GBApplication.deviceService(gbDevice).onSetContacts(mGBContactListAdapter.getContactList());
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureReminders.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureReminders.java
new file mode 100644
index 0000000..88db995
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureReminders.java
@@ -0,0 +1,210 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver, Daniel Dakhno, Jos茅 Rebelo,
+ Johannes Krude
+
+ 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.activities;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Calendar;
+import java.util.TimeZone;
+import java.util.List;
+import java.util.Locale;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.adapter.GBReminderListAdapter;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.Reminder;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+
+public class ConfigureReminders extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(ConfigureReminders.class);
+
+ private static final int REQ_CONFIGURE_REMINDER = 1;
+
+ private GBReminderListAdapter mGBReminderListAdapter;
+ private GBDevice gbDevice;
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (DeviceService.ACTION_SAVE_REMINDERS.equals(intent.getAction())) {
+ updateRemindersFromDB();
+ }
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_configure_reminders);
+
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(DeviceService.ACTION_SAVE_REMINDERS);
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filterLocal);
+
+ gbDevice = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+
+ mGBReminderListAdapter = new GBReminderListAdapter(this, gbDevice.getDeviceCoordinator().getRemindersHaveTime());
+
+ final RecyclerView remindersRecyclerView = findViewById(R.id.reminder_list);
+ remindersRecyclerView.setHasFixedSize(true);
+ remindersRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+ remindersRecyclerView.setAdapter(mGBReminderListAdapter);
+ updateRemindersFromDB();
+
+ final FloatingActionButton fab = findViewById(R.id.fab);
+ fab.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
+
+ int deviceSlots = coordinator.getReminderSlotCount(gbDevice) - GBApplication.getDevicePrefs(gbDevice).getReservedReminderCalendarSlots();
+
+ if (mGBReminderListAdapter.getItemCount() >= deviceSlots) {
+ // No more free slots
+ new MaterialAlertDialogBuilder(v.getContext())
+ .setTitle(R.string.reminder_no_free_slots_title)
+ .setMessage(getBaseContext().getString(R.string.reminder_no_free_slots_description, String.format(Locale.getDefault(), "%d", deviceSlots)))
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ public void onClick(final DialogInterface dialog, final int whichButton) {
+ }
+ })
+ .show();
+ return;
+ }
+
+ final Reminder reminder;
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final Device device = DBHelper.getDevice(gbDevice, daoSession);
+ final User user = DBHelper.getUser(daoSession);
+ reminder = createDefaultReminder(device, user);
+ } catch (final Exception e) {
+ LOG.error("Error accessing database", e);
+ return;
+ }
+
+ configureReminder(reminder);
+ }
+ });
+ }
+
+ @Override
+ protected void onDestroy() {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == REQ_CONFIGURE_REMINDER && resultCode == 1) {
+ updateRemindersFromDB();
+ sendRemindersToDevice();
+ }
+ }
+
+ private Reminder createDefaultReminder(@NonNull Device device, @NonNull User user) {
+ final Reminder reminder = new Reminder();
+ reminder.setRepetition(Reminder.ONCE);
+ if (gbDevice.getDeviceCoordinator().getRemindersHaveTime()) {
+ reminder.setDate(Calendar.getInstance().getTime());
+ } else {
+ Calendar noonUTC = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+ noonUTC.set(noonUTC.get(Calendar.YEAR), noonUTC.get(Calendar.MONTH), noonUTC.get(Calendar.DAY_OF_MONTH), 12, 0);
+ reminder.setDate(noonUTC.getTime());
+ }
+ reminder.setMessage("");
+ reminder.setDeviceId(device.getId());
+ reminder.setUserId(user.getId());
+ reminder.setReminderId(UUID.randomUUID().toString());
+
+ return reminder;
+ }
+
+ /**
+ * Reads the available reminders from the database and updates the view afterwards.
+ */
+ private void updateRemindersFromDB() {
+ final List<Reminder> reminders = DBHelper.getReminders(gbDevice);
+
+ mGBReminderListAdapter.setReminderList(reminders);
+ mGBReminderListAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ public void configureReminder(final Reminder reminder) {
+ final Intent startIntent = new Intent(getApplicationContext(), ReminderDetails.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, gbDevice);
+ startIntent.putExtra(Reminder.EXTRA_REMINDER, reminder);
+ startActivityForResult(startIntent, REQ_CONFIGURE_REMINDER);
+ }
+
+ public void deleteReminder(final Reminder reminder) {
+ DBHelper.delete(reminder);
+ updateRemindersFromDB();
+ sendRemindersToDevice();
+ }
+
+ private void sendRemindersToDevice() {
+ if (gbDevice.isInitialized()) {
+ GBApplication.deviceService(gbDevice).onSetReminders(mGBReminderListAdapter.getReminderList());
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureWorldClocks.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureWorldClocks.java
new file mode 100644
index 0000000..7a35ff1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureWorldClocks.java
@@ -0,0 +1,220 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.adapter.GBWorldClockListAdapter;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.entities.WorldClock;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+
+public class ConfigureWorldClocks extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(ConfigureWorldClocks.class);
+
+ private static final int REQ_CONFIGURE_WORLD_CLOCK = 1;
+
+ private GBWorldClockListAdapter mGBWorldClockListAdapter;
+ private GBDevice gbDevice;
+
+ private BroadcastReceiver timeTickBroadcastReceiver;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_configure_world_clocks);
+
+ gbDevice = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+
+ mGBWorldClockListAdapter = new GBWorldClockListAdapter(this);
+
+ final RecyclerView worldClocksRecyclerView = findViewById(R.id.world_clock_list);
+ worldClocksRecyclerView.setHasFixedSize(true);
+ worldClocksRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+ worldClocksRecyclerView.setAdapter(mGBWorldClockListAdapter);
+ updateWorldClocksFromDB();
+
+ final FloatingActionButton fab = findViewById(R.id.fab);
+ fab.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
+
+ int deviceSlots = coordinator.getWorldClocksSlotCount();
+
+ if (mGBWorldClockListAdapter.getItemCount() >= deviceSlots) {
+ // No more free slots
+ new MaterialAlertDialogBuilder(v.getContext())
+ .setTitle(R.string.world_clock_no_free_slots_title)
+ .setMessage(getBaseContext().getString(R.string.world_clock_no_free_slots_description, String.format(Locale.getDefault(), "%d", deviceSlots)))
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ public void onClick(final DialogInterface dialog, final int whichButton) {
+ }
+ })
+ .show();
+ return;
+ }
+
+ final WorldClock worldClock;
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final Device device = DBHelper.getDevice(gbDevice, daoSession);
+ final User user = DBHelper.getUser(daoSession);
+ worldClock = createDefaultWorldClock(device, user);
+ } catch (final Exception e) {
+ LOG.error("Error accessing database", e);
+ return;
+ }
+
+ configureWorldClock(worldClock);
+ }
+ });
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+
+ timeTickBroadcastReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(final Context context, Intent intent) {
+ if (Intent.ACTION_TIME_TICK.equals(intent.getAction())) {
+ // Refresh the UI, to update the current time in each timezone
+ mGBWorldClockListAdapter.notifyDataSetChanged();
+ }
+ }
+ };
+
+ ContextCompat.registerReceiver(this, timeTickBroadcastReceiver, new IntentFilter(Intent.ACTION_TIME_TICK), ContextCompat.RECEIVER_EXPORTED);
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+
+ if (timeTickBroadcastReceiver != null) {
+ unregisterReceiver(timeTickBroadcastReceiver);
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ // Refresh to update the current time on each clock
+ mGBWorldClockListAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == REQ_CONFIGURE_WORLD_CLOCK && resultCode == 1) {
+ updateWorldClocksFromDB();
+ sendWorldClocksToDevice();
+ }
+ }
+
+ private WorldClock createDefaultWorldClock(@NonNull Device device, @NonNull User user) {
+ final WorldClock worldClock = new WorldClock();
+ final String timezone = TimeZone.getDefault().getID();
+ worldClock.setEnabled(true);
+ worldClock.setTimeZoneId(timezone);
+ final String[] timezoneParts = timezone.split("/");
+ worldClock.setLabel(timezoneParts[timezoneParts.length - 1]);
+ worldClock.setCode(StringUtils.truncate(timezoneParts[timezoneParts.length - 1], 3).toUpperCase(Locale.getDefault()));
+
+ worldClock.setDeviceId(device.getId());
+ worldClock.setUserId(user.getId());
+ worldClock.setWorldClockId(UUID.randomUUID().toString());
+
+ return worldClock;
+ }
+
+ /**
+ * Reads the available worldClocks from the database and updates the view afterwards.
+ */
+ private void updateWorldClocksFromDB() {
+ final List<WorldClock> worldClocks = DBHelper.getWorldClocks(gbDevice);
+
+ mGBWorldClockListAdapter.setWorldClockList(worldClocks);
+ mGBWorldClockListAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ public void configureWorldClock(final WorldClock worldClock) {
+ final Intent startIntent = new Intent(getApplicationContext(), WorldClockDetails.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, gbDevice);
+ startIntent.putExtra(WorldClock.EXTRA_WORLD_CLOCK, worldClock);
+ startActivityForResult(startIntent, REQ_CONFIGURE_WORLD_CLOCK);
+ }
+
+ public void deleteWorldClock(final WorldClock worldClock) {
+ DBHelper.delete(worldClock);
+ updateWorldClocksFromDB();
+ sendWorldClocksToDevice();
+ }
+
+ private void sendWorldClocksToDevice() {
+ if (gbDevice.isInitialized()) {
+ GBApplication.deviceService(gbDevice).onSetWorldClocks(mGBWorldClockListAdapter.getWorldClockList());
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ContactDetails.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ContactDetails.java
new file mode 100644
index 0000000..df13be6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ContactDetails.java
@@ -0,0 +1,154 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.MenuItem;
+import android.widget.EditText;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.Contact;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+public class ContactDetails extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(ContactDetails.class);
+
+ private Contact contact;
+ private GBDevice device;
+
+ EditText contactName;
+ EditText contactNumber;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_contact_details);
+
+ contact = (Contact) getIntent().getSerializableExtra(Contact.EXTRA_CONTACT);
+
+ if (contact == null) {
+ GB.toast("No contact provided to ContactDetails Activity", Toast.LENGTH_LONG, GB.ERROR);
+ finish();
+ return;
+ }
+
+ contactName = findViewById(R.id.contact_name);
+ contactNumber = findViewById(R.id.contact_number);
+
+ device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+
+ contactName.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(final CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(final CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(final Editable s) {
+ contact.setName(s.toString());
+ }
+ });
+
+ contactNumber.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(final CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(final CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(final Editable s) {
+ contact.setNumber(s.toString());
+ }
+ });
+
+ final FloatingActionButton fab = findViewById(R.id.fab_save);
+ fab.setOnClickListener(view -> {
+ if (StringUtils.isNullOrEmpty(contact.getName())) {
+ GB.toast(getBaseContext().getString(R.string.contact_missing_name), Toast.LENGTH_LONG, GB.WARN);
+ return;
+ }
+
+ if (StringUtils.isNullOrEmpty(contact.getNumber())) {
+ GB.toast(getBaseContext().getString(R.string.contact_missing_number), Toast.LENGTH_LONG, GB.WARN);
+ return;
+ }
+
+ updateContact();
+ ContactDetails.this.setResult(Activity.RESULT_OK);
+ finish();
+ });
+
+ updateUiFromContact();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ // TODO confirm when exiting without saving
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void updateContact() {
+ DBHelper.store(contact);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull final Bundle state) {
+ super.onSaveInstanceState(state);
+ state.putSerializable("contact", contact);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ contact = (Contact) savedInstanceState.getSerializable("contact");
+ updateUiFromContact();
+ }
+
+ public void updateUiFromContact() {
+ contactName.setText(contact.getName());
+ contactNumber.setText(contact.getNumber());
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java
new file mode 100644
index 0000000..eea3a80
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java
@@ -0,0 +1,524 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Andrzej Surowiec, Arjan
+ Schrijver, Carsten Pfeiffer, Daniel Dakhno, Daniele Gobbetti, Ganblejs,
+ gfwilliams, Gordon Williams, Johannes Tysiak, Jos茅 Rebelo, marco.altomonte,
+ Petr Van臎k, Taavi Eom盲e
+
+ 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.activities;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_CONNECT;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.util.TypedValue;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.Toast;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.activity.OnBackPressedCallback;
+import androidx.appcompat.app.ActionBarDrawerToggle;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.core.view.GravityCompat;
+import androidx.core.view.MenuProvider;
+import androidx.drawerlayout.widget.DrawerLayout;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+import androidx.viewpager2.widget.ViewPager2;
+
+import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.bottomnavigation.BottomNavigationView;
+import com.google.android.material.navigation.NavigationView;
+import com.google.android.material.tabs.TabLayout;
+import com.google.android.material.tabs.TabLayoutMediator;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Serializable;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBChangeLog;
+import nodomain.freeyourgadget.gadgetbridge.util.PermissionsUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import nodomain.freeyourgadget.gadgetbridge.womp.WompKeepAliveService;
+import nodomain.freeyourgadget.gadgetbridge.womp.WompWorkbenchFragment;
+
+//TODO: extend AbstractGBActivity, but it requires actionbar that is not available
+public class ControlCenterv2 extends AppCompatActivity
+ implements NavigationView.OnNavigationItemSelectedListener, GBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(ControlCenterv2.class);
+ public static final int MENU_REFRESH_CODE = 1;
+ private boolean isLanguageInvalid = false;
+ private boolean isThemeInvalid = false;
+ private ViewPager2 viewPager;
+ private FragmentStateAdapter pagerAdapter;
+ private SwipeRefreshLayout swipeLayout;
+ private AlertDialog clDialog;
+ private TabLayout bandTabs;
+ private View workbenchContainer;
+ private BottomNavigationView navigationView;
+ private long lastBackPressedAt;
+
+ //needed for KK compatibility
+ static {
+ AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
+ }
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ switch (Objects.requireNonNull(action)) {
+ case GBApplication.ACTION_LANGUAGE_CHANGE:
+ setLanguage(GBApplication.getLanguage(), true);
+ break;
+ case GBApplication.ACTION_THEME_CHANGE:
+ isThemeInvalid = true;
+ break;
+ case GBApplication.ACTION_QUIT:
+ finish();
+ break;
+ case DeviceService.ACTION_REALTIME_SAMPLES:
+ final GBDevice device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ handleRealtimeSample(device, intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE));
+ break;
+ case GBDevice.ACTION_DEVICE_CHANGED:
+ GBDevice dev = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (dev != null && !dev.isBusy()) {
+ swipeLayout.setRefreshing(false);
+ }
+ break;
+ }
+ }
+ };
+ private boolean pesterWithPermissions = true;
+ private final Map<GBDevice, ActivitySample> currentHRSample = new HashMap<>();
+
+ public ActivitySample getCurrentHRSample(final GBDevice device) {
+ return currentHRSample.get(device);
+ }
+
+ private void setCurrentHRSample(final GBDevice device, ActivitySample sample) {
+ if (HeartRateUtils.getInstance().isValidHeartRateValue(sample.getHeartRate())) {
+ currentHRSample.put(device, sample);
+ }
+ }
+
+ private void handleRealtimeSample(final GBDevice device, Serializable extra) {
+ if (extra instanceof ActivitySample) {
+ ActivitySample sample = (ActivitySample) extra;
+ setCurrentHRSample(device, sample);
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ AbstractGBActivity.init(this, AbstractGBActivity.NO_ACTIONBAR);
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_main);
+ WompKeepAliveService.start(this);
+
+ Prefs prefs = GBApplication.getPrefs();
+
+ // Determine availability of device with activity tracking functionality
+ boolean activityTrackerAvailable = false;
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
+ for (GBDevice dev : devices) {
+ if (dev.getDeviceCoordinator().supportsActivityTracking()) {
+ activityTrackerAvailable = true;
+ break;
+ }
+ }
+ if (savedInstanceState != null) {
+ if (savedInstanceState.getBoolean("cl")) {
+ GBChangeLog cl = GBChangeLog.createChangeLog(this);
+ try {
+ if (cl.hasChanges(false)) {
+ clDialog = cl.getMaterialLogDialog();
+ } else {
+ clDialog = cl.getMaterialFullLogDialog();
+ }
+ clDialog.show();
+ } catch (Exception ignored) {
+ GB.toast(getBaseContext(), getString(R.string.error_showing_changelog), Toast.LENGTH_LONG, GB.ERROR);
+ }
+ }
+ }
+
+
+ // Initialize drawer
+ NavigationView drawerNavigationView = findViewById(R.id.nav_view);
+ drawerNavigationView.setNavigationItemSelectedListener(this);
+
+ // Initialize bottom navigation锛氭墜鐜� / 宸ヤ綔鍙颁袱涓《绾у叆鍙o紝浜掍笉渚濊禆鐧诲綍
+ navigationView = findViewById(R.id.bottom_nav_bar);
+ bandTabs = findViewById(R.id.band_tabs);
+ workbenchContainer = findViewById(R.id.workbench_container);
+ navigationView.setVisibility(View.VISIBLE);
+ navigationView.setOnItemSelectedListener(menuItem -> {
+ final int itemId = menuItem.getItemId();
+ if (itemId == R.id.bottom_nav_band) {
+ showBandContent();
+ } else if (itemId == R.id.bottom_nav_workbench) {
+ showWorkbenchContent();
+ }
+ return true;
+ });
+
+ if (savedInstanceState == null) {
+ getSupportFragmentManager().beginTransaction()
+ .replace(R.id.workbench_container, new WompWorkbenchFragment(), "womp_workbench")
+ .commit();
+ }
+
+ // Initialize actionbar
+ MaterialToolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+ DrawerLayout drawer = findViewById(R.id.drawer_layout);
+ ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(
+ this, drawer, toolbar, R.string.controlcenter_navigation_drawer_open, R.string.controlcenter_navigation_drawer_close);
+ drawer.setDrawerListener(toggle);
+ toggle.syncState();
+ if (GBApplication.areDynamicColorsEnabled()) {
+ TypedValue typedValue = new TypedValue();
+ Resources.Theme theme = getTheme();
+ theme.resolveAttribute(com.google.android.material.R.attr.colorSurface, typedValue, true);
+ @ColorInt int toolbarBackground = typedValue.data;
+ toolbar.setBackgroundColor(toolbarBackground);
+ } else {
+ toolbar.setBackgroundColor(getResources().getColor(R.color.primarydark_light));
+ toolbar.setTitleTextColor(getResources().getColor(android.R.color.white));
+ }
+
+ // Configure ViewPager2 with fragment adapter and default fragment
+ viewPager = findViewById(R.id.dashboard_viewpager);
+ pagerAdapter = new MainFragmentsPagerAdapter(this);
+ viewPager.setAdapter(pagerAdapter);
+ new TabLayoutMediator(bandTabs, viewPager, (tab, position) -> {
+ if (position == 0) {
+ tab.setText(R.string.bottom_nav_health);
+ } else {
+ tab.setText(R.string.bottom_nav_devices);
+ }
+ }).attach();
+ if (!prefs.getBoolean("dashboard_as_default_view", true) || !activityTrackerAvailable) {
+ viewPager.setCurrentItem(1, false);
+ }
+
+ // Sync ViewPager changes with fragment menus
+ viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
+ private MenuProvider existingMenuProvider = null;
+
+ @Override
+ public void onPageSelected(int position) {
+ // Ensure the menu provider is set to the current fragment
+ if (existingMenuProvider != null) {
+ ControlCenterv2.this.removeMenuProvider(existingMenuProvider);
+ }
+ final Fragment fragment = getSupportFragmentManager().findFragmentByTag("f" + position);
+ if (fragment instanceof MenuProvider) {
+ existingMenuProvider = (MenuProvider) fragment;
+ ControlCenterv2.this.addMenuProvider(existingMenuProvider);
+ }
+ }
+ });
+
+ // Make sure the SwipeRefreshLayout doesn't interfere with the ViewPager2
+ swipeLayout = findViewById(R.id.dashboard_swipe_layout);
+ if (viewPager.getChildAt(0) != null) {
+ viewPager.getChildAt(0).setOnTouchListener((v, event) -> {
+ if (event.getAction() == MotionEvent.ACTION_MOVE) {
+ swipeLayout.setEnabled(false);
+ } else {
+ swipeLayout.setEnabled(true);
+ }
+ return false;
+ });
+ }
+
+ // Set pull-down-to-refresh action
+ swipeLayout.setOnRefreshListener(() -> {
+ // Fetch activity for all connected devices
+ GBApplication.deviceService().onFetchRecordedData(RecordedDataTypes.TYPE_SYNC);
+ // Hide 'refreshing' animation immediately if no health devices are connected
+ List<GBDevice> devices1 = GBApplication.app().getDeviceManager().getDevices();
+ for (GBDevice dev : devices1) {
+ if (dev.getDeviceCoordinator().supportsActivityTracking() && dev.isInitialized()) {
+ return;
+ }
+ }
+ swipeLayout.setRefreshing(false);
+ GB.toast(getString(R.string.info_no_devices_connected), Toast.LENGTH_LONG, GB.WARN);
+ });
+
+ // Set up local intent listener
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(GBApplication.ACTION_LANGUAGE_CHANGE);
+ filterLocal.addAction(GBApplication.ACTION_THEME_CHANGE);
+ filterLocal.addAction(GBApplication.ACTION_QUIT);
+ filterLocal.addAction(DeviceService.ACTION_REALTIME_SAMPLES);
+ filterLocal.addAction(GBDevice.ACTION_DEVICE_CHANGED);
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filterLocal);
+
+ // 鍒嗘嫞鍛樼璺宠繃棣栨寮曞涓庢洿鏂版棩蹇�
+ boolean firstRun = prefs.getBoolean("first_run", true);
+ if (firstRun) {
+ prefs.getPreferences().edit().putBoolean("first_run", false).apply();
+ }
+ pesterWithPermissions = prefs.getBoolean("permission_pestering", true);
+ if (pesterWithPermissions && !PermissionsUtils.checkAllPermissions(this)) {
+ Intent permissionsIntent = new Intent(this, PermissionsActivity.class);
+ startActivity(permissionsIntent);
+ }
+
+ /*
+ GBChangeLog cl = GBChangeLog.createChangeLog(this);
+ boolean showChangelog = prefs.getBoolean("show_changelog", true);
+ if (showChangelog && cl.isFirstRun() && cl.hasChanges(cl.isFirstRunEver())) {
+ try {
+ cl.getMaterialLogDialog().show();
+ } catch (Exception ignored) {
+ GB.toast(this, getString(R.string.error_showing_changelog), Toast.LENGTH_LONG, GB.ERROR);
+ }
+ }
+ */
+
+ GBApplication.deviceService().requestDeviceInfo();
+
+ if (savedInstanceState != null && !savedInstanceState.getBoolean("womp_wb", true)) {
+ navigationView.setSelectedItemId(R.id.bottom_nav_band);
+ showBandContent();
+ } else {
+ navigationView.setSelectedItemId(R.id.bottom_nav_workbench);
+ showWorkbenchContent();
+ }
+
+ getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
+ @Override
+ public void handleOnBackPressed() {
+ handleMainBackPressed();
+ }
+ });
+ }
+
+ public void showBandTab() {
+ if (navigationView != null) {
+ navigationView.setSelectedItemId(R.id.bottom_nav_band);
+ } else {
+ showBandContent();
+ }
+ }
+
+ private void showBandContent() {
+ if (workbenchContainer != null) {
+ workbenchContainer.setVisibility(View.GONE);
+ }
+ if (swipeLayout != null) {
+ swipeLayout.setVisibility(View.VISIBLE);
+ }
+ if (bandTabs != null) {
+ bandTabs.setVisibility(View.VISIBLE);
+ }
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().setTitle(R.string.title_activity_controlcenter);
+ }
+ }
+
+ private void showWorkbenchContent() {
+ if (swipeLayout != null) {
+ swipeLayout.setRefreshing(false);
+ swipeLayout.setVisibility(View.GONE);
+ }
+ if (bandTabs != null) {
+ bandTabs.setVisibility(View.GONE);
+ }
+ if (workbenchContainer != null) {
+ workbenchContainer.setVisibility(View.VISIBLE);
+ }
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().setTitle(R.string.womp_workbench);
+ }
+ }
+
+ private void handleMainBackPressed() {
+ final DrawerLayout drawer = findViewById(R.id.drawer_layout);
+ if (drawer != null && drawer.isDrawerOpen(GravityCompat.START)) {
+ drawer.closeDrawer(GravityCompat.START);
+ return;
+ }
+ if (workbenchContainer != null && workbenchContainer.getVisibility() == View.VISIBLE) {
+ Fragment fragment = getSupportFragmentManager().findFragmentByTag("womp_workbench");
+ if (fragment instanceof WompWorkbenchFragment && ((WompWorkbenchFragment) fragment).goBack()) {
+ return;
+ }
+ }
+ long now = System.currentTimeMillis();
+ if (now - lastBackPressedAt < 2000) {
+ finishAffinity();
+ return;
+ }
+ lastBackPressedAt = now;
+ Toast.makeText(this, R.string.womp_press_again_to_exit, Toast.LENGTH_SHORT).show();
+ }
+
+ @Override
+ public void onSaveInstanceState(@NonNull Bundle outState) {
+ if (clDialog != null){
+ outState.putBoolean("cl", clDialog.isShowing());
+ }
+ if (workbenchContainer != null) {
+ outState.putBoolean("womp_wb", workbenchContainer.getVisibility() == View.VISIBLE);
+ }
+ super.onSaveInstanceState(outState);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ handleShortcut(getIntent());
+ if (isLanguageInvalid || isThemeInvalid) {
+ isLanguageInvalid = false;
+ isThemeInvalid = false;
+ recreate();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ try {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
+ } catch (Exception ignored) {
+ }
+ super.onDestroy();
+ }
+
+ @Override
+ public boolean onNavigationItemSelected(@NonNull final MenuItem item) {
+ final DrawerLayout drawer = findViewById(R.id.drawer_layout);
+ drawer.closeDrawer(GravityCompat.START);
+
+ final int itemId = item.getItemId();
+ if (itemId == R.id.action_womp_workbench) {
+ if (navigationView != null) {
+ navigationView.setSelectedItemId(R.id.bottom_nav_workbench);
+ } else {
+ showWorkbenchContent();
+ }
+ return false;
+ } else if (itemId == R.id.action_settings) {
+ final Intent settingsIntent = new Intent(this, SettingsActivity.class);
+ startActivityForResult(settingsIntent, MENU_REFRESH_CODE);
+ return false;
+ } else if (itemId == R.id.device_action_discover) {
+ launchDiscoveryActivity();
+ return false;
+ } else if (itemId == R.id.action_quit) {
+ GBApplication.quit();
+ return false;
+ } else if (itemId == R.id.about) {
+ final Intent aboutIntent = new Intent(this, AboutActivity.class);
+ startActivity(aboutIntent);
+ return false;
+ }
+ /*
+ } else if (itemId == R.id.action_debug) {
+ final Intent debugIntent = new Intent(this, DebugActivity.class);
+ startActivity(debugIntent);
+ return false;
+ } else if (itemId == R.id.action_data_management) {
+ final Intent dbIntent = new Intent(this, DataManagementActivity.class);
+ startActivity(dbIntent);
+ return false;
+ } else if (itemId == R.id.donation_link) {
+ ...
+ } else if (itemId == R.id.external_changelog) {
+ ...
+ }
+ */
+
+ return false; // we do not want the drawer menu item to get selected
+ }
+
+ private void launchDiscoveryActivity() {
+ startActivity(new Intent(this, DiscoveryActivityV2.class));
+ }
+
+ private void handleShortcut(Intent intent) {
+ if(ACTION_CONNECT.equals(intent.getAction())) {
+ String btDeviceAddress = intent.getStringExtra("device");
+ if(btDeviceAddress!=null){
+ GBDevice candidate = DeviceHelper.getInstance().findAvailableDevice(btDeviceAddress, this);
+ if (candidate != null && !candidate.isConnected()) {
+ GBApplication.deviceService(candidate).connect();
+ }
+ }
+ }
+ }
+
+ public void setLanguage(Locale language, boolean invalidateLanguage) {
+ if (invalidateLanguage) {
+ isLanguageInvalid = true;
+ }
+ AndroidUtils.setLanguage(this, language);
+ }
+
+ private class MainFragmentsPagerAdapter extends FragmentStateAdapter {
+ public MainFragmentsPagerAdapter(FragmentActivity fa) {
+ super(fa);
+ }
+
+ @Override
+ public Fragment createFragment(int position) {
+ if (position == 0) {
+ return new DashboardFragment();
+ } else {
+ return new DevicesFragment();
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return 2;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java
new file mode 100644
index 0000000..ba21a2b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java
@@ -0,0 +1,532 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.util.DisplayMetrics;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.activity.result.ActivityResult;
+import androidx.activity.result.ActivityResultCallback;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.core.view.MenuProvider;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentContainerView;
+import androidx.gridlayout.widget.GridLayout;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.google.android.material.card.MaterialCardView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.AbstractDashboardWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardCaloriesActiveGoalWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardActiveTimeWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardBodyEnergyWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardCalendarActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardCaloriesTotalSegmentedWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardDistanceWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardGoalsWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardHrvWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardSleepScoreWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardSleepWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardStepsWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardStressBreakdownWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardStressSegmentedWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardStressSimpleWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardTodayWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardVO2MaxCyclingWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardVO2MaxAnyWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardVO2MaxRunningWidget;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.util.DashboardUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class DashboardFragment extends Fragment implements MenuProvider {
+ private static final Logger LOG = LoggerFactory.getLogger(DashboardFragment.class);
+
+ private final Calendar day = GregorianCalendar.getInstance();
+ private TextView textViewDate;
+ private TextView arrowRight;
+ private GridLayout gridLayout;
+ private final Map<String, AbstractDashboardWidget> widgetMap = new HashMap<>();
+ private DashboardData dashboardData = new DashboardData();
+ private boolean isConfigChanged = false;
+
+ private ActivityResultLauncher<Intent> calendarLauncher;
+ private final ActivityResultCallback<ActivityResult> calendarCallback = result -> {
+ if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+ long timeMillis = result.getData().getLongExtra(DashboardCalendarActivity.EXTRA_TIMESTAMP, 0);
+ if (timeMillis != 0) {
+ day.setTimeInMillis(timeMillis);
+ fullRefresh();
+ }
+ }
+ };
+
+ public static final String ACTION_CONFIG_CHANGE = "nodomain.freeyourgadget.gadgetbridge.activities.dashboardfragment.action.config_change";
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action == null) return;
+ switch (action) {
+ case GBApplication.ACTION_NEW_DATA:
+ final GBDevice dev = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (dev != null) {
+ if (dashboardData.showAllDevices || dashboardData.showDeviceList.contains(dev.getAddress())) {
+ refresh();
+ }
+ }
+ break;
+ case ACTION_CONFIG_CHANGE:
+ isConfigChanged = true;
+ break;
+ }
+ }
+ };
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ View dashboardView = inflater.inflate(R.layout.fragment_dashboard, container, false);
+ textViewDate = dashboardView.findViewById(R.id.dashboard_date);
+ gridLayout = dashboardView.findViewById(R.id.dashboard_gridlayout);
+
+ calendarLauncher = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ calendarCallback
+ );
+
+ // Increase column count on landscape, tablets and open foldables
+ DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
+ if (displayMetrics.widthPixels / displayMetrics.density >= 600) {
+ gridLayout.setColumnCount(4);
+ }
+
+ final TextView arrowLeft = dashboardView.findViewById(R.id.arrow_left);
+ arrowLeft.setOnClickListener(v -> {
+ day.add(Calendar.DAY_OF_MONTH, -1);
+ refresh();
+ });
+ arrowRight = dashboardView.findViewById(R.id.arrow_right);
+ arrowRight.setOnClickListener(v -> {
+ Calendar today = GregorianCalendar.getInstance();
+ if (!DateTimeUtils.isSameDay(today, day)) {
+ day.add(Calendar.DAY_OF_MONTH, 1);
+ refresh();
+ }
+ });
+
+ if (savedInstanceState != null && savedInstanceState.containsKey("dashboard_data") && dashboardData.isEmpty()) {
+ dashboardData = (DashboardData) savedInstanceState.getSerializable("dashboard_data");
+ } else if (dashboardData.isEmpty()) {
+ reloadPreferences();
+ }
+
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(GBDevice.ACTION_DEVICE_CHANGED);
+ filterLocal.addAction(GBApplication.ACTION_NEW_DATA);
+ filterLocal.addAction(ACTION_CONFIG_CHANGE);
+ LocalBroadcastManager.getInstance(requireContext()).registerReceiver(mReceiver, filterLocal);
+
+ return dashboardView;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (isConfigChanged) {
+ isConfigChanged = false;
+ fullRefresh();
+ } else if (dashboardData.isEmpty() || !widgetMap.containsKey("today")) {
+ refresh();
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(mReceiver);
+ super.onDestroy();
+ }
+
+ @Override
+ public void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putSerializable("dashboard_data", dashboardData);
+ }
+
+ @Override
+ public void onCreateMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) {
+ inflater.inflate(R.menu.dashboard_menu, menu);
+ }
+
+ @Override
+ public boolean onMenuItemSelected(@NonNull final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == R.id.dashboard_show_calendar) {
+ final Intent intent = new Intent(requireActivity(), DashboardCalendarActivity.class);
+ intent.putExtra(DashboardCalendarActivity.EXTRA_TIMESTAMP, day.getTimeInMillis());
+ calendarLauncher.launch(intent);
+ return true;
+ } else if (itemId == R.id.dashboard_settings) {
+ final Intent intent = new Intent(requireActivity(), DashboardPreferencesActivity.class);
+ startActivity(intent);
+ return true;
+ }
+ return false;
+ }
+
+ private void fullRefresh() {
+ gridLayout.removeAllViews();
+ widgetMap.clear();
+ refresh();
+ }
+
+ private void refresh() {
+ day.set(Calendar.HOUR_OF_DAY, 23);
+ day.set(Calendar.MINUTE, 59);
+ day.set(Calendar.SECOND, 59);
+ dashboardData.clear();
+ reloadPreferences();
+ draw();
+ }
+
+ private void reloadPreferences() {
+ Prefs prefs = GBApplication.getPrefs();
+ dashboardData.showAllDevices = prefs.getBoolean("dashboard_devices_all", true);
+ dashboardData.showDeviceList = prefs.getStringSet("dashboard_devices_multiselect", new HashSet<>());
+ dashboardData.hrIntervalSecs = prefs.getInt("dashboard_widget_today_hr_interval", 1) * 60;
+ dashboardData.timeTo = (int) (day.getTimeInMillis() / 1000);
+ dashboardData.timeFrom = DateTimeUtils.shiftDays(dashboardData.timeTo, -1);
+ }
+
+ private void draw() {
+ Prefs prefs = GBApplication.getPrefs();
+ String defaultWidgetsOrder = String.join(",", getResources().getStringArray(R.array.pref_dashboard_widgets_order_default));
+ String widgetsOrderPref = prefs.getString("pref_dashboard_widgets_order", defaultWidgetsOrder);
+ String[] widgetsOrder = widgetsOrderPref.split(",");
+
+ Calendar today = GregorianCalendar.getInstance();
+ if (DateTimeUtils.isSameDay(today, day)) {
+ textViewDate.setText(requireContext().getString(R.string.activity_summary_today));
+ arrowRight.setAlpha(0.5f);
+ } else {
+ textViewDate.setText(DateTimeUtils.formatDate(day.getTime()));
+ arrowRight.setAlpha(1);
+ }
+
+ boolean cardsEnabled = prefs.getBoolean("dashboard_cards_enabled", true);
+
+ for (String widgetName : widgetsOrder) {
+ AbstractDashboardWidget widget = widgetMap.get(widgetName);
+ if (widget == null) {
+ int columnSpan = 1;
+ switch (widgetName) {
+ case "today":
+ widget = DashboardTodayWidget.newInstance(dashboardData);
+ columnSpan = prefs.getBoolean("dashboard_widget_today_2columns", true) ? 2 : 1;
+ break;
+ case "goals":
+ widget = DashboardGoalsWidget.newInstance(dashboardData);
+ columnSpan = prefs.getBoolean("dashboard_widget_goals_2columns", true) ? 2 : 1;
+ break;
+ case "steps":
+ widget = DashboardStepsWidget.newInstance(dashboardData);
+ break;
+ case "distance":
+ widget = DashboardDistanceWidget.newInstance(dashboardData);
+ break;
+ case "activetime":
+ widget = DashboardActiveTimeWidget.newInstance(dashboardData);
+ break;
+ case "sleep":
+ widget = DashboardSleepWidget.newInstance(dashboardData);
+ break;
+ case "stress_simple":
+ widget = DashboardStressSimpleWidget.newInstance(dashboardData);
+ break;
+ case "stress_segmented":
+ widget = DashboardStressSegmentedWidget.newInstance(dashboardData);
+ break;
+ case "stress_breakdown":
+ widget = DashboardStressBreakdownWidget.newInstance(dashboardData);
+ break;
+ case "bodyenergy":
+ widget = DashboardBodyEnergyWidget.newInstance(dashboardData);
+ break;
+ case "hrv":
+ widget = DashboardHrvWidget.newInstance(dashboardData);
+ break;
+ case "vo2max_running":
+ widget = DashboardVO2MaxRunningWidget.newInstance(dashboardData);
+ break;
+ case "vo2max_cycling":
+ widget = DashboardVO2MaxCyclingWidget.newInstance(dashboardData);
+ break;
+ case "vo2max":
+ widget = DashboardVO2MaxAnyWidget.newInstance(dashboardData);
+ break;
+ case "calories_active":
+ widget = DashboardCaloriesActiveGoalWidget.newInstance(dashboardData);
+ break;
+ case "calories_segmented":
+ widget = DashboardCaloriesTotalSegmentedWidget.newInstance(dashboardData);
+ break;
+ case "sleepscore":
+ widget = DashboardSleepScoreWidget.newInstance(dashboardData);
+ break;
+ default:
+ LOG.error("Unknown dashboard widget {}", widgetName);
+ continue;
+ }
+
+ createWidget(widget, cardsEnabled, columnSpan);
+
+ widgetMap.put(widgetName, widget);
+ } else {
+ widget.update();
+ }
+ }
+ }
+
+ private void createWidget(AbstractDashboardWidget widgetObj, boolean cardsEnabled, int columnSpan) {
+ final float scale = requireContext().getResources().getDisplayMetrics().density;
+ FragmentContainerView fragment = new FragmentContainerView(requireActivity());
+ int fragmentId = View.generateViewId();
+ fragment.setId(fragmentId);
+ getChildFragmentManager()
+ .beginTransaction()
+ .replace(fragmentId, widgetObj)
+ .commitAllowingStateLoss(); // FIXME: #4007
+
+ GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(
+ GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f),
+ GridLayout.spec(GridLayout.UNDEFINED, columnSpan, GridLayout.FILL, 1f)
+ );
+ layoutParams.width = 0;
+ int pixels_8dp = (int) (8 * scale + 0.5f);
+ layoutParams.setMargins(pixels_8dp, pixels_8dp, pixels_8dp, pixels_8dp);
+
+ if (cardsEnabled) {
+ MaterialCardView card = new MaterialCardView(requireActivity());
+ int pixels_4dp = (int) (4 * scale + 0.5f);
+ card.setRadius(pixels_4dp);
+ card.setCardElevation(pixels_4dp);
+ card.setContentPadding(pixels_4dp, pixels_4dp, pixels_4dp, pixels_4dp);
+ card.setLayoutParams(layoutParams);
+ card.addView(fragment);
+ gridLayout.addView(card);
+ } else {
+ fragment.setLayoutParams(layoutParams);
+ gridLayout.addView(fragment);
+ }
+ }
+
+ /**
+ * This class serves as a data collection object for all data points used by the various
+ * dashboard widgets. Since retrieving this data can be costly, this class makes sure it will
+ * only be done once. It will be passed to every widget, making sure they have the necessary
+ * data available.
+ */
+ public static class DashboardData implements Serializable {
+ public boolean showAllDevices;
+ public Set<String> showDeviceList;
+ public int hrIntervalSecs;
+ public int timeFrom;
+ public int timeTo;
+ public final List<GeneralizedActivity> generalizedActivities = Collections.synchronizedList(new ArrayList<>());
+ private int stepsTotal;
+ private float stepsGoalFactor;
+ private int restingCaloriesTotal;
+ private int activeCaloriesTotal;
+ private float activeCaloriesGoalFactor;
+ private int caloriesTotal;
+ private long sleepTotalMinutes;
+ private float sleepGoalFactor;
+ private float distanceTotalMeters;
+ private float distanceGoalFactor;
+ private long activeMinutesTotal;
+ private float activeMinutesGoalFactor;
+ private final Map<String, Serializable> genericData = new ConcurrentHashMap<>();
+
+ public void clear() {
+ restingCaloriesTotal = 0;
+ activeCaloriesTotal = 0;
+ activeCaloriesGoalFactor = 0;
+ caloriesTotal = 0;
+ stepsTotal = 0;
+ stepsGoalFactor = 0;
+ sleepTotalMinutes = 0;
+ sleepGoalFactor = 0;
+ distanceTotalMeters = 0;
+ distanceGoalFactor = 0;
+ activeMinutesTotal = 0;
+ activeMinutesGoalFactor = 0;
+ generalizedActivities.clear();
+ genericData.clear();
+ }
+
+ public boolean isEmpty() {
+ return (stepsTotal == 0 &&
+ stepsGoalFactor == 0 &&
+ restingCaloriesTotal == 0 &&
+ activeCaloriesTotal == 0 &&
+ activeCaloriesGoalFactor == 0 &&
+ caloriesTotal == 0 &&
+ sleepTotalMinutes == 0 &&
+ sleepGoalFactor == 0 &&
+ distanceTotalMeters == 0 &&
+ distanceGoalFactor == 0 &&
+ activeMinutesTotal == 0 &&
+ activeMinutesGoalFactor == 0 &&
+ genericData.isEmpty() &&
+ generalizedActivities.isEmpty());
+ }
+
+ public synchronized int getStepsTotal() {
+ if (stepsTotal == 0)
+ stepsTotal = DashboardUtils.getStepsTotal(this);
+ return stepsTotal;
+ }
+
+ public synchronized float getStepsGoalFactor() {
+ if (stepsGoalFactor == 0)
+ stepsGoalFactor = DashboardUtils.getStepsGoalFactor(this);
+ return stepsGoalFactor;
+ }
+
+ public synchronized float getDistanceTotal() {
+ if (distanceTotalMeters == 0)
+ distanceTotalMeters = DashboardUtils.getDistanceTotal(this);
+ return distanceTotalMeters;
+ }
+
+ public synchronized float getDistanceGoalFactor() {
+ if (distanceGoalFactor == 0)
+ distanceGoalFactor = DashboardUtils.getDistanceGoalFactor(this);
+ return distanceGoalFactor;
+ }
+
+ public synchronized long getActiveMinutesTotal() {
+ if (activeMinutesTotal == 0)
+ activeMinutesTotal = DashboardUtils.getActiveMinutesTotal(this);
+ return activeMinutesTotal;
+ }
+
+ public synchronized float getActiveMinutesGoalFactor() {
+ if (activeMinutesGoalFactor == 0)
+ activeMinutesGoalFactor = DashboardUtils.getActiveMinutesGoalFactor(this);
+ return activeMinutesGoalFactor;
+ }
+
+ public synchronized long getSleepMinutesTotal() {
+ if (sleepTotalMinutes == 0)
+ sleepTotalMinutes = DashboardUtils.getSleepMinutesTotal(this);
+ return sleepTotalMinutes;
+ }
+
+ public synchronized float getSleepMinutesGoalFactor() {
+ if (sleepGoalFactor == 0)
+ sleepGoalFactor = DashboardUtils.getSleepMinutesGoalFactor(this);
+ return sleepGoalFactor;
+ }
+
+ public synchronized int getActiveCaloriesTotal() {
+ if (activeCaloriesTotal == 0)
+ activeCaloriesTotal = DashboardUtils.getActiveCaloriesTotal(this);
+ return activeCaloriesTotal;
+ }
+
+ public synchronized int getRestingCaloriesTotal() {
+ if (restingCaloriesTotal == 0)
+ restingCaloriesTotal = DashboardUtils.getRestingCaloriesTotal(this);
+ return restingCaloriesTotal;
+ }
+
+ public synchronized float getActiveCaloriesGoalFactor() {
+ if (activeCaloriesGoalFactor == 0)
+ activeCaloriesGoalFactor = DashboardUtils.getActiveCaloriesGoalFactor(this);
+ return activeCaloriesGoalFactor;
+ }
+
+ public void put(final String key, final Serializable value) {
+ genericData.put(key, value);
+ }
+
+ public Serializable get(final String key) {
+ return genericData.get(key);
+ }
+
+ /**
+ * @noinspection UnusedReturnValue
+ */
+ public Serializable computeIfAbsent(final String key, final Supplier<Serializable> supplier) {
+ return genericData.computeIfAbsent(key, absent -> supplier.get());
+ }
+
+ public static class GeneralizedActivity implements Serializable {
+ public ActivityKind activityKind;
+ public long timeFrom;
+ public long timeTo;
+
+ public GeneralizedActivity(ActivityKind activityKind, long timeFrom, long timeTo) {
+ this.activityKind = activityKind;
+ this.timeFrom = timeFrom;
+ this.timeTo = timeTo;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "Generalized activity: timeFrom=" + timeFrom + ", timeTo=" + timeTo + ", activityKind=" + activityKind + ", calculated duration: " + (timeTo - timeFrom) + " seconds";
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardPreferencesActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardPreferencesActivity.java
new file mode 100644
index 0000000..1e5e720
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardPreferencesActivity.java
@@ -0,0 +1,104 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.InputType;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.preference.MultiSelectListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class DashboardPreferencesActivity extends AbstractSettingsActivityV2 {
+ @Override
+ protected String fragmentTag() {
+ return DashboardPreferencesFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return new DashboardPreferencesFragment();
+ }
+
+ public static class DashboardPreferencesFragment extends AbstractPreferenceFragment {
+ static final String FRAGMENT_TAG = "DASHBOARD_PREFERENCES_FRAGMENT";
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ setPreferencesFromResource(R.xml.dashboard_preferences, rootKey);
+
+ setInputTypeFor("dashboard_widget_today_hr_interval", InputType.TYPE_CLASS_NUMBER);
+
+ final MultiSelectListPreference dashboardDevices = findPreference("dashboard_devices_multiselect");
+ if (dashboardDevices != null) {
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
+ List<String> deviceMACs = new ArrayList<>();
+ List<String> deviceNames = new ArrayList<>();
+ for (GBDevice dev : devices) {
+ deviceMACs.add(dev.getAddress());
+ deviceNames.add(dev.getAliasOrName());
+ }
+ dashboardDevices.setEntryValues(deviceMACs.toArray(new String[0]));
+ dashboardDevices.setEntries(deviceNames.toArray(new String[0]));
+ }
+ List<String> dashboardPrefs = Arrays.asList(
+ "dashboard_cards_enabled",
+ "pref_dashboard_widgets_order",
+ "dashboard_widget_today_24h",
+ "dashboard_widget_today_24h_upside_down",
+ "dashboard_widget_today_show_yesterday",
+ "dashboard_widget_today_time_indicator",
+ "dashboard_widget_today_2columns",
+ "dashboard_widget_today_legend",
+ "dashboard_widget_today_hr_interval",
+ "dashboard_widget_goals_2columns",
+ "dashboard_widget_goals_legend",
+ "dashboard_devices_all",
+ "dashboard_devices_multiselect"
+ );
+ Preference pref;
+ for (String dashboardPref : dashboardPrefs) {
+ pref = findPreference(dashboardPref);
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener((preference, autoExportEnabled) -> {
+ sendDashboardConfigChangedIntent();
+ return true;
+ });
+ }
+ }
+ }
+
+ /**
+ * Signal dashboard that its config has changed
+ */
+ private void sendDashboardConfigChangedIntent() {
+ Intent intent = new Intent();
+ intent.setAction(DashboardFragment.ACTION_CONFIG_CHANGE);
+ LocalBroadcastManager.getInstance(requireContext()).sendBroadcast(intent);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java
new file mode 100644
index 0000000..593b1dd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DataManagementActivity.java
@@ -0,0 +1,518 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver, Petr Van臎k
+
+ 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.activities;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.provider.DocumentsContract;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.core.app.NavUtils;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.files.FileManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.ImportExportSharedPreferences;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+
+public class DataManagementActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(DataManagementActivity.class);
+ private static SharedPreferences sharedPrefs;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_data_management);
+
+ final ActivityResultLauncher<String> backupZipFileChooser = registerForActivityResult(
+ new ActivityResultContracts.CreateDocument("application/zip"),
+ uri -> {
+ LOG.info("Got target backup file: {}", uri);
+ if (uri != null) {
+ final Intent startBackupIntent = new Intent(DataManagementActivity.this, BackupRestoreProgressActivity.class);
+ startBackupIntent.putExtra(BackupRestoreProgressActivity.EXTRA_URI, uri);
+ startBackupIntent.putExtra(BackupRestoreProgressActivity.EXTRA_ACTION, "export");
+ startActivity(startBackupIntent);
+ }
+ }
+ );
+
+ final Button backupToZipButton = findViewById(R.id.backupToZipButton);
+ backupToZipButton.setOnClickListener(v -> {
+ final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault());
+ final String defaultFilename = String.format(Locale.ROOT, "gadgetbridge_%s.zip", sdf.format(new Date()));
+ backupZipFileChooser.launch(defaultFilename);
+ });
+
+ final ActivityResultLauncher<String[]> restoreFileChooser = registerForActivityResult(
+ new ActivityResultContracts.OpenDocument(),
+ uri -> {
+ LOG.info("Got restore file: {}", uri);
+
+ if (uri == null) {
+ return;
+ }
+
+ new MaterialAlertDialogBuilder(this)
+ .setCancelable(true)
+ .setIcon(R.drawable.ic_warning)
+ .setTitle(R.string.dbmanagementactivity_import_data_title)
+ .setMessage(R.string.dbmanagementactivity_overwrite_database_confirmation)
+ .setPositiveButton(R.string.dbmanagementactivity_overwrite, (dialog, which) -> {
+ // Disconnect from all devices right away
+ GBApplication.deviceService().disconnect();
+
+ final Intent startBackupIntent = new Intent(DataManagementActivity.this, BackupRestoreProgressActivity.class);
+ startBackupIntent.putExtra(BackupRestoreProgressActivity.EXTRA_URI, uri);
+ startBackupIntent.putExtra(BackupRestoreProgressActivity.EXTRA_ACTION, "import");
+ startActivity(startBackupIntent);
+ })
+ .setNegativeButton(R.string.Cancel, (dialog, which) -> {
+ })
+ .show();
+ }
+ );
+
+ final Button restoreFromZipButton = findViewById(R.id.restoreFromZipButton);
+ restoreFromZipButton.setOnClickListener(v -> restoreFileChooser.launch(new String[]{"application/zip"}));
+
+ TextView dbPath = findViewById(R.id.activity_data_management_path);
+ dbPath.setText(getExternalPath());
+
+ Button exportDBButton = findViewById(R.id.exportDataButton);
+ exportDBButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ exportDB();
+ }
+ });
+ Button importDBButton = findViewById(R.id.importDataButton);
+ importDBButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ importDB();
+ }
+ });
+
+ Button showContentDataButton = findViewById(R.id.showContentDataButton);
+ showContentDataButton.setOnClickListener(v -> {
+ final Intent fileManagerIntent = new Intent(DataManagementActivity.this, FileManagerActivity.class);
+ startActivity(fileManagerIntent);
+ });
+
+ int oldDBVisibility = hasOldActivityDatabase() ? View.VISIBLE : View.GONE;
+
+ TextView deleteOldActivityTitle = findViewById(R.id.mergeOldActivityDataTitle);
+ deleteOldActivityTitle.setVisibility(oldDBVisibility);
+
+ Button deleteOldActivityDBButton = findViewById(R.id.deleteOldActivityDB);
+ deleteOldActivityDBButton.setVisibility(oldDBVisibility);
+ deleteOldActivityDBButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ deleteOldActivityDbFile();
+ }
+ });
+
+ Button deleteDBButton = findViewById(R.id.emptyDBButton);
+ deleteDBButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ deleteActivityDatabase();
+ }
+ });
+
+ TextView dbPath2 = findViewById(R.id.activity_data_management_path2);
+ dbPath2.setText(getExternalPath());
+
+ Button cleanExportDirectoryButton = findViewById(R.id.cleanExportDirectoryButton);
+ cleanExportDirectoryButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ cleanExportDirectory();
+ }
+ });
+ GBApplication gbApp = GBApplication.app();
+ Prefs prefs = GBApplication.getPrefs();
+ boolean autoExportEnabled = prefs.getBoolean(GBPrefs.AUTO_EXPORT_ENABLED, false);
+ int autoExportInterval = prefs.getInt(GBPrefs.AUTO_EXPORT_INTERVAL, 0);
+ //returns an ugly content://...
+ //String autoExportLocation = prefs.getString(GBPrefs.AUTO_EXPORT_LOCATION, "");
+
+ int testExportVisibility = (autoExportInterval > 0 && autoExportEnabled) ? View.VISIBLE : View.GONE;
+ boolean isExportEnabled = autoExportInterval > 0 && autoExportEnabled;
+ TextView autoExportLocation_label = findViewById(R.id.autoExportLocation_label);
+ autoExportLocation_label.setVisibility(testExportVisibility);
+
+ TextView autoExportLocation_path = findViewById(R.id.autoExportLocation_path);
+ autoExportLocation_path.setVisibility(testExportVisibility);
+ autoExportLocation_path.setText(getAutoExportLocationUserString() + " (" + getAutoExportLocationPreferenceString() + ")" );
+
+ TextView autoExportEnabled_label = findViewById(R.id.autoExportEnabled);
+ if (isExportEnabled) {
+ autoExportEnabled_label.setText(getString(R.string.activity_db_management_autoexport_enabled_yes));
+ } else {
+ autoExportEnabled_label.setText(getString(R.string.activity_db_management_autoexport_enabled_no));
+ }
+
+ TextView autoExportScheduled = findViewById(R.id.autoExportScheduled);
+ autoExportScheduled.setVisibility(testExportVisibility);
+ long setAutoExportScheduledTimestamp = gbApp.getAutoExportScheduledTimestamp();
+ if (setAutoExportScheduledTimestamp > 0) {
+ autoExportScheduled.setText(getString(R.string.activity_db_management_autoexport_scheduled_yes,
+ DateTimeUtils.formatDateTime(new Date(setAutoExportScheduledTimestamp))));
+ } else {
+ autoExportScheduled.setText(getResources().getString(R.string.activity_db_management_autoexport_scheduled_no));
+ }
+
+ TextView autoExport_lastTime_label = findViewById(R.id.autoExport_lastTime_label);
+ long lastAutoExportTimestamp = gbApp.getLastAutoExportTimestamp();
+
+ autoExport_lastTime_label.setVisibility(View.GONE);
+ autoExport_lastTime_label.setText(getString(R.string.autoExport_lastTime_label,
+ DateTimeUtils.formatDateTime(new Date(lastAutoExportTimestamp))));
+
+ if (lastAutoExportTimestamp > 0) {
+ autoExport_lastTime_label.setVisibility(testExportVisibility);
+ autoExport_lastTime_label.setVisibility(testExportVisibility);
+ }
+
+ final Context context = getApplicationContext();
+ Button testExportDBButton = findViewById(R.id.testExportDBButton);
+ testExportDBButton.setVisibility(testExportVisibility);
+ testExportDBButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ sendBroadcast(new Intent(context, PeriodicExporter.class));
+ GB.toast(context,
+ context.getString(R.string.activity_DB_test_export_message),
+ Toast.LENGTH_SHORT, GB.INFO);
+ }
+ });
+
+ sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this);
+ }
+
+ private String getAutoExportLocationPreferenceString() {
+ String autoExportLocation = GBApplication.getPrefs().getString(GBPrefs.AUTO_EXPORT_LOCATION, null);
+ if (autoExportLocation == null) {
+ return "";
+ }
+ return autoExportLocation;
+ }
+
+ private String getAutoExportLocationUri() {
+ String autoExportLocation = getAutoExportLocationPreferenceString();
+ if (autoExportLocation == null) {
+ return "";
+ }
+ Uri uri = Uri.parse(autoExportLocation);
+ try {
+
+ return AndroidUtils.getFilePath(getApplicationContext(), uri);
+ } catch (IllegalArgumentException e) {
+ LOG.error("getFilePath did not work, trying to resolve content provider path");
+ try {
+ Cursor cursor = getContentResolver().query(
+ uri,
+ new String[]{DocumentsContract.Document.COLUMN_DISPLAY_NAME},
+ null, null, null, null
+ );
+ if (cursor != null && cursor.moveToFirst()) {
+ return cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));
+ }
+ } catch (Exception exception) {
+ LOG.error("Error getting export path", exception);
+ }
+ }
+ return "";
+ }
+
+ private String getAutoExportLocationUserString() {
+ String location = getAutoExportLocationUri();
+ if (location == "") {
+ return getString(R.string.activity_db_management_autoexport_location);
+ }
+ return location;
+ }
+
+ private boolean hasOldActivityDatabase() {
+ return new DBHelper(this).existsDB("ActivityDatabase");
+ }
+
+ private String getExternalPath() {
+ try {
+ return FileUtils.getExternalFilesDir().getAbsolutePath();
+ } catch (Exception ex) {
+ LOG.warn("Unable to get external files dir", ex);
+ }
+ return getString(R.string.dbmanagementactivvity_cannot_access_export_path);
+ }
+
+ private void exportShared() {
+ try {
+ File myPath = FileUtils.getExternalFilesDir();
+ File myFile = new File(myPath, "Export_preference");
+ ImportExportSharedPreferences.exportToFile(sharedPrefs, myFile);
+ } catch (IOException ex) {
+ GB.toast(this, getString(R.string.dbmanagementactivity_error_exporting_shared, ex.getMessage()), Toast.LENGTH_LONG, GB.ERROR, ex);
+ }
+ try (DBHandler lockHandler = GBApplication.acquireDB()) {
+ List<Device> activeDevices = DBHelper.getActiveDevices(lockHandler.getDaoSession());
+ for (Device dbDevice : activeDevices) {
+ SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ if (sharedPrefs != null) {
+ File myPath = FileUtils.getExternalFilesDir();
+ File myFile = new File(myPath, "Export_preference_" + FileUtils.makeValidFileName(dbDevice.getIdentifier()));
+ try {
+ ImportExportSharedPreferences.exportToFile(deviceSharedPrefs, myFile);
+ } catch (Exception ignore) {
+ // some devices no not have device specific preferences
+ }
+ }
+ }
+ } catch (Exception e) {
+ GB.toast("Error exporting device specific preferences", Toast.LENGTH_SHORT, GB.ERROR, e);
+ }
+ }
+
+ private void importShared() {
+ try {
+ File myPath = FileUtils.getExternalFilesDir();
+ File myFile = new File(myPath, "Export_preference");
+ ImportExportSharedPreferences.importFromFile(sharedPrefs, myFile);
+ } catch (Exception ex) {
+ GB.toast(DataManagementActivity.this, getString(R.string.dbmanagementactivity_error_importing_db, ex.getMessage()), Toast.LENGTH_LONG, GB.ERROR, ex);
+ }
+
+ try (DBHandler lockHandler = GBApplication.acquireDB()) {
+ List<Device> activeDevices = DBHelper.getActiveDevices(lockHandler.getDaoSession());
+ for (Device dbDevice : activeDevices) {
+ SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
+ if (sharedPrefs != null) {
+ File myPath = FileUtils.getExternalFilesDir();
+ File myFile = new File(myPath, "Export_preference_" + FileUtils.makeValidFileName(dbDevice.getIdentifier()));
+
+ if (!myFile.exists()) { //first try to use file in new format de_ad_be_af, if doesn't exist use old format de:at:be:af
+ myFile = new File(myPath, "Export_preference_" + dbDevice.getIdentifier());
+ LOG.info("Trying to import with older filename");
+ }else{
+ LOG.info("Trying to import with new filename");
+ }
+
+ try {
+ ImportExportSharedPreferences.importFromFile(deviceSharedPrefs, myFile);
+ } catch (Exception ignore) {
+ // some devices no not have device specific preferences
+ }
+ }
+ }
+ } catch (Exception e) {
+ GB.toast("Error importing device specific preferences", Toast.LENGTH_SHORT, GB.ERROR);
+ }
+ }
+
+ private void exportDB() {
+ new MaterialAlertDialogBuilder(this)
+ .setCancelable(true)
+ .setIcon(R.drawable.ic_warning)
+ .setTitle(R.string.dbmanagementactivity_export_data_title)
+ .setMessage(R.string.dbmanagementactivity_export_confirmation)
+ .setPositiveButton(R.string.activity_DB_ExportButton, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ exportShared();
+ DBHelper helper = new DBHelper(DataManagementActivity.this);
+ File dir = FileUtils.getExternalFilesDir();
+ File destFile = helper.exportDB(dbHandler, dir);
+ GB.toast(DataManagementActivity.this, getString(R.string.dbmanagementactivity_exported_to, destFile.getAbsolutePath()), Toast.LENGTH_LONG, GB.INFO);
+ } catch (Exception ex) {
+ GB.toast(DataManagementActivity.this, getString(R.string.dbmanagementactivity_error_exporting_db, ex.getMessage()), Toast.LENGTH_LONG, GB.ERROR, ex);
+ }
+ }
+ })
+ .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ })
+ .show();
+ }
+
+ private void importDB() {
+ new MaterialAlertDialogBuilder(this)
+ .setCancelable(true)
+ .setIcon(R.drawable.ic_warning)
+ .setTitle(R.string.dbmanagementactivity_import_data_title)
+ .setMessage(R.string.dbmanagementactivity_overwrite_database_confirmation)
+ .setPositiveButton(R.string.dbmanagementactivity_overwrite, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ DBHelper helper = new DBHelper(DataManagementActivity.this);
+ File dir = FileUtils.getExternalFilesDir();
+ SQLiteOpenHelper sqLiteOpenHelper = dbHandler.getHelper();
+ File sourceFile = new File(dir, sqLiteOpenHelper.getDatabaseName());
+ helper.importDB(dbHandler, sourceFile);
+ helper.validateDB(sqLiteOpenHelper);
+ GB.toast(DataManagementActivity.this, getString(R.string.dbmanagementactivity_import_successful), Toast.LENGTH_LONG, GB.INFO);
+ } catch (Exception ex) {
+ GB.toast(DataManagementActivity.this, getString(R.string.dbmanagementactivity_error_importing_db, ex.getMessage()), Toast.LENGTH_LONG, GB.ERROR, ex);
+ }
+ importShared();
+ }
+ })
+ .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ })
+ .show();
+ }
+
+ private void deleteActivityDatabase() {
+ new MaterialAlertDialogBuilder(this)
+ .setCancelable(true)
+ .setIcon(R.drawable.ic_warning)
+ .setTitle(R.string.dbmanagementactivity_delete_activity_data_title)
+ .setMessage(R.string.dbmanagementactivity_really_delete_entire_db)
+ .setPositiveButton(R.string.Delete, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (GBApplication.deleteActivityDatabase(DataManagementActivity.this)) {
+ GB.toast(DataManagementActivity.this, getString(R.string.dbmanagementactivity_database_successfully_deleted), Toast.LENGTH_SHORT, GB.INFO);
+ } else {
+ GB.toast(DataManagementActivity.this, getString(R.string.dbmanagementactivity_db_deletion_failed), Toast.LENGTH_SHORT, GB.INFO);
+ }
+ }
+ })
+ .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ })
+ .show();
+ }
+
+ private void deleteOldActivityDbFile() {
+ new MaterialAlertDialogBuilder(this)
+ .setCancelable(true)
+ .setTitle(R.string.dbmanagementactivity_delete_old_activity_db)
+ .setIcon(R.drawable.ic_warning)
+ .setMessage(R.string.dbmanagementactivity_delete_old_activitydb_confirmation)
+ .setPositiveButton(R.string.Delete, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (GBApplication.deleteOldActivityDatabase(DataManagementActivity.this)) {
+ GB.toast(DataManagementActivity.this, getString(R.string.dbmanagementactivity_old_activity_db_successfully_deleted), Toast.LENGTH_SHORT, GB.INFO);
+ } else {
+ GB.toast(DataManagementActivity.this, getString(R.string.dbmanagementactivity_old_activity_db_deletion_failed), Toast.LENGTH_SHORT, GB.INFO);
+ }
+ }
+ });
+ new MaterialAlertDialogBuilder(this).setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ });
+ new MaterialAlertDialogBuilder(this).show();
+ }
+
+ private void cleanExportDirectory() {
+ new MaterialAlertDialogBuilder(this)
+ .setCancelable(true)
+ .setIcon(R.drawable.ic_warning)
+ .setTitle(R.string.activity_DB_clean_export_directory_warning_title)
+ .setMessage(getString(R.string.activity_DB_clean_export_directory_warning_message))
+ .setPositiveButton(R.string.Delete, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ try {
+ File externalFilesDir = FileUtils.getExternalFilesDir();
+ String autoexportFile = getAutoExportLocationUri();
+ for (File file : externalFilesDir.listFiles()) {
+ if (file.isFile() &&
+ (!FileUtils.getExtension(file.toString()).toLowerCase().equals("gpx")) && //keep GPX files
+ (!file.toString().equals(autoexportFile)) // do not remove autoexport
+ ) {
+ LOG.debug("Deleting file: " + file);
+ try {
+ file.delete();
+ } catch (Exception exception) {
+ LOG.error("Error erasing file: " + exception);
+ }
+ }
+ }
+ GB.toast(getString(R.string.dbmanagementactivity_export_finished), Toast.LENGTH_SHORT, GB.INFO);
+ } catch (Exception ex) {
+ GB.toast(DataManagementActivity.this, getString(R.string.dbmanagementactivity_error_cleaning_export_directory, ex.getMessage()), Toast.LENGTH_LONG, GB.ERROR, ex);
+ }
+ }
+ })
+ .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ })
+ .show();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java
new file mode 100644
index 0000000..92e0f0c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java
@@ -0,0 +1,1260 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniel Dakhno, Daniele Gobbetti, Dmitriy Bogdanov, Frank Slezak,
+ Ganblejs, ivanovlev, Jos茅 Rebelo, Kamalei Zestri, Kasha, Lem Dulfo, Pavel
+ Elagin, Petr Van臎k, Steffen Liebergeld, Tim
+
+ 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.activities;
+
+import static android.content.Intent.EXTRA_SUBJECT;
+import static nodomain.freeyourgadget.gadgetbridge.util.GB.NOTIFICATION_CHANNEL_ID;
+
+import android.app.Activity;
+import android.app.DatePickerDialog;
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetHost;
+import android.appwidget.AppWidgetManager;
+import android.bluetooth.BluetoothDevice;
+import android.companion.AssociationRequest;
+import android.companion.BluetoothDeviceFilter;
+import android.companion.CompanionDeviceManager;
+import android.content.BroadcastReceiver;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.IntentSender;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.preference.PreferenceManager;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.Pair;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.DatePicker;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.app.ActivityCompat;
+import androidx.core.app.NavUtils;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.RemoteInput;
+import androidx.core.content.ContextCompat;
+import androidx.core.content.FileProvider;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.Serializable;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Random;
+import java.util.TreeMap;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.Widget;
+import nodomain.freeyourgadget.gadgetbridge.activities.welcome.WelcomeActivity;
+import nodomain.freeyourgadget.gadgetbridge.adapter.SpinnerWithIconAdapter;
+import nodomain.freeyourgadget.gadgetbridge.adapter.SpinnerWithIconItem;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCameraRemote;
+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.GBLocationService;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks.OpenTracksContentObserver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks.OpenTracksController;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
+import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
+import nodomain.freeyourgadget.gadgetbridge.model.Weather;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.WidgetPreferenceStorage;
+
+public class DebugActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(DebugActivity.class);
+
+ private static Bundle dataLossSave;
+
+ private static final String EXTRA_REPLY = "reply";
+ private static final String ACTION_REPLY
+ = "nodomain.freeyourgadget.gadgetbridge.DebugActivity.action.reply";
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ switch (Objects.requireNonNull(intent.getAction())) {
+ case ACTION_REPLY: {
+ Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
+ CharSequence reply = remoteInput.getCharSequence(EXTRA_REPLY);
+ LOG.info("got wearable reply: " + reply);
+ GB.toast(context, "got wearable reply: " + reply, Toast.LENGTH_SHORT, GB.INFO);
+ break;
+ }
+ case DeviceService.ACTION_REALTIME_SAMPLES:
+ handleRealtimeSample(intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE));
+ break;
+ default:
+ LOG.info("ignoring intent action " + intent.getAction());
+ break;
+ }
+ }
+ };
+ private Spinner sendTypeSpinner;
+ private EditText editContent;
+ public static final long SELECT_DEVICE = -1;
+ private long selectedTestDeviceKey = SELECT_DEVICE;
+ private String selectedTestDeviceMAC;
+
+ private static final int SELECT_DEVICE_REQUEST_CODE = 1;
+
+ private void handleRealtimeSample(Serializable extra) {
+ if (extra instanceof ActivitySample) {
+ ActivitySample sample = (ActivitySample) extra;
+ GB.toast(this, "Heart Rate measured: " + sample.getHeartRate(), Toast.LENGTH_LONG, GB.INFO);
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_debug);
+
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(ACTION_REPLY);
+ filter.addAction(DeviceService.ACTION_REALTIME_SAMPLES);
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filter);
+ ContextCompat.registerReceiver(this, mReceiver, filter, ContextCompat.RECEIVER_EXPORTED); // for ACTION_REPLY
+
+ editContent = findViewById(R.id.editContent);
+
+ final ArrayList<String> spinnerArray = new ArrayList<>();
+ for (NotificationType notificationType : NotificationType.sortedValues()) {
+ spinnerArray.add(notificationType.name());
+ }
+ ArrayAdapter<String> spinnerArrayAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, spinnerArray);
+ sendTypeSpinner = findViewById(R.id.sendTypeSpinner);
+ sendTypeSpinner.setAdapter(spinnerArrayAdapter);
+
+ Button sendButton = findViewById(R.id.sendButton);
+ sendButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ NotificationSpec notificationSpec = new NotificationSpec();
+ String testString = editContent.getText().toString();
+ notificationSpec.phoneNumber = testString;
+ notificationSpec.body = testString;
+ notificationSpec.sender = testString;
+ notificationSpec.subject = testString;
+ if (notificationSpec.type != NotificationType.GENERIC_SMS) {
+ // SMS notifications don't have a source app ID when sent by the SMSReceiver,
+ // so let's not set it here as well for consistency
+ notificationSpec.sourceAppId = BuildConfig.APPLICATION_ID;
+ }
+ notificationSpec.sourceName = getApplicationContext().getApplicationInfo()
+ .loadLabel(getApplicationContext().getPackageManager())
+ .toString();
+ notificationSpec.type = NotificationType.sortedValues()[sendTypeSpinner.getSelectedItemPosition()];
+ notificationSpec.pebbleColor = notificationSpec.type.color;
+ notificationSpec.attachedActions = new ArrayList<>();
+
+ if (notificationSpec.type == NotificationType.GENERIC_SMS) {
+ // REPLY action
+ NotificationSpec.Action replyAction = new NotificationSpec.Action();
+ 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);
+ }
+ });
+
+ Button incomingCallButton = findViewById(R.id.incomingCallButton);
+ incomingCallButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ CallSpec callSpec = new CallSpec();
+ callSpec.command = CallSpec.CALL_INCOMING;
+ callSpec.number = editContent.getText().toString();
+ GBApplication.deviceService().onSetCallState(callSpec);
+ }
+ });
+ Button outgoingCallButton = findViewById(R.id.outgoingCallButton);
+ outgoingCallButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ CallSpec callSpec = new CallSpec();
+ callSpec.command = CallSpec.CALL_OUTGOING;
+ callSpec.number = editContent.getText().toString();
+ GBApplication.deviceService().onSetCallState(callSpec);
+ }
+ });
+
+ Button startCallButton = findViewById(R.id.startCallButton);
+ startCallButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ CallSpec callSpec = new CallSpec();
+ callSpec.command = CallSpec.CALL_START;
+ GBApplication.deviceService().onSetCallState(callSpec);
+ }
+ });
+ Button endCallButton = findViewById(R.id.endCallButton);
+ endCallButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ CallSpec callSpec = new CallSpec();
+ callSpec.command = CallSpec.CALL_END;
+ GBApplication.deviceService().onSetCallState(callSpec);
+ }
+ });
+
+ Button rebootButton = findViewById(R.id.rebootButton);
+ rebootButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GBApplication.deviceService().onReset(GBDeviceProtocol.RESET_FLAGS_REBOOT);
+ }
+ });
+
+ Button factoryResetButton = findViewById(R.id.factoryResetButton);
+ factoryResetButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ new MaterialAlertDialogBuilder(DebugActivity.this)
+ .setCancelable(true)
+ .setTitle(R.string.debugactivity_really_factoryreset_title)
+ .setMessage(R.string.debugactivity_really_factoryreset)
+ .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ GBApplication.deviceService().onReset(GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET);
+ }
+ })
+ .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ })
+ .show();
+ }
+ });
+
+ Button heartRateButton = findViewById(R.id.HeartRateButton);
+ heartRateButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GB.toast("Measuring heart rate, please wait...", Toast.LENGTH_LONG, GB.INFO);
+ GBApplication.deviceService().onHeartRateTest();
+ }
+ });
+
+ Button setFetchTimeButton = findViewById(R.id.SetFetchTimeButton);
+ setFetchTimeButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+
+ final Calendar currentDate = Calendar.getInstance();
+ Context context = getApplicationContext();
+
+ if (context instanceof GBApplication) {
+ GBApplication gbApp = (GBApplication) context;
+ final List<GBDevice> devices = gbApp.getDeviceManager().getSelectedDevices();
+ if(devices.size() == 0){
+ GB.toast("Device not selected/connected", Toast.LENGTH_LONG, GB.INFO);
+ return;
+ }
+ new DatePickerDialog(DebugActivity.this, new DatePickerDialog.OnDateSetListener() {
+ @Override
+ public void onDateSet(DatePicker view, int year, int monthOfYear, int dayOfMonth) {
+ Calendar date = Calendar.getInstance();
+ date.set(year, monthOfYear, dayOfMonth);
+
+ long timestamp = date.getTimeInMillis() - 1000;
+ GB.toast("Setting lastSyncTimeMillis: " + timestamp, Toast.LENGTH_LONG, GB.INFO);
+
+ for(GBDevice device : devices){
+ SharedPreferences.Editor editor = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()).edit();
+ editor.remove("lastSyncTimeMillis"); //FIXME: key reconstruction is BAD
+ editor.putLong("lastSyncTimeMillis", timestamp);
+ editor.apply();
+ }
+ }
+ }, currentDate.get(Calendar.YEAR), currentDate.get(Calendar.MONTH), currentDate.get(Calendar.DATE)).show();
+ }
+
+
+ }
+ });
+
+ Button setWeatherButton = findViewById(R.id.setWeatherButton);
+ setWeatherButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (Weather.getInstance().getWeatherSpec() == null) {
+ final WeatherSpec weatherSpec = new WeatherSpec();
+ weatherSpec.forecasts = new ArrayList<>();
+
+ weatherSpec.location = "Green Hill";
+ weatherSpec.currentConditionCode = 601; // snow
+ weatherSpec.currentCondition = Weather.getConditionString(weatherSpec.currentConditionCode);
+
+ weatherSpec.currentTemp = 15 + 273;
+ weatherSpec.currentHumidity = 30;
+
+ weatherSpec.windSpeed = 10;
+ weatherSpec.windDirection = 12;
+ weatherSpec.timestamp = (int) (System.currentTimeMillis() / 1000);
+ weatherSpec.todayMinTemp = 10 + 273;
+ weatherSpec.todayMaxTemp = 25 + 273;
+
+ for (int i = 0; i < 5; i++) {
+ final WeatherSpec.Daily gbForecast = new WeatherSpec.Daily();
+ gbForecast.minTemp = 10 + i + 273;
+ gbForecast.maxTemp = 25 + i + 273;
+
+ gbForecast.conditionCode = 800; // clear
+ weatherSpec.forecasts.add(gbForecast);
+ }
+
+ Weather.getInstance().setWeatherSpec(new ArrayList<>(Collections.singletonList(weatherSpec)));
+ }
+
+ final ArrayList<WeatherSpec> specs = new ArrayList<>(Weather.getInstance().getWeatherSpecs());
+ GBApplication.deviceService().onSendWeather(specs);
+ }
+ });
+
+ Button showCachedWeatherButton = findViewById(R.id.showCachedWeatherButton);
+ showCachedWeatherButton.setOnClickListener(new View.OnClickListener(){
+ @Override
+ public void onClick(View v) {
+ final List<WeatherSpec> weatherSpecs = Weather.getInstance().getWeatherSpecs();
+
+ if (weatherSpecs == null || weatherSpecs.isEmpty()) {
+ displayWeatherInfo(null);
+ return;
+ } else if (weatherSpecs.size() == 1) {
+ displayWeatherInfo(weatherSpecs.get(0));
+ return;
+ }
+
+ final String[] weatherLocations = new String[weatherSpecs.size()];
+
+ for (int i = 0; i < weatherSpecs.size(); i++) {
+ weatherLocations[i] = weatherSpecs.get(i).location;
+ }
+
+ new MaterialAlertDialogBuilder(DebugActivity.this)
+ .setCancelable(true)
+ .setTitle("Choose Location")
+ .setItems(weatherLocations, (dialog, which) -> displayWeatherInfo(weatherSpecs.get(which)))
+ .setNegativeButton("Cancel", (dialog, which) -> {
+ })
+ .show();
+ }
+ });
+
+ Button setMusicInfoButton = findViewById(R.id.setMusicInfoButton);
+ setMusicInfoButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ MusicSpec musicSpec = new MusicSpec();
+ String testString = editContent.getText().toString();
+ musicSpec.artist = testString + "(artist)";
+ musicSpec.album = testString + "(album)";
+ musicSpec.track = testString + "(track)";
+ musicSpec.duration = 10;
+ musicSpec.trackCount = 5;
+ musicSpec.trackNr = 2;
+
+ GBApplication.deviceService().onSetMusicInfo(musicSpec);
+
+ MusicStateSpec stateSpec = new MusicStateSpec();
+ stateSpec.position = 0;
+ stateSpec.state = 0x01; // playing
+ stateSpec.playRate = 100;
+ stateSpec.repeat = 1;
+ stateSpec.shuffle = 1;
+
+ GBApplication.deviceService().onSetMusicState(stateSpec);
+ }
+ });
+
+ Button setTimeButton = findViewById(R.id.setTimeButton);
+ setTimeButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GBApplication.deviceService().onSetTime();
+ }
+ });
+
+ Button testNotificationButton = findViewById(R.id.testNotificationButton);
+ testNotificationButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ testNotification();
+ }
+ });
+
+ Button testPebbleKitNotificationButton = findViewById(R.id.testPebbleKitNotificationButton);
+ testPebbleKitNotificationButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ testPebbleKitNotification();
+ }
+ });
+
+ Button fetchDebugLogsButton = findViewById(R.id.fetchDebugLogsButton);
+ fetchDebugLogsButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GBApplication.deviceService().onFetchRecordedData(RecordedDataTypes.TYPE_DEBUGLOGS);
+ }
+ });
+
+ Button testNewFunctionalityButton = findViewById(R.id.testNewFunctionality);
+ testNewFunctionalityButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ testNewFunctionality();
+ }
+ });
+
+ Button shareLogButton = findViewById(R.id.shareLog);
+ shareLogButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(DebugActivity.this);
+ Prefs prefs = new Prefs(sharedPrefs);
+ boolean logging_enabled = prefs.getBoolean("log_to_file", false);
+ if (logging_enabled) {
+ showLogSharingWarning();
+ } else {
+ showLogSharingNotEnabledAlert();
+ }
+ }
+ });
+
+ Button showWidgetsButton = findViewById(R.id.showWidgetsButton);
+ showWidgetsButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ showAllRegisteredAppWidgets();
+ }
+ });
+
+ Button unregisterWidgetsButton = findViewById(R.id.deleteWidgets);
+ unregisterWidgetsButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ unregisterAllRegisteredAppWidgets();
+ }
+ });
+
+ Button showWidgetsPrefsButton = findViewById(R.id.showWidgetsPrefs);
+ showWidgetsPrefsButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ showAppWidgetsPrefs();
+ }
+ });
+
+ Button deleteWidgetsPrefsButton = findViewById(R.id.deleteWidgetsPrefs);
+ deleteWidgetsPrefsButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ deleteWidgetsPrefs();
+ }
+ });
+
+ Button removeDevicePreferencesButton = findViewById(R.id.removeDevicePreferences);
+ removeDevicePreferencesButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ new MaterialAlertDialogBuilder(DebugActivity.this)
+ .setCancelable(true)
+ .setTitle(R.string.debugactivity_confirm_remove_device_preferences_title)
+ .setMessage(R.string.debugactivity_confirm_remove_device_preferences)
+ .setPositiveButton(R.string.ok, (dialog, which) -> {
+ final GBApplication gbApp = (GBApplication) getApplicationContext();
+ final List<GBDevice> devices = gbApp.getDeviceManager().getSelectedDevices();
+ for(final GBDevice device : devices){
+ GBApplication.deleteDeviceSpecificSharedPrefs(device.getAddress());
+ }
+ })
+ .setNegativeButton(R.string.Cancel, (dialog, which) -> {})
+ .show();
+ }
+ });
+
+ Button runDebugFunction = findViewById(R.id.runDebugFunction);
+ runDebugFunction.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ //SharedPreferences.Editor editor = GBApplication.getPrefs().getPreferences().edit();
+ //editor.remove("notification_list_is_blacklist").apply();
+ }
+ });
+
+ Button addDeviceButtonDebug = findViewById(R.id.addDeviceButtonDebug);
+ addDeviceButtonDebug.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Map<String, Pair<Long, Integer>> allDevices = getAllSupportedDevices(getApplicationContext());
+
+ final LinearLayout linearLayout = new LinearLayout(DebugActivity.this);
+ linearLayout.setOrientation(LinearLayout.VERTICAL);
+
+ final LinearLayout macLayout = new LinearLayout(DebugActivity.this);
+ macLayout.setOrientation(LinearLayout.HORIZONTAL);
+ macLayout.setPadding(20, 0, 20, 0);
+
+ final TextView textView = new TextView(DebugActivity.this);
+ textView.setText("MAC Address: ");
+ final EditText editText = new EditText(DebugActivity.this);
+ selectedTestDeviceMAC = randomMac();
+ editText.setText(selectedTestDeviceMAC);
+ editText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable editable) {
+ selectedTestDeviceMAC = editable.toString();
+
+ }
+ });
+
+ macLayout.addView(textView);
+ macLayout.addView(editText);
+
+ final Spinner deviceListSpinner = new Spinner(DebugActivity.this);
+ ArrayList<SpinnerWithIconItem> deviceListArray = new ArrayList<>();
+ for (Map.Entry<String, Pair<Long, Integer>> item : allDevices.entrySet()) {
+ deviceListArray.add(new SpinnerWithIconItem(item.getKey(), item.getValue().first, item.getValue().second));
+ }
+ final SpinnerWithIconAdapter deviceListAdapter = new SpinnerWithIconAdapter(DebugActivity.this,
+ R.layout.spinner_with_image_layout, R.id.spinner_item_text, deviceListArray);
+ deviceListSpinner.setAdapter(deviceListAdapter);
+ addListenerOnSpinnerDeviceSelection(deviceListSpinner);
+
+ linearLayout.addView(deviceListSpinner);
+ linearLayout.addView(macLayout);
+
+ new MaterialAlertDialogBuilder(DebugActivity.this)
+ .setCancelable(true)
+ .setTitle(R.string.add_test_device)
+ .setView(linearLayout)
+ .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ createTestDevice(DebugActivity.this, selectedTestDeviceKey, selectedTestDeviceMAC, null);
+ }
+ })
+ .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ })
+ .show();
+ }
+ });
+
+ CheckBox activity_list_debug_extra_time_range = findViewById(R.id.activity_list_debug_extra_time_range);
+ activity_list_debug_extra_time_range.setAllCaps(true);
+ boolean activity_list_debug_extra_time_range_value = GBApplication.getPrefs().getPreferences().getBoolean("activity_list_debug_extra_time_range", false);
+ activity_list_debug_extra_time_range.setChecked(activity_list_debug_extra_time_range_value);
+
+ activity_list_debug_extra_time_range.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
+ GBApplication.getPrefs().getPreferences().getBoolean("activity_list_debug_extra_time_range", false);
+ SharedPreferences.Editor editor = GBApplication.getPrefs().getPreferences().edit();
+ editor.putBoolean("activity_list_debug_extra_time_range", b).apply();
+ }
+ });
+
+ Button startFitnessAppTracking = findViewById(R.id.startFitnessAppTracking);
+ startFitnessAppTracking.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ OpenTracksController.startRecording(DebugActivity.this);
+ }
+ });
+
+ Button stopFitnessAppTracking = findViewById(R.id.stopFitnessAppTracking);
+ stopFitnessAppTracking.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ OpenTracksController.stopRecording(DebugActivity.this);
+ }
+ });
+
+ Button stopPhoneGpsLocationListener = findViewById(R.id.stopPhoneGpsLocationListener);
+ stopPhoneGpsLocationListener.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GBLocationService.stop(DebugActivity.this, null);
+ }
+ });
+
+ Button showCompanionDevices = findViewById(R.id.showCompanionDevices);
+ showCompanionDevices.setVisibility(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? View.VISIBLE : View.GONE);
+ showCompanionDevices.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ LOG.warn("Android version < O, companion devices not supported");
+ return;
+ }
+
+ final CompanionDeviceManager manager = (CompanionDeviceManager) GBApplication.getContext().getSystemService(Context.COMPANION_DEVICE_SERVICE);
+ final List<String> associations = new ArrayList<>(manager.getAssociations());
+ Collections.sort(associations);
+ String companionDevicesList = String.format(Locale.ROOT, "%d companion devices", associations.size());
+ if (!associations.isEmpty()) {
+ companionDevicesList += "\n\n" + StringUtils.join("\n", associations.toArray(new String[0]));
+ }
+
+ new MaterialAlertDialogBuilder(DebugActivity.this)
+ .setCancelable(false)
+ .setTitle("Companion Devices")
+ .setMessage(companionDevicesList)
+ .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ })
+ .show();
+ }
+ });
+
+ final Button pairAsCompanion = findViewById(R.id.pairAsCompanion);
+ pairAsCompanion.setVisibility(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? View.VISIBLE : View.GONE);
+ pairAsCompanion.setOnClickListener(v -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ pairCurrentAsCompanion();
+ }
+ });
+
+ Button showStatusFitnessAppTracking = findViewById(R.id.showStatusFitnessAppTracking);
+ final int delay = 2 * 1000;
+
+ showStatusFitnessAppTracking.setOnClickListener(new View.OnClickListener() {
+ final Handler handler = new Handler();
+ Runnable runnable;
+
+ @Override
+ public void onClick(View v) {
+ final MaterialAlertDialogBuilder fitnesStatusBuilder = new MaterialAlertDialogBuilder(DebugActivity.this);
+ fitnesStatusBuilder
+ .setCancelable(false)
+ .setTitle("openTracksObserver Status")
+ .setMessage("Starting openTracksObserver watcher, waiting for an update, refreshing every: " + delay + "ms")
+ .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ handler.removeCallbacks(runnable);
+ }
+ });
+ final AlertDialog alert = fitnesStatusBuilder.show();
+
+
+ runnable = new Runnable() {
+ @Override
+ public void run() {
+ LOG.debug("openTracksObserver debug watch dialog running");
+ handler.postDelayed(this, delay); //schedule next execution
+
+ OpenTracksContentObserver openTracksObserver = GBApplication.app().getOpenTracksObserver();
+ if (openTracksObserver == null) {
+ LOG.debug("openTracksObserver is null");
+ alert.cancel();
+ alert.setMessage("openTracksObserver not running");
+ alert.show();
+ return;
+ }
+ LOG.debug("openTracksObserver is not null, updating debug view");
+ long timeSecs = openTracksObserver.getTimeMillisChange() / 1000;
+ float distanceCM = openTracksObserver.getDistanceMeterChange() * 100;
+
+ LOG.debug("Time: " + timeSecs + " distanceCM " + distanceCM);
+ alert.cancel();
+ alert.setMessage("TimeSec: " + timeSecs + " distanceCM " + distanceCM);
+ alert.show();
+ }
+ };
+ handler.postDelayed(runnable, delay);
+ }
+ });
+
+ Button cameraOpenButton = findViewById(R.id.cameraOpen);
+ cameraOpenButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+
+ Intent cameraIntent = new Intent(getApplicationContext(), CameraActivity.class);
+ cameraIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ cameraIntent.putExtra(CameraActivity.intentExtraEvent, GBDeviceEventCameraRemote.eventToInt(GBDeviceEventCameraRemote.Event.OPEN_CAMERA));
+ getApplicationContext().startActivity(cameraIntent);
+ }
+ });
+
+ Button startWelcomeActivity = findViewById(R.id.startWelcomeActivity);
+ startWelcomeActivity.setOnClickListener(view -> {
+ startActivity(new Intent(this, WelcomeActivity.class));
+ });
+
+ Button startPermissionsActivity = findViewById(R.id.startPermissionsActivity);
+ startPermissionsActivity.setOnClickListener(view -> {
+ startActivity(new Intent(this, PermissionsActivity.class));
+ });
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
+ requestCode == SELECT_DEVICE_REQUEST_CODE &&
+ resultCode == Activity.RESULT_OK) {
+
+ final BluetoothDevice deviceToPair = data.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE);
+
+ if (deviceToPair != null) {
+ if (deviceToPair.getBondState() != BluetoothDevice.BOND_BONDED) {
+ GB.toast("Creating bond...", Toast.LENGTH_SHORT, GB.INFO);
+ deviceToPair.createBond();
+ } else {
+ GB.toast("Bonding complete", Toast.LENGTH_LONG, GB.INFO);
+ }
+ } else {
+ GB.toast("No device to pair", Toast.LENGTH_LONG, GB.ERROR);
+ }
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private void pairCurrentAsCompanion() {
+ final GBApplication gbApp = (GBApplication) getApplicationContext();
+ final List<GBDevice> devices = gbApp.getDeviceManager().getSelectedDevices();
+ if (devices.size() != 1) {
+ GB.toast("Please connect to a single device that you want to pair as companion", Toast.LENGTH_LONG, GB.WARN);
+ return;
+ }
+
+ final GBDevice device = devices.get(0);
+
+ final CompanionDeviceManager manager = (CompanionDeviceManager) getSystemService(Context.COMPANION_DEVICE_SERVICE);
+
+ if (manager.getAssociations().contains(device.getAddress())) {
+ GB.toast(device.getAliasOrName() + " already paired as companion", Toast.LENGTH_LONG, GB.INFO);
+ return;
+ }
+
+ final BluetoothDeviceFilter deviceFilter = new BluetoothDeviceFilter.Builder()
+ .setAddress(device.getAddress())
+ .build();
+
+ final AssociationRequest pairingRequest = new AssociationRequest.Builder()
+ .addDeviceFilter(deviceFilter)
+ .setSingleDevice(true)
+ .build();
+
+ CompanionDeviceManager.Callback callback = new CompanionDeviceManager.Callback() {
+ @Override
+ public void onFailure(final CharSequence error) {
+ GB.toast("Companion pairing failed: " + error, Toast.LENGTH_LONG, GB.ERROR);
+ }
+
+ @Override
+ public void onDeviceFound(@NonNull final IntentSender chooserLauncher) {
+ GB.toast("Found device", Toast.LENGTH_SHORT, GB.INFO);
+
+ try {
+ ActivityCompat.startIntentSenderForResult(
+ DebugActivity.this,
+ chooserLauncher,
+ SELECT_DEVICE_REQUEST_CODE,
+ null,
+ 0,
+ 0,
+ 0,
+ null
+ );
+ } catch (final IntentSender.SendIntentException e) {
+ LOG.error("Failed to send intent", e);
+ }
+ }
+ };
+
+ manager.associate(pairingRequest, callback, null);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ if (dataLossSave != null ) {
+ dataLossSave.clear();
+ dataLossSave = null ;
+ }
+ dataLossSave = new Bundle();
+ dataLossSave.putString("editContent", editContent.getText().toString());
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (dataLossSave != null ) {
+ editContent.setText(dataLossSave.getString("editContent", ""));
+ }else{
+ editContent.setText("Test");
+ }
+ }
+
+ private void deleteWidgetsPrefs() {
+ WidgetPreferenceStorage widgetPreferenceStorage = new WidgetPreferenceStorage();
+ widgetPreferenceStorage.deleteWidgetsPrefs(DebugActivity.this);
+ widgetPreferenceStorage.showAppWidgetsPrefs(DebugActivity.this);
+ }
+
+ private void showAppWidgetsPrefs() {
+ WidgetPreferenceStorage widgetPreferenceStorage = new WidgetPreferenceStorage();
+ widgetPreferenceStorage.showAppWidgetsPrefs(DebugActivity.this);
+
+ }
+
+ private void showAllRegisteredAppWidgets() {
+ //https://stackoverflow.com/questions/17387191/check-if-a-widget-is-exists-on-homescreen-using-appwidgetid
+
+ AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(DebugActivity.this);
+ AppWidgetHost appWidgetHost = new AppWidgetHost(DebugActivity.this, 1); // for removing phantoms
+ int[] appWidgetIDs = appWidgetManager.getAppWidgetIds(new ComponentName(DebugActivity.this, Widget.class));
+ GB.toast("Number of registered app widgets: " + appWidgetIDs.length, Toast.LENGTH_SHORT, GB.INFO);
+ for (int appWidgetID : appWidgetIDs) {
+ GB.toast("Widget: " + appWidgetID, Toast.LENGTH_SHORT, GB.INFO);
+ }
+ }
+
+ private void unregisterAllRegisteredAppWidgets() {
+ //https://stackoverflow.com/questions/17387191/check-if-a-widget-is-exists-on-homescreen-using-appwidgetid
+
+ AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(DebugActivity.this);
+ AppWidgetHost appWidgetHost = new AppWidgetHost(DebugActivity.this, 1); // for removing phantoms
+ int[] appWidgetIDs = appWidgetManager.getAppWidgetIds(new ComponentName(DebugActivity.this, Widget.class));
+ GB.toast("Number of registered app widgets: " + appWidgetIDs.length, Toast.LENGTH_SHORT, GB.INFO);
+ for (int appWidgetID : appWidgetIDs) {
+ appWidgetHost.deleteAppWidgetId(appWidgetID);
+ GB.toast("Removing widget: " + appWidgetID, Toast.LENGTH_SHORT, GB.INFO);
+ }
+ }
+
+ private void showLogSharingNotEnabledAlert() {
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.note)
+ .setPositiveButton(R.string.ok, null)
+ .setMessage(R.string.share_log_not_enabled_message)
+ .show();
+ }
+
+ private void showLogSharingWarning() {
+ new MaterialAlertDialogBuilder(this)
+ .setCancelable(true)
+ .setTitle(R.string.warning)
+ .setMessage(R.string.share_log_warning)
+ .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ shareLog();
+ }
+ })
+ .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ // do nothing
+ }
+ })
+ .show();
+ }
+
+ private void testNewFunctionality() {
+ GBApplication.deviceService().onTestNewFunction();
+ }
+
+ private void shareLog() {
+ String fileName = GBApplication.getLogPath();
+ if (fileName != null && fileName.length() > 0) {
+ // Flush the logs, so that we ensure latest lines are also there
+ GBApplication.getLogging().setImmediateFlush(true);
+ LOG.debug("Flushing logs before sharing");
+ GBApplication.getLogging().setImmediateFlush(false);
+
+ File logFile = new File(fileName);
+ if (!logFile.exists()) {
+ GB.toast("File does not exist", Toast.LENGTH_LONG, GB.INFO);
+ return;
+ }
+
+ final Uri providerUri = FileProvider.getUriForFile(
+ this,
+ getApplicationContext().getPackageName() + ".screenshot_provider",
+ logFile
+ );
+
+ Intent emailIntent = new Intent(android.content.Intent.ACTION_SEND);
+ emailIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ emailIntent.setType("*/*");
+ emailIntent.putExtra(EXTRA_SUBJECT, "Gadgetbridge log file");
+ emailIntent.putExtra(Intent.EXTRA_STREAM, providerUri);
+ startActivity(Intent.createChooser(emailIntent, "Share File"));
+ }
+ }
+
+ private void testNotification() {
+ Intent notificationIntent = new Intent(getApplicationContext(), DebugActivity.class);
+ notificationIntent.setPackage(BuildConfig.APPLICATION_ID);
+ notificationIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ PendingIntent pendingIntent = PendingIntentUtils.getActivity(getApplicationContext(), 0,
+ notificationIntent, 0, false);
+
+ RemoteInput remoteInput = new RemoteInput.Builder(EXTRA_REPLY)
+ .build();
+
+ Intent replyIntent = new Intent(ACTION_REPLY);
+ replyIntent.setPackage(BuildConfig.APPLICATION_ID);
+
+ PendingIntent replyPendingIntent = PendingIntentUtils.getBroadcast(this, 0, replyIntent, 0, true);
+
+ NotificationCompat.Action action =
+ new NotificationCompat.Action.Builder(android.R.drawable.ic_input_add, "Reply", replyPendingIntent)
+ .addRemoteInput(remoteInput)
+ .build();
+
+ NotificationCompat.WearableExtender wearableExtender = new NotificationCompat.WearableExtender().addAction(action);
+
+ NotificationCompat.Builder ncomp = new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
+ .setContentTitle(getString(R.string.test_notification))
+ .setContentText(getString(R.string.this_is_a_test_notification_from_gadgetbridge))
+ .setTicker(getString(R.string.this_is_a_test_notification_from_gadgetbridge))
+ .setSmallIcon(R.drawable.ic_notification)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+ .extend(wearableExtender);
+
+ GB.notify((int) System.currentTimeMillis(), ncomp.build(), this);
+ }
+
+ private void testPebbleKitNotification() {
+ Intent pebbleKitIntent = new Intent("com.getpebble.action.SEND_NOTIFICATION");
+ pebbleKitIntent.putExtra("messageType", "PEBBLE_ALERT");
+ pebbleKitIntent.putExtra("notificationData", "[{\"title\":\"PebbleKitTest\",\"body\":\"sent from Gadgetbridge\"}]");
+ getApplicationContext().sendBroadcast(pebbleKitIntent);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
+ unregisterReceiver(mReceiver);
+ }
+
+ private void addListenerOnSpinnerDeviceSelection(Spinner spinner) {
+ spinner.setOnItemSelectedListener(new CustomOnDeviceSelectedListener());
+ }
+
+ public static void createTestDevice(Context context, long deviceKey, String deviceMac, String deviceName) {
+ if (deviceKey == SELECT_DEVICE) {
+ return;
+ }
+ DeviceType deviceType = DeviceType.values()[(int) deviceKey];
+ if(deviceName == null) {
+ int deviceNameResource = deviceType.getDeviceCoordinator().getDeviceNameResource();
+ if(deviceNameResource == 0){
+ deviceName = deviceType.name();
+ }else {
+ deviceName = context.getString(deviceNameResource);
+ }
+ };
+ try (
+ DBHandler db = GBApplication.acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ GBDevice gbDevice = new GBDevice(deviceMac, deviceName, "", null, deviceType);
+ gbDevice.setFirmwareVersion("N/A");
+ gbDevice.setFirmwareVersion2("N/A");
+
+ //this causes the attributes (fw version) to be stored as well. Not much useful, but still...
+ gbDevice.setState(GBDevice.State.INITIALIZED);
+
+ Device device = DBHelper.getDevice(gbDevice, daoSession); //the addition happens here
+ Intent refreshIntent = new Intent(DeviceManager.ACTION_REFRESH_DEVICELIST);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(refreshIntent);
+ GB.toast(context, "Added test device: " + deviceName, Toast.LENGTH_SHORT, GB.INFO);
+
+ } catch (
+ Exception e) {
+ GB.log("Error accessing database", GB.ERROR, e);
+ }
+ }
+
+ private String randomMac() {
+ Random random = new Random();
+ String separator = ":";
+ String[] mac = {
+ String.format("%02x", random.nextInt(0xff)),
+ String.format("%02x", random.nextInt(0xff)),
+ String.format("%02x", random.nextInt(0xff)),
+ String.format("%02x", random.nextInt(0xff)),
+ String.format("%02x", random.nextInt(0xff)),
+ String.format("%02x", random.nextInt(0xff))
+ };
+ return TextUtils.join(separator, mac).toUpperCase(Locale.ROOT);
+ }
+
+ private void displayWeatherInfo(final WeatherSpec weatherSpec) {
+ final String weatherInfo = getWeatherInfo(weatherSpec);
+
+ new MaterialAlertDialogBuilder(DebugActivity.this)
+ .setCancelable(true)
+ .setTitle("Cached Weather Data")
+ .setMessage(weatherInfo)
+ .setPositiveButton(R.string.ok, (dialog, which) -> {
+ })
+ .setNeutralButton(android.R.string.copy, (dialog, which) -> {
+ final ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
+ ClipData clip = ClipData.newPlainText("Weather Info", weatherInfo);
+ clipboard.setPrimaryClip(clip);
+ })
+ .show();
+ }
+
+ private String getWeatherInfo(final WeatherSpec weatherSpec) {
+ final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ROOT);
+
+ final StringBuilder builder = new StringBuilder();
+
+ if (weatherSpec == null)
+ return "Weather cache is empty.";
+
+ builder.append("Location: ").append(weatherSpec.location).append("\n");
+ builder.append("Timestamp: ").append(weatherSpec.timestamp).append("\n");
+ builder.append("Current Temp: ").append(weatherSpec.currentTemp).append(" K\n");
+ builder.append("Max Temp: ").append(weatherSpec.todayMaxTemp).append(" K\n");
+ builder.append("Min Temp: ").append(weatherSpec.todayMinTemp).append(" K\n");
+ builder.append("Condition: ").append(weatherSpec.currentCondition).append("\n");
+ builder.append("Condition Code: ").append(weatherSpec.currentConditionCode).append("\n");
+ builder.append("Humidity: ").append(weatherSpec.currentHumidity).append("\n");
+ builder.append("Wind Speed: ").append(weatherSpec.windSpeed).append(" kmph\n");
+ builder.append("Wind Direction: ").append(weatherSpec.windDirection).append(" deg\n");
+ builder.append("UV Index: ").append(weatherSpec.uvIndex).append("\n");
+ builder.append("Precip Probability: ").append(weatherSpec.precipProbability).append(" %\n");
+ builder.append("Dew Point: ").append(weatherSpec.dewPoint).append(" K\n");
+ builder.append("Pressure: ").append(weatherSpec.pressure).append(" mb\n");
+ builder.append("Cloud Cover: ").append(weatherSpec.cloudCover).append(" %\n");
+ builder.append("Visibility: ").append(weatherSpec.visibility).append(" m\n");
+ builder.append("Sun Rise: ").append(sdf.format(new Date(weatherSpec.sunRise * 1000L))).append("\n");
+ builder.append("Sun Set: ").append(sdf.format(new Date(weatherSpec.sunSet * 1000L))).append("\n");
+ builder.append("Moon Rise: ").append(sdf.format(new Date(weatherSpec.moonRise * 1000L))).append("\n");
+ builder.append("Moon Set: ").append(sdf.format(new Date(weatherSpec.moonSet * 1000L))).append("\n");
+ builder.append("Moon Phase: ").append(weatherSpec.moonPhase).append(" deg\n");
+ builder.append("Latitude: ").append(weatherSpec.latitude).append("\n");
+ builder.append("Longitude: ").append(weatherSpec.longitude).append("\n");
+ builder.append("Feels Like Temp: ").append(weatherSpec.feelsLikeTemp).append(" K\n");
+ builder.append("Is Current Location: ").append(weatherSpec.isCurrentLocation).append("\n");
+
+ if (weatherSpec.airQuality != null) {
+ builder.append("Air Quality aqi: ").append(weatherSpec.airQuality.aqi).append("\n");
+ builder.append("Air Quality co: ").append(weatherSpec.airQuality.co).append("\n");
+ builder.append("Air Quality no2: ").append(weatherSpec.airQuality.no2).append("\n");
+ builder.append("Air Quality o3: ").append(weatherSpec.airQuality.o3).append("\n");
+ builder.append("Air Quality pm10: ").append(weatherSpec.airQuality.pm10).append("\n");
+ builder.append("Air Quality pm25: ").append(weatherSpec.airQuality.pm25).append("\n");
+ builder.append("Air Quality so2: ").append(weatherSpec.airQuality.so2).append("\n");
+ builder.append("Air Quality coAqi: ").append(weatherSpec.airQuality.coAqi).append("\n");
+ builder.append("Air Quality no2Aqi: ").append(weatherSpec.airQuality.no2Aqi).append("\n");
+ builder.append("Air Quality o3Aqi: ").append(weatherSpec.airQuality.o3Aqi).append("\n");
+ builder.append("Air Quality pm10Aqi: ").append(weatherSpec.airQuality.pm10Aqi).append("\n");
+ builder.append("Air Quality pm25Aqi: ").append(weatherSpec.airQuality.pm25Aqi).append("\n");
+ builder.append("Air Quality so2Aqi: ").append(weatherSpec.airQuality.so2Aqi).append("\n");
+ } else {
+ builder.append("Air Quality: null\n");
+ }
+
+ int i = 0;
+ for (final WeatherSpec.Daily daily : weatherSpec.forecasts) {
+ builder.append("-------------\n");
+ builder.append("-->Day ").append(i++).append("\n");
+ builder.append("Max Temp: ").append(daily.maxTemp).append(" K\n");
+ builder.append("Min Temp: ").append(daily.minTemp).append(" K\n");
+ builder.append("Condition Code: ").append(daily.conditionCode).append("\n");
+ builder.append("Humidity: ").append(daily.humidity).append("\n");
+ builder.append("Wind Speed: ").append(daily.windSpeed).append(" kmph\n");
+ builder.append("Wind Direction: ").append(daily.windDirection).append(" deg\n");
+ builder.append("UV Index: ").append(daily.uvIndex).append("\n");
+ builder.append("Precip Probability: ").append(daily.precipProbability).append(" %\n");
+ builder.append("Sun Rise: ").append(sdf.format(new Date(daily.sunRise * 1000L))).append("\n");
+ builder.append("Sun Set: ").append(sdf.format(new Date(daily.sunSet * 1000L))).append("\n");
+ builder.append("Moon Rise: ").append(sdf.format(new Date(daily.moonRise * 1000L))).append("\n");
+ builder.append("Moon Set: ").append(sdf.format(new Date(daily.moonSet * 1000L))).append("\n");
+ builder.append("Moon Phase: ").append(daily.moonPhase).append(" deg\n");
+
+ if (daily.airQuality != null) {
+ builder.append("Air Quality aqi: ").append(daily.airQuality.aqi).append("\n");
+ builder.append("Air Quality co: ").append(daily.airQuality.co).append("\n");
+ builder.append("Air Quality no2: ").append(daily.airQuality.no2).append("\n");
+ builder.append("Air Quality o3: ").append(daily.airQuality.o3).append("\n");
+ builder.append("Air Quality pm10: ").append(daily.airQuality.pm10).append("\n");
+ builder.append("Air Quality pm25: ").append(daily.airQuality.pm25).append("\n");
+ builder.append("Air Quality so2: ").append(daily.airQuality.so2).append("\n");
+ builder.append("Air Quality coAqi: ").append(daily.airQuality.coAqi).append("\n");
+ builder.append("Air Quality no2Aqi: ").append(daily.airQuality.no2Aqi).append("\n");
+ builder.append("Air Quality o3Aqi: ").append(daily.airQuality.o3Aqi).append("\n");
+ builder.append("Air Quality pm10Aqi: ").append(daily.airQuality.pm10Aqi).append("\n");
+ builder.append("Air Quality pm25Aqi: ").append(daily.airQuality.pm25Aqi).append("\n");
+ builder.append("Air Quality so2Aqi: ").append(daily.airQuality.so2Aqi).append("\n");
+ } else {
+ builder.append("Air Quality: null\n");
+ }
+ }
+
+ builder.append("=============\n");
+
+ for (final WeatherSpec.Hourly hourly : weatherSpec.hourly) {
+ builder.append("-------------\n");
+ builder.append("-->Hour: ").append(sdf.format(new Date(hourly.timestamp * 1000L))).append("\n");
+ builder.append("Max Temp: ").append(hourly.temp).append(" K\n");
+ builder.append("Condition Code: ").append(hourly.conditionCode).append("\n");
+ builder.append("Humidity: ").append(hourly.humidity).append("\n");
+ builder.append("Wind Speed: ").append(hourly.windSpeed).append(" kmph\n");
+ builder.append("Wind Direction: ").append(hourly.windDirection).append(" deg\n");
+ builder.append("UV Index: ").append(hourly.uvIndex).append("\n");
+ builder.append("Precip Probability: ").append(hourly.precipProbability).append(" %\n");
+ }
+
+ return builder.toString();
+ }
+
+ public static Map<String, Pair<Long, Integer>> getAllSupportedDevices(Context appContext) {
+ LinkedHashMap<String, Pair<Long, Integer>> newMap = new LinkedHashMap<>(1);
+ GBApplication app = (GBApplication) appContext;
+ for (DeviceType deviceType : DeviceType.values()) {
+ DeviceCoordinator coordinator = deviceType.getDeviceCoordinator();
+ int icon = coordinator.getDefaultIconResource();
+ String name = app.getString(coordinator.getDeviceNameResource());
+ if (!name.startsWith(coordinator.getManufacturer())) {
+ name += " (" + coordinator.getManufacturer() + ")";
+ }
+ long deviceId = deviceType.ordinal();
+ newMap.put(name, new Pair(deviceId, icon));
+ }
+ TreeMap <String, Pair<Long, Integer>> sortedMap = new TreeMap<>(newMap);
+ newMap = new LinkedHashMap<>(1);
+ newMap.put(app.getString(R.string.widget_settings_select_device_title), new Pair(SELECT_DEVICE, R.drawable.ic_device_unknown));
+ newMap.put(app.getString(R.string.devicetype_scannable), new Pair((long) DeviceType.SCANNABLE.ordinal(), R.drawable.ic_device_scannable));
+ newMap.put(app.getString(R.string.devicetype_ble_gatt_client), new Pair((long) DeviceType.BLE_GATT_CLIENT.ordinal(), R.drawable.ic_device_scannable));
+
+ newMap.putAll(sortedMap);
+
+ return newMap;
+ }
+
+ public class CustomOnDeviceSelectedListener implements AdapterView.OnItemSelectedListener {
+
+ public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
+ SpinnerWithIconItem selectedItem = (SpinnerWithIconItem) parent.getItemAtPosition(pos);
+ selectedTestDeviceKey = selectedItem.getId();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> arg0) {
+ // TODO Auto-generated method stub
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java
new file mode 100644
index 0000000..726fea7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java
@@ -0,0 +1,282 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Andrzej Surowiec, Arjan
+ Schrijver, Carsten Pfeiffer, Daniel Dakhno, Daniele Gobbetti, Ganblejs,
+ gfwilliams, Gordon Williams, Johannes Tysiak, Jos茅 Rebelo, marco.altomonte,
+ Petr Van臎k, Taavi Eom盲e
+
+ 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.activities;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import java.io.Serializable;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.adapter.GBDeviceAdapterv2;
+import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DailyTotals;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class DevicesFragment extends Fragment {
+
+ private DeviceManager deviceManager;
+ private GBDeviceAdapterv2 mGBDeviceAdapter;
+ private RecyclerView deviceListView;
+ private FloatingActionButton fab;
+ List<GBDevice> deviceList;
+ private HashMap<String, DailyTotals> deviceActivityHashMap = new HashMap();
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ final GBDevice device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ switch (Objects.requireNonNull(action)) {
+ case DeviceManager.ACTION_DEVICES_CHANGED:
+ case GBApplication.ACTION_NEW_DATA:
+ if (action.equals(GBApplication.ACTION_NEW_DATA)) {
+ createRefreshTask("get activity data", requireContext(), device).execute();
+ }
+ if (device != null) {
+ // Refresh only this device
+ refreshSingleDevice(device);
+ } else {
+ refreshPairedDevices();
+ }
+
+ break;
+ case DeviceService.ACTION_REALTIME_SAMPLES:
+ handleRealtimeSample(device, intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE));
+ break;
+ }
+ }
+ };
+
+ private void handleRealtimeSample(GBDevice device, Serializable extra) {
+ if (extra instanceof ActivitySample) {
+ ActivitySample sample = (ActivitySample) extra;
+ if (HeartRateUtils.getInstance().isValidHeartRateValue(sample.getHeartRate())) {
+ if (device != null) {
+ refreshSingleDevice(device);
+ } else {
+ refreshPairedDevices();
+ }
+ }
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
+ View currentView = inflater.inflate(R.layout.fragment_devices, container, false);
+
+ deviceManager = ((GBApplication) getActivity().getApplication()).getDeviceManager();
+
+ deviceListView = currentView.findViewById(R.id.deviceListView);
+ deviceListView.setHasFixedSize(true);
+ deviceListView.setLayoutManager(new LinearLayoutManager(currentView.getContext()));
+
+ deviceList = deviceManager.getDevices();
+ mGBDeviceAdapter = new GBDeviceAdapterv2(currentView.getContext(), deviceList, deviceActivityHashMap);
+ mGBDeviceAdapter.setHasStableIds(true);
+
+ deviceListView.setAdapter(this.mGBDeviceAdapter);
+
+ // get activity data asynchronously, this fills the deviceActivityHashMap
+ // and calls refreshPairedDevices() 鈫� notifyDataSetChanged
+ deviceListView.post(new Runnable() {
+ @Override
+ public void run() {
+ if (getContext() != null) {
+ createRefreshTask("get activity data", getContext(), null).execute();
+ }
+ }
+ });
+
+ fab = currentView.findViewById(R.id.fab);
+ fab.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ launchDiscoveryActivity();
+ }
+ });
+
+ showFabIfNeccessary();
+
+ /* uncomment to enable fixed-swipe to reveal more actions
+
+ ItemTouchHelper swipeToDismissTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(
+ ItemTouchHelper.LEFT , ItemTouchHelper.RIGHT) {
+ @Override
+ public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
+ if(dX>50)
+ dX = 50;
+ super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
+
+ }
+
+ @Override
+ public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
+ GB.toast(getBaseContext(), "onMove", Toast.LENGTH_LONG, GB.ERROR);
+
+ return false;
+ }
+
+ @Override
+ public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
+ GB.toast(getBaseContext(), "onSwiped", Toast.LENGTH_LONG, GB.ERROR);
+
+ }
+
+ @Override
+ public void onChildDrawOver(Canvas c, RecyclerView recyclerView,
+ RecyclerView.ViewHolder viewHolder, float dX, float dY,
+ int actionState, boolean isCurrentlyActive) {
+ }
+ });
+
+ swipeToDismissTouchHelper.attachToRecyclerView(deviceListView);
+ */
+
+ registerForContextMenu(deviceListView);
+
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(GBApplication.ACTION_NEW_DATA);
+ filterLocal.addAction(DeviceManager.ACTION_DEVICES_CHANGED);
+ filterLocal.addAction(DeviceService.ACTION_REALTIME_SAMPLES);
+ LocalBroadcastManager.getInstance(requireContext()).registerReceiver(mReceiver, filterLocal);
+
+ refreshPairedDevices();
+
+ if (GB.isBluetoothEnabled() && deviceList.isEmpty() && Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ startActivity(new Intent(getActivity(), DiscoveryActivityV2.class));
+ }
+
+ return currentView;
+ }
+
+ private void launchDiscoveryActivity() {
+ startActivity(new Intent(getActivity(), DiscoveryActivityV2.class));
+ }
+
+ private void showFabIfNeccessary() {
+ if (GBApplication.getPrefs().getBoolean("display_add_device_fab", true)) {
+ fab.show();
+ } else {
+ if (deviceManager.getDevices().size() < 1) {
+ fab.show();
+ } else {
+ fab.hide();
+ }
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ if (deviceListView != null) unregisterForContextMenu(deviceListView);
+ LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(mReceiver);
+ super.onDestroy();
+ }
+
+ private DailyTotals getSteps(GBDevice device, DBHandler db) {
+ Calendar day = GregorianCalendar.getInstance();
+
+ return DailyTotals.getDailyTotalsForDevice(device, day, db);
+ }
+
+ public void refreshPairedDevices() {
+ if (mGBDeviceAdapter != null) {
+ mGBDeviceAdapter.rebuildFolders();
+ mGBDeviceAdapter.notifyDataSetChanged();
+ }
+ }
+
+ public void refreshSingleDevice(final GBDevice device) {
+ if (mGBDeviceAdapter != null) {
+ mGBDeviceAdapter.refreshSingleDevice(device);
+ }
+ }
+
+ public RefreshTask createRefreshTask(String task, Context context, GBDevice device) {
+ return new RefreshTask(task, context, device);
+ }
+
+ public class RefreshTask extends DBAccess {
+ private final GBDevice device;
+
+ public RefreshTask(final String task, final Context context, final GBDevice device) {
+ super(task, context);
+ this.device = device;
+ }
+
+ @Override
+ protected void doInBackground(final DBHandler db) {
+ if (device != null) {
+ updateDevice(db, device);
+ } else {
+ for (GBDevice gbDevice : deviceList) {
+ updateDevice(db, gbDevice);
+ }
+ }
+ }
+
+ private void updateDevice(final DBHandler db, final GBDevice gbDevice) {
+ final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
+ final boolean showActivityCard = GBApplication.getDevicePrefs(gbDevice).getBoolean(DeviceSettingsPreferenceConst.PREFS_ACTIVITY_IN_DEVICE_CARD, true);
+ if (coordinator.supportsActivityTracking() && showActivityCard) {
+ final DailyTotals stepsAndSleepData = getSteps(gbDevice, db);
+ deviceActivityHashMap.put(gbDevice.getAddress(), stepsAndSleepData);
+ }
+ }
+
+ @Override
+ protected void onPostExecute(final Object o) {
+ if (device != null) {
+ refreshSingleDevice(device);
+ } else {
+ refreshPairedDevices();
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java
new file mode 100644
index 0000000..9f1c46a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java
@@ -0,0 +1,242 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Davis Mosenkovs, Lem Dulfo, Taavi Eom盲e, Uwe Hermann
+
+ 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.activities;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.JavascriptInterface;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.widget.FrameLayout;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.NavUtils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager;
+import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceCommunicationService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.GBChromeClient;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.GBWebClient;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.JSInterface;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.WebViewSingleton;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_CONNECT;
+
+public class ExternalPebbleJSActivity extends AbstractGBActivity {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ExternalPebbleJSActivity.class);
+
+ private Uri confUri;
+ /**
+ * When bgjs is enabled, this field refers to the WebViewSingleton,
+ * otherwise it refers to the legacy webview from the activity_legacy_external_pebble_js layout
+ */
+ private WebView myWebView;
+ public static final String START_BG_WEBVIEW = "start_webview";
+ public static final String SHOW_CONFIG = "configure";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Bundle extras = getIntent().getExtras();
+
+ boolean showConfig = false;
+
+ UUID currentUUID = null;
+ GBDevice currentDevice = null;
+
+ if (extras == null) {
+ confUri = getIntent().getData();
+ if(confUri.getScheme().equals("gadgetbridge")) {
+ try {
+ currentUUID = UUID.fromString(confUri.getHost());
+ } catch (IllegalArgumentException e) {
+ LOG.error("UUID in incoming configuration is not a valid UUID: " +confUri.toString());
+ }
+
+ //first check if we are still connected to a pebble
+ DeviceManager deviceManager = ((GBApplication) getApplication()).getDeviceManager();
+ List<GBDevice> deviceList = deviceManager.getDevices();
+ for (GBDevice device : deviceList) {
+ if (device.getState() == GBDevice.State.INITIALIZED && device.getType().equals(DeviceType.PEBBLE)) {
+ currentDevice = device;
+ break;
+ }
+ }
+ if (currentDevice == null) {
+ //then try to reconnect to one of last connected Pebble devices
+ Set<String> lastDeviceAddresses = GBApplication.getPrefs().getStringSet(GBPrefs.LAST_DEVICE_ADDRESSES, Collections.emptySet());
+ for (GBDevice device : deviceList) {
+ if (!device.isConnected() && device.getType() == DeviceType.PEBBLE && lastDeviceAddresses.contains(device.getAddress())) {
+ Intent intent = new Intent(this, DeviceCommunicationService.class)
+ .setAction(ACTION_CONNECT)
+ .putExtra(GBDevice.EXTRA_DEVICE, device);
+ this.startService(intent);
+ currentDevice = device;
+ break;
+ }
+ }
+ }
+
+ showConfig = true; //we are getting incoming configuration data
+ }
+ } else {
+ currentDevice = extras.getParcelable(GBDevice.EXTRA_DEVICE);
+ currentUUID = (UUID) extras.getSerializable(DeviceService.EXTRA_APP_UUID);
+
+ if (currentDevice != null && extras.getBoolean(START_BG_WEBVIEW, false) && ((PebbleCoordinator) currentDevice.getDeviceCoordinator()).isBackgroundJsEnabled(currentDevice)) {
+ startBackgroundWebViewAndFinish();
+ return;
+ }
+ showConfig = extras.getBoolean(SHOW_CONFIG, false);
+ }
+
+ if (currentDevice != null && ((PebbleCoordinator) currentDevice.getDeviceCoordinator()).isBackgroundJsEnabled(currentDevice)) {
+ if (showConfig) {
+ Objects.requireNonNull(currentDevice, "Must provide a device when invoking this activity");
+ Objects.requireNonNull(currentUUID, "Must provide a uuid when invoking this activity");
+ WebViewSingleton.getInstance().runJavascriptInterface(this, currentDevice, currentUUID);
+ }
+
+ // FIXME: is this really supposed to be outside the check for SHOW_CONFIG?
+ setupBGWebView();
+ } else {
+ Objects.requireNonNull(currentDevice, "Must provide a device when invoking this activity without bgjs");
+ Objects.requireNonNull(currentUUID, "Must provide a uuid when invoking this activity without bgjs");
+ setupLegacyWebView(currentDevice, currentUUID);
+ }
+ }
+
+ private void startBackgroundWebViewAndFinish() {
+ WebViewSingleton.ensureCreated(this);
+ finish();
+ }
+
+ private void setupBGWebView() {
+ setContentView(R.layout.activity_external_pebble_js);
+ myWebView = WebViewSingleton.getInstance().getWebView(this);
+ if (myWebView.getParent() != null) {
+ ((ViewGroup) myWebView.getParent()).removeView(myWebView);
+ }
+ myWebView.setWillNotDraw(false);
+ myWebView.removeJavascriptInterface("GBActivity");
+ myWebView.addJavascriptInterface(new ActivityJSInterface(), "GBActivity");
+ FrameLayout fl = (FrameLayout) findViewById(R.id.webview_placeholder);
+ fl.addView(myWebView);
+
+ myWebView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
+ @Override
+ public void onViewAttachedToWindow(View v) {
+ v.setLayerType(View.LAYER_TYPE_HARDWARE, null);
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(View v) {
+ v.removeOnAttachStateChangeListener(this);
+ FrameLayout fl = (FrameLayout) findViewById(R.id.webview_placeholder);
+ fl.removeAllViews();
+ }
+ });
+ }
+
+ private void setupLegacyWebView(@NonNull GBDevice device, @NonNull UUID uuid) {
+ setContentView(R.layout.activity_legacy_external_pebble_js);
+ myWebView = (WebView) findViewById(R.id.configureWebview);
+ myWebView.clearCache(true);
+ myWebView.setWebViewClient(new GBWebClient());
+ myWebView.setWebChromeClient(new GBChromeClient());
+ WebSettings webSettings = myWebView.getSettings();
+ //noinspection SetJavaScriptEnabled
+ webSettings.setJavaScriptEnabled(true);
+ //needed to access the DOM
+ webSettings.setDomStorageEnabled(true);
+ //needed for localstorage
+ webSettings.setDatabaseEnabled(true);
+ // #3373 #3424 - Fix configuration for pebble apps
+ // TODO: this should use a WebViewAssetLoader
+ webSettings.setAllowFileAccess(true);
+
+ JSInterface gbJSInterface = new JSInterface(device, uuid);
+ myWebView.addJavascriptInterface(gbJSInterface, "GBjs");
+ myWebView.addJavascriptInterface(new ActivityJSInterface(), "GBActivity");
+
+ myWebView.loadUrl("file:///android_asset/app_config/configure.html");
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ String queryString = "";
+ if (confUri != null) {
+ //getting back with configuration data
+ LOG.debug("WEBVIEW returned config: " + confUri.toString());
+ try {
+ queryString = confUri.getEncodedQuery();
+ } catch (IllegalArgumentException e) {
+ GB.toast("returned uri: " + confUri.toString(), Toast.LENGTH_LONG, GB.ERROR);
+ }
+ myWebView.stopLoading();
+ myWebView.loadUrl("file:///android_asset/app_config/configure.html?" + queryString);
+ }
+ }
+ @Override
+ protected void onNewIntent(Intent incoming) {
+ incoming.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ super.onNewIntent(incoming);
+ confUri = incoming.getData();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private class ActivityJSInterface {
+
+ @JavascriptInterface
+ public void closeActivity() {
+ NavUtils.navigateUpFromSameTask(ExternalPebbleJSActivity.this);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FindPhoneActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FindPhoneActivity.java
new file mode 100644
index 0000000..4516c0a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FindPhoneActivity.java
@@ -0,0 +1,218 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Anemograph, Carsten Pfeiffer,
+ Cre3per, Daniele Gobbetti, Dmitriy Bogdanov, Jos茅 Rebelo, Pauli Salmenrinne
+
+ 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.activities;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.res.AssetFileDescriptor;
+import android.media.AudioManager;
+import android.media.MediaPlayer;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.VibrationEffect;
+import android.os.Vibrator;
+import android.view.View;
+import android.widget.Button;
+
+import androidx.core.content.ContextCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+
+
+public class FindPhoneActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(FindPhoneActivity.class);
+
+ public static final String ACTION_FOUND = "nodomain.freeyourgadget.gadgetbridge.findphone.action.reply";
+ public static final String ACTION_VIBRATE = "nodomain.freeyourgadget.gadgetbridge.findphone.action.vibrate";
+ public static final String ACTION_RING = "nodomain.freeyourgadget.gadgetbridge.findphone.action.ring";
+
+ public static final String EXTRA_RING = "extra_ring";
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action != null) {
+ LOG.info("Got action: {}", action);
+
+ switch (action) {
+ case ACTION_FOUND:
+ finish();
+ break;
+ case ACTION_VIBRATE:
+ stopSound();
+ break;
+ case ACTION_RING:
+ playRingtone();
+ break;
+ }
+ }
+ }
+ };
+
+ Vibrator mVibrator;
+ AudioManager mAudioManager;
+ int userVolume;
+ MediaPlayer mp;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_find_phone);
+
+ final boolean ring = getIntent().getBooleanExtra(EXTRA_RING, true);
+
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(ACTION_FOUND);
+ filter.addAction(ACTION_VIBRATE);
+ filter.addAction(ACTION_RING);
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filter);
+ ContextCompat.registerReceiver(this, mReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED); // for ACTION_FOUND
+
+ Button foundButton = findViewById(R.id.foundbutton);
+ foundButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ finish();
+ }
+ });
+
+ GB.removeNotification(GB.NOTIFICATION_ID_PHONE_FIND, this);
+
+ vibrate();
+ if (ring) {
+ playRingtone();
+ }
+ GBApplication.deviceService().onFindPhone(true);
+ }
+
+ private void vibrate() {
+ mVibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
+
+ long[] vibrationPattern = new long[]{1000, 1000};
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ VibrationEffect vibrationEffect = VibrationEffect.createWaveform(vibrationPattern, 0);
+
+ mVibrator.vibrate(vibrationEffect);
+ } else {
+ mVibrator.vibrate(vibrationPattern, 0);
+ }
+ }
+
+ private void playRingtone() {
+ mAudioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
+ if (mAudioManager != null) {
+ userVolume = mAudioManager.getStreamVolume(AudioManager.STREAM_ALARM);
+ }
+ if (mp != null && mp.isPlaying()) {
+ LOG.warn("Already playing");
+ return;
+ } else if (mp == null) {
+ mp = new MediaPlayer();
+ }
+
+ if (!playConfiguredRingtone()) {
+ playFallbackRingtone();
+ }
+
+ if (mAudioManager != null) {
+ userVolume = mAudioManager.getStreamVolume(AudioManager.STREAM_ALARM);
+ mAudioManager.setStreamVolume(AudioManager.STREAM_ALARM, mAudioManager.getStreamMaxVolume(AudioManager.STREAM_ALARM), AudioManager.FLAG_PLAY_SOUND);
+ }
+ }
+
+ private void stopVibration() {
+ mVibrator.cancel();
+ }
+
+ /**
+ * Attempt to play the configured ringtone. This fails to get the default ringtone on some ROMs
+ * (<a href="https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/2697">#2697</a>)
+ *
+ * @return whether playing the configured ringtone was successful or not.
+ */
+ private boolean playConfiguredRingtone() {
+ try {
+ Uri ringtoneUri = Uri.parse(GBApplication.getPrefs().getString(GBPrefs.PING_TONE, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE).toString()));
+ mp.setDataSource(this, ringtoneUri);
+ mp.setAudioStreamType(AudioManager.STREAM_ALARM);
+ mp.setLooping(true);
+ mp.prepare();
+ mp.start();
+ } catch (final Exception e) {
+ LOG.warn("Failed to play configured ringtone", e);
+ return false;
+ }
+
+ return true;
+ }
+
+ private void playFallbackRingtone() {
+ try {
+ final AssetFileDescriptor afd = getBaseContext().getResources().openRawResourceFd(R.raw.ping_tone);
+ if (afd == null) {
+ LOG.error("Failed to load fallback ringtone");
+ return;
+ }
+
+ mp.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength());
+ mp.setAudioStreamType(AudioManager.STREAM_ALARM);
+ mp.setLooping(true);
+ mp.prepare();
+ mp.start();
+ } catch (final Exception e) {
+ LOG.warn("Failed to play fallback ringtone", e);
+ }
+ }
+
+ private void stopSound() {
+ if (mAudioManager != null) {
+ mAudioManager.setStreamVolume(AudioManager.STREAM_ALARM, userVolume, AudioManager.FLAG_PLAY_SOUND);
+ }
+ if (mp != null) {
+ mp.stop();
+ mp.reset();
+ mp.release();
+ mp = null;
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+
+ stopVibration();
+ stopSound();
+ GBApplication.deviceService().onFindPhone(false);
+
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
+ unregisterReceiver(mReceiver);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FwAppInstallerActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FwAppInstallerActivity.java
new file mode 100644
index 0000000..2efedd5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FwAppInstallerActivity.java
@@ -0,0 +1,351 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
+ Dakhno, Daniele Gobbetti, Jos茅 Rebelo, Lem Dulfo, Petr Van臎k, Taavi Eom盲e
+
+ 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.activities;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.core.app.NavUtils;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.adapter.ItemWithDetailsAdapter;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.model.ItemWithDetails;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+
+public class FwAppInstallerActivity extends AbstractGBActivity implements InstallActivity {
+
+ private static final Logger LOG = LoggerFactory.getLogger(FwAppInstallerActivity.class);
+ private static final String ITEM_DETAILS = "details";
+
+ private TextView fwAppInstallTextView;
+ private ImageView previewImage;
+ private Button installButton;
+ private Uri uri;
+ private GBDevice device;
+ private InstallHandler installHandler;
+ private boolean mayConnect;
+
+ private ProgressBar progressBar;
+ private TextView progressText;
+ private ListView itemListView;
+ private final List<ItemWithDetails> items = new ArrayList<>();
+ private ItemWithDetailsAdapter itemAdapter;
+
+ private ListView detailsListView;
+ private ItemWithDetailsAdapter detailsAdapter;
+ private ArrayList<ItemWithDetails> details = new ArrayList<>();
+
+ private final BroadcastReceiver receiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (GBDevice.ACTION_DEVICE_CHANGED.equals(action)) {
+ final GBDevice changedDevice = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (changedDevice != null && changedDevice.equals(device)) {
+ refreshBusyState(device);
+ if (!device.isInitialized()) {
+ setInstallEnabled(false);
+ if (mayConnect) {
+ GB.toast(FwAppInstallerActivity.this, getString(R.string.connecting), Toast.LENGTH_SHORT, GB.INFO);
+ connect();
+ } else {
+ setInfoText(getString(R.string.fwappinstaller_connection_state, device.getStateString(context)));
+ }
+ } else {
+ validateInstallation();
+ }
+ }
+ } else if (GB.ACTION_SET_PROGRESS_BAR.equals(action)) {
+ if (intent.hasExtra(GB.PROGRESS_BAR_INDETERMINATE)) {
+ setProgressIndeterminate(intent.getBooleanExtra(GB.PROGRESS_BAR_INDETERMINATE, false));
+ }
+
+ if (intent.hasExtra(GB.PROGRESS_BAR_PROGRESS)) {
+ setProgressIndeterminate(false);
+ setProgressBar(intent.getIntExtra(GB.PROGRESS_BAR_PROGRESS, 0));
+ }
+ } else if (GB.ACTION_SET_PROGRESS_TEXT.equals(action)) {
+ if (intent.hasExtra(GB.DISPLAY_MESSAGE_MESSAGE)) {
+ setProgressText(intent.getStringExtra(GB.DISPLAY_MESSAGE_MESSAGE));
+ }
+ } else if (GB.ACTION_SET_INFO_TEXT.equals(action)) {
+ if (intent.hasExtra(GB.DISPLAY_MESSAGE_MESSAGE)) {
+ setInfoText(intent.getStringExtra(GB.DISPLAY_MESSAGE_MESSAGE));
+ }
+ } else if (GB.ACTION_DISPLAY_MESSAGE.equals(action)) {
+ String message = intent.getStringExtra(GB.DISPLAY_MESSAGE_MESSAGE);
+ int severity = intent.getIntExtra(GB.DISPLAY_MESSAGE_SEVERITY, GB.INFO);
+ addMessage(message, severity);
+ }
+ }
+ };
+
+ private void refreshBusyState(GBDevice dev) {
+ if (dev.isConnecting() || dev.isBusy()) {
+ progressBar.setVisibility(View.VISIBLE);
+ } else {
+ boolean wasBusy = progressBar.getVisibility() != View.GONE;
+ if (wasBusy) {
+ progressBar.setVisibility(View.GONE);
+ // done!
+ }
+ }
+ }
+
+ public void setProgressIndeterminate(boolean indeterminate) {
+ progressBar.setVisibility(View.VISIBLE);
+ progressBar.setIndeterminate(indeterminate);
+ }
+
+ public void setProgressBar(int progress) {
+ progressBar.setProgress(progress);
+ }
+
+ public void setProgressText(String text) {
+ progressText.setVisibility(View.VISIBLE);
+ progressText.setText(text);
+ }
+
+ private void connect() {
+ mayConnect = false; // only do that once per #onCreate
+ GBApplication.deviceService(device).connect();
+ }
+
+ private void validateInstallation() {
+ if (installHandler != null) {
+ installHandler.validateInstallation(this, device);
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_appinstaller);
+
+ GBDevice dev = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (dev != null) {
+ device = dev;
+ }
+ if (savedInstanceState != null) {
+ details = savedInstanceState.getParcelableArrayList(ITEM_DETAILS);
+ if (details == null) {
+ details = new ArrayList<>();
+ }
+ }
+
+ mayConnect = true;
+ itemListView = findViewById(R.id.itemListView);
+ itemAdapter = new ItemWithDetailsAdapter(this, items);
+ itemListView.setAdapter(itemAdapter);
+ fwAppInstallTextView = findViewById(R.id.infoTextView);
+ previewImage = findViewById(R.id.previewImage);
+ installButton = findViewById(R.id.installButton);
+ progressBar = findViewById(R.id.installProgressBar);
+ progressText = findViewById(R.id.installProgressText);
+ detailsListView = findViewById(R.id.detailsListView);
+ detailsAdapter = new ItemWithDetailsAdapter(this, details);
+ detailsAdapter.setSize(ItemWithDetailsAdapter.SIZE_SMALL);
+ detailsListView.setAdapter(detailsAdapter);
+
+ setInstallEnabled(false);
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(GBDevice.ACTION_DEVICE_CHANGED);
+ filter.addAction(GB.ACTION_DISPLAY_MESSAGE);
+ filter.addAction(GB.ACTION_SET_PROGRESS_BAR);
+ filter.addAction(GB.ACTION_SET_PROGRESS_TEXT);
+ filter.addAction(GB.ACTION_SET_INFO_TEXT);
+ LocalBroadcastManager.getInstance(this).registerReceiver(receiver, filter);
+
+ installButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ setInstallEnabled(false);
+ installHandler.onStartInstall(device);
+ GBApplication.deviceService(device).onInstallApp(uri);
+ }
+ });
+
+ uri = getIntent().getData();
+ if (uri == null) { // For "share" intent
+ uri = getIntent().getParcelableExtra(Intent.EXTRA_STREAM);
+ }
+ installHandler = findInstallHandlerFor(uri);
+ if (installHandler == null) {
+ setInfoText(getString(R.string.installer_activity_unable_to_find_handler));
+ } else {
+ setInfoText(getString(R.string.installer_activity_wait_while_determining_status));
+
+ List<GBDevice> selectedDevices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ if(selectedDevices.size() == 0){
+ GB.toast(getString(R.string.open_fw_installer_connect_minimum_one_device), Toast.LENGTH_LONG, GB.ERROR);
+ finish();
+ return;
+ }
+ if(selectedDevices.size() != 1){
+ GB.toast(getString(R.string.open_fw_installer_connect_maximum_one_device), Toast.LENGTH_LONG, GB.ERROR);
+ finish();
+ return;
+ }
+ device = selectedDevices.get(0);
+
+ // needed to get the device
+ if (device == null || !device.isConnected()) {
+ connect();
+ } else {
+ GBApplication.deviceService(device).requestDeviceInfo();
+ }
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelableArrayList(ITEM_DETAILS, details);
+ }
+
+ private InstallHandler findInstallHandlerFor(Uri uri) {
+ for (DeviceCoordinator coordinator : getAllCoordinatorsConnectedFirst()) {
+ InstallHandler handler = coordinator.findInstallHandler(uri, this);
+ if (handler != null) {
+ LOG.info("Found install handler {} from {}", handler.getClass(), coordinator.getClass());
+ return handler;
+ }
+ }
+ return null;
+ }
+
+ private List<DeviceCoordinator> getAllCoordinatorsConnectedFirst() {
+ DeviceManager deviceManager = ((GBApplication) getApplicationContext()).getDeviceManager();
+ List<DeviceCoordinator> connectedCoordinators = new ArrayList<>();
+ List<DeviceCoordinator> allCoordinators = new ArrayList<>(DeviceType.values().length);
+ for(DeviceType type : DeviceType.values()){
+ allCoordinators.add(type.getDeviceCoordinator());
+ }
+ List<DeviceCoordinator> sortedCoordinators = new ArrayList<>(allCoordinators.size());
+
+ List<GBDevice> devices = deviceManager.getSelectedDevices();
+ for(GBDevice connectedDevice : devices){
+ if (connectedDevice.isConnected()) {
+ DeviceCoordinator coordinator = connectedDevice.getDeviceCoordinator();
+ if (coordinator != null) {
+ connectedCoordinators.add(coordinator);
+ }
+ }
+ }
+
+
+ sortedCoordinators.addAll(connectedCoordinators);
+ for (DeviceCoordinator coordinator : allCoordinators) {
+ if (!connectedCoordinators.contains(coordinator)) {
+ sortedCoordinators.add(coordinator);
+ }
+ }
+ return sortedCoordinators;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onDestroy() {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver);
+ super.onDestroy();
+ }
+
+ @Override
+ public void setInfoText(String text) {
+ fwAppInstallTextView.setText(text);
+ }
+
+ @Override
+ public void setPreview(@Nullable final Bitmap bitmap) {
+ previewImage.setImageBitmap(bitmap);
+ if (previewImage == null) {
+ previewImage.setVisibility(View.GONE);
+ } else {
+ previewImage.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ public CharSequence getInfoText() {
+ return fwAppInstallTextView.getText();
+ }
+
+ @Override
+ public void setInstallEnabled(boolean enable) {
+ boolean enabled = device != null && device.isConnected() && enable;
+ installButton.setEnabled(enabled);
+ installButton.setVisibility(enabled ? View.VISIBLE : View.GONE);
+ }
+
+ @Override
+ public void clearInstallItems() {
+ items.clear();
+ itemAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public void setInstallItem(ItemWithDetails item) {
+ items.clear();
+ items.add(item);
+ itemAdapter.notifyDataSetChanged();
+ }
+
+ private void addMessage(String message, int severity) {
+ details.add(new GenericItem(message));
+ detailsAdapter.notifyDataSetChanged();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/GBActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/GBActivity.java
new file mode 100644
index 0000000..94b9b02
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/GBActivity.java
@@ -0,0 +1,25 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.activities;
+
+import java.util.Locale;
+
+public interface GBActivity {
+ void setLanguage(Locale language, boolean invalidateLanguage);
+ void setTheme(int themeId);
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/GpxReceiverActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/GpxReceiverActivity.java
new file mode 100644
index 0000000..3a6bc03
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/GpxReceiverActivity.java
@@ -0,0 +1,162 @@
+/* Copyright (C) 2021-2024 Gordon Williams, Petr Van臎k
+
+ 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.activities;
+
+import android.content.ClipData;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+
+public class GpxReceiverActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(ActivitySummaryDetail.class);
+ boolean toOverwrite = false;
+ ArrayList<FileToProcess> fileList = new ArrayList<>();
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_gpx_receiver);
+ Button gpx_receiver_ok = findViewById(R.id.gpx_receiver_ok);
+ Button gpx_receiver_cancel = findViewById(R.id.gpx_receiver_cancel);
+ View gpx_receiver_overwrite_label = findViewById(R.id.gpx_receiver_overwrite_label);
+ TextView gpx_receiver_files_listing = findViewById(R.id.gpx_receiver_files_listing);
+ TextView gpx_receiver_received_label = findViewById(R.id.gpx_receiver_received_label);
+
+ gpx_receiver_cancel.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ finish();
+ }
+ });
+
+ gpx_receiver_ok.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ for (FileToProcess fileToProcess : fileList) {
+ save_file(fileToProcess.source, fileToProcess.destination);
+ }
+ finish();
+ }
+ });
+
+ final Intent intent = getIntent();
+ final ClipData intentClipData = intent.getClipData();
+ StringBuilder fileListingText = new StringBuilder();
+ ArrayList<Uri> documentUris = new ArrayList<>();
+
+ if (savedInstanceState == null) {
+ if (intent.getData() != null) {
+ documentUris.add(intent.getData());
+ } else {
+ if (intentClipData != null && intentClipData.getItemCount() > 0) {
+ for (int i = 0; i < intentClipData.getItemCount(); i++) {
+ documentUris.add(intentClipData.getItemAt(i).getUri());
+ }
+ }
+ }
+ }
+
+ if (documentUris != null) {
+ for (Uri uri : documentUris) {
+ if (uri!=null && uri.getPath().toLowerCase().endsWith(".gpx")) {
+ FileToProcess file = new FileToProcess(uri);
+ fileList.add(file);
+ fileListingText.append(String.format("%s %s\n\n", file.name, file.exists ? getString(R.string.dbmanagementactivity_overwrite) : ""));
+ }
+ }
+ }
+
+ if (toOverwrite) {
+ gpx_receiver_overwrite_label.setVisibility(View.VISIBLE);
+ } else {
+ gpx_receiver_overwrite_label.setVisibility(View.GONE);
+ }
+
+ gpx_receiver_received_label.setText(String.format("%s %s", getString(R.string.gpx_receiver_files_received), fileList.toArray().length));
+ gpx_receiver_files_listing.setText(fileListingText.toString());
+ }
+
+ private String get_file_name(Uri source) {
+ int cut = source.getPath().lastIndexOf("/");
+ String fileName = null;
+ if (cut != -1) {
+ fileName = source.getPath().substring(cut + 1);
+ }
+ return fileName;
+ }
+
+ private File create_file_from_uri(Uri source) {
+ File destination = null;
+ try {
+ File external = FileUtils.getExternalFilesDir();
+ String fileName = get_file_name(source);
+
+ if (fileName != null) {
+ destination = new File(external + "/" + fileName);
+ }
+ } catch (IOException exception) {
+ LOG.error("Error creating file", exception);
+ }
+ return destination;
+ }
+
+ private void save_file(Uri source, File destination) {
+ try {
+ String fileName = get_file_name(source);
+ if (fileName != null) {
+ FileUtils.copyURItoFile(GpxReceiverActivity.this, source, destination);
+ }
+
+ } catch (IOException exception) {
+ LOG.error("Error copying file", exception);
+ }
+ }
+
+ private class FileToProcess {
+ String name;
+ Uri source;
+ File destination;
+ boolean exists;
+
+ FileToProcess(Uri file) {
+ this.name = get_file_name(file);
+ this.source = file;
+ this.destination = create_file_from_uri(file);
+ this.exists = this.destination.exists();
+
+ if (this.exists) {
+ toOverwrite = true;
+ }
+ }
+ }
+}
+
+
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateDialog.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateDialog.java
new file mode 100644
index 0000000..56380df
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateDialog.java
@@ -0,0 +1,153 @@
+/* Copyright (C) 2021-2024 Petr Van臎k
+
+ 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.activities;
+
+import android.app.Dialog;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+
+public class HeartRateDialog extends Dialog {
+ private final GBDevice device;
+
+ protected static final Logger LOG = LoggerFactory.getLogger(HeartRateDialog.class);
+ LinearLayout heart_rate_dialog_results_layout;
+ RelativeLayout heart_rate_dialog_loading_layout;
+
+ TextView heart_rate_widget_hr_value;
+ TextView heart_rate_widget_spo2_value;
+ TextView heart_rate_widget_pressure_value;
+
+ LinearLayout heart_rate_hr;
+ LinearLayout heart_rate_spo2;
+ LinearLayout heart_rate_pressure;
+
+ TextView heart_rate_dialog_label;
+
+ final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ switch (Objects.requireNonNull(intent.getAction())) {
+ case DeviceService.ACTION_REALTIME_SAMPLES:
+ final GBDevice incomingDevice = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (device.equals(incomingDevice)) {
+ setMeasurementResults(intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE));
+ }
+ break;
+ default:
+ LOG.info("ignoring intent action {}", intent.getAction());
+ break;
+ }
+ }
+ };
+
+ public HeartRateDialog(final GBDevice device, @NonNull Context context) {
+ super(context);
+ this.device = device;
+ }
+
+ private void setMeasurementResults(Serializable result) {
+ heart_rate_dialog_results_layout.setVisibility(View.VISIBLE);
+ heart_rate_dialog_loading_layout.setVisibility(View.GONE);
+ heart_rate_dialog_label.setText(getContext().getString(R.string.heart_rate_result));
+
+ int heartRate = 0;
+ if (result instanceof ActivitySample) {
+ ActivitySample sample = (ActivitySample) result;
+ heartRate = sample.getHeartRate();
+ }
+ if (result instanceof HeartRateSample) {
+ HeartRateSample sample = (HeartRateSample) result;
+ heartRate = sample.getHeartRate();
+ }
+ if (HeartRateUtils.getInstance().isValidHeartRateValue(heartRate)) {
+ heart_rate_hr.setVisibility(View.VISIBLE);
+ heart_rate_widget_hr_value.setText(String.valueOf(heartRate));
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(DeviceService.ACTION_REALTIME_SAMPLES);
+ LocalBroadcastManager.getInstance(getContext()).registerReceiver(mReceiver, filter);
+
+ setContentView(R.layout.heart_rate_dialog);
+ heart_rate_dialog_results_layout = findViewById(R.id.heart_rate_dialog_results_layout);
+ heart_rate_dialog_loading_layout = findViewById(R.id.heart_rate_dialog_loading_layout);
+
+ heart_rate_hr = findViewById(R.id.heart_rate_measurements1);
+ heart_rate_spo2 = findViewById(R.id.heart_rate_measurements2);
+ heart_rate_pressure = findViewById(R.id.heart_rate_measurements3);
+
+ TextView heart_rate_widget_hr_title = heart_rate_hr.findViewById(R.id.generic_widget_title);
+ TextView heart_rate_widget_spo2_title = heart_rate_spo2.findViewById(R.id.generic_widget_title);
+ TextView heart_rate_widget_pressure_title = heart_rate_pressure.findViewById(R.id.generic_widget_title);
+
+ heart_rate_widget_hr_value = heart_rate_hr.findViewById(R.id.generic_widget_value);
+ heart_rate_widget_spo2_value = heart_rate_spo2.findViewById(R.id.generic_widget_value);
+ heart_rate_widget_pressure_value = heart_rate_pressure.findViewById(R.id.generic_widget_value);
+
+ ImageView heart_rate_widget_hr_icon = heart_rate_hr.findViewById(R.id.generic_widget_icon);
+ ImageView heart_rate_widget_spo2_icon = heart_rate_spo2.findViewById(R.id.generic_widget_icon);
+ ImageView heart_rate_widget_pressure_icon = heart_rate_pressure.findViewById(R.id.generic_widget_icon);
+
+ heart_rate_widget_hr_icon.setImageResource(R.drawable.ic_heart);
+ heart_rate_widget_spo2_icon.setImageResource(R.drawable.ic_circle);
+ heart_rate_widget_pressure_icon.setImageResource(R.drawable.ic_heartrate);
+
+ heart_rate_hr.setVisibility(View.VISIBLE);
+ heart_rate_spo2.setVisibility(View.GONE);
+ heart_rate_pressure.setVisibility(View.GONE);
+
+ heart_rate_widget_hr_title.setText(R.string.heart_rate);
+ heart_rate_widget_spo2_title.setText(R.string.menuitem_spo2);
+ heart_rate_widget_pressure_title.setText(R.string.blood_pressure);
+
+ heart_rate_dialog_label = findViewById(R.id.heart_rate_dialog_title);
+ heart_rate_dialog_results_layout.setVisibility(View.GONE);
+ heart_rate_dialog_loading_layout.setVisibility(View.VISIBLE);
+
+ setOnCancelListener(dialogInterface -> {
+ LocalBroadcastManager.getInstance(getContext()).unregisterReceiver(mReceiver);
+ });
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateUtils.java
new file mode 100644
index 0000000..fec5418
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateUtils.java
@@ -0,0 +1,69 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Dikay900
+
+ 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.activities;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class HeartRateUtils {
+ public static final int MAX_HEART_RATE_VALUE = 250;
+ public static final int MIN_HEART_RATE_VALUE = 10;
+
+ /**
+ * The maxiumum gap between two hr measurements in which
+ * we interpolate between the measurements. Otherwise, two
+ * distinct measurements will be shown.
+ *
+ * Value is in minutes
+ */
+ public static final int MAX_HR_MEASUREMENTS_GAP_MINUTES = 10;
+
+ private int maxHeartRateValue;
+ private int minHeartRateValue;
+
+ private static final HeartRateUtils instance = new HeartRateUtils();
+
+ public static HeartRateUtils getInstance() {
+ return instance;
+ }
+
+ /**
+ * Singleton - to access this class use the static #getInstance()
+ */
+ private HeartRateUtils() {
+ updateCachedHeartRatePreferences();
+ }
+
+ public void updateCachedHeartRatePreferences(){
+ Prefs prefs = GBApplication.getPrefs();
+ maxHeartRateValue = prefs.getInt(GBPrefs.CHART_MAX_HEART_RATE, MAX_HEART_RATE_VALUE);
+ minHeartRateValue = prefs.getInt(GBPrefs.CHART_MIN_HEART_RATE, MIN_HEART_RATE_VALUE);
+ }
+
+ public int getMaxHeartRate(){
+ return maxHeartRateValue;
+ }
+
+ public int getMinHeartRate(){
+ return minHeartRateValue;
+ }
+
+ public boolean isValidHeartRateValue(int value) {
+ return value > 0 && value >= getMinHeartRate() && value <= getMaxHeartRate();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/InstallActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/InstallActivity.java
new file mode 100644
index 0000000..162b064
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/InstallActivity.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.graphics.Bitmap;
+
+import androidx.annotation.Nullable;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ItemWithDetails;
+
+public interface InstallActivity {
+ CharSequence getInfoText();
+
+ void setInfoText(String text);
+
+ void setPreview(@Nullable Bitmap bitmap);
+
+ void setInstallEnabled(boolean enable);
+
+ void clearInstallItems();
+
+ void setInstallItem(ItemWithDetails item);
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/NotificationFilterActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/NotificationFilterActivity.java
new file mode 100644
index 0000000..ab6e3ff
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/NotificationFilterActivity.java
@@ -0,0 +1,233 @@
+/* Copyright (C) 2018-2024 abettenburg, AndrewBedscastle, Carsten Pfeiffer,
+ Daniele Gobbetti
+
+ 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.activities;
+
+import android.os.Bundle;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.Spinner;
+import android.widget.Toast;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import de.greenrobot.dao.query.Query;
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.adapter.AppBlacklistAdapter;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.entities.NotificationFilter;
+import nodomain.freeyourgadget.gadgetbridge.entities.NotificationFilterDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.NotificationFilterEntry;
+import nodomain.freeyourgadget.gadgetbridge.entities.NotificationFilterEntryDao;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class NotificationFilterActivity extends AbstractGBActivity {
+
+ public static final int NOTIFICATION_FILTER_MODE_NONE = 0;
+ public static final int NOTIFICATION_FILTER_MODE_WHITELIST = 1;
+ public static final int NOTIFICATION_FILTER_MODE_BLACKLIST = 2;
+ public static final int NOTIFICATION_FILTER_SUBMODE_ANY = 0;
+ public static final int NOTIFICATION_FILTER_SUBMODE_ALL = 1;
+
+ private Button mButtonSave;
+ private Spinner mSpinnerFilterMode;
+ private Spinner mSpinnerFilterSubMode;
+ private NotificationFilter mNotificationFilter;
+ private EditText mEditTextWords;
+ private List<String> mWordsList = new ArrayList<>();
+ private List<Long> mFilterEntryIds = new ArrayList<>();
+
+ private static final Logger LOG = LoggerFactory.getLogger(NotificationFilterActivity.class);
+
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_notification_filter);
+
+ String packageName = getIntent().getStringExtra(AppBlacklistAdapter.STRING_EXTRA_PACKAGE_NAME);
+
+ if (StringUtils.isBlank(packageName)) {
+ this.finish();
+ }
+
+ packageName = packageName.toLowerCase();
+
+ try (DBHandler db = GBApplication.acquireDB()) {
+
+ NotificationFilterDao notificationFilterDao = db.getDaoSession().getNotificationFilterDao();
+ NotificationFilterEntryDao notificationFilterEntryDao = db.getDaoSession().getNotificationFilterEntryDao();
+
+ Query<NotificationFilter> query = notificationFilterDao.queryBuilder().where(NotificationFilterDao.Properties.AppIdentifier.eq(packageName)).build();
+ mNotificationFilter = query.unique();
+
+ if (mNotificationFilter == null) {
+ mNotificationFilter = new NotificationFilter();
+ mNotificationFilter.setAppIdentifier(packageName);
+ LOG.debug("New Notification Filter");
+ } else {
+ LOG.debug("Loaded existing notification filter");
+ Query<NotificationFilterEntry> queryEntries = notificationFilterEntryDao.queryBuilder().where(NotificationFilterEntryDao.Properties.NotificationFilterId.eq(mNotificationFilter.getId())).build();
+ List<NotificationFilterEntry> filterEntries = queryEntries.list();
+ for (NotificationFilterEntry temp : filterEntries) {
+ mWordsList.add(temp.getNotificationFilterContent());
+ mFilterEntryIds.add(temp.getId());
+ LOG.debug("Loaded filter word: " + temp.getNotificationFilterContent());
+ }
+ }
+
+ setupView(db);
+
+ } catch (Exception e) {
+ GB.toast(this, "Error accessing the database: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
+ this.finish();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ }
+
+ private void setupView(DBHandler db) {
+
+ mSpinnerFilterMode = findViewById(R.id.spinnerFilterMode);
+ mSpinnerFilterMode.setSelection(mNotificationFilter.getNotificationFilterMode());
+
+ mSpinnerFilterSubMode = findViewById(R.id.spinnerSubMode);
+ mSpinnerFilterMode.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView<?> adapterView, View view, int pos, long id) {
+ switch (pos) {
+ case NOTIFICATION_FILTER_MODE_NONE:
+ mEditTextWords.setEnabled(false);
+ mSpinnerFilterSubMode.setEnabled(false);
+ break;
+ case NOTIFICATION_FILTER_MODE_BLACKLIST:
+ case NOTIFICATION_FILTER_MODE_WHITELIST:
+ mEditTextWords.setEnabled(true);
+ mSpinnerFilterSubMode.setEnabled(true);
+ break;
+
+ }
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> adapterView) {
+
+ }
+ });
+
+ mSpinnerFilterSubMode.setSelection(mNotificationFilter.getNotificationFilterSubMode());
+
+ mEditTextWords = findViewById(R.id.editTextWords);
+
+ if (!mWordsList.isEmpty()) {
+ StringBuilder builder = new StringBuilder();
+ for (String temp : mWordsList) {
+ builder.append(temp);
+ builder.append("\n");
+ }
+ mEditTextWords.setText(builder.toString());
+ }
+
+ mEditTextWords.setEnabled(mSpinnerFilterMode.getSelectedItemPosition() == NOTIFICATION_FILTER_MODE_NONE);
+
+ mButtonSave = findViewById(R.id.buttonSaveFilter);
+
+ mButtonSave.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ saveFilter();
+ }
+ });
+ }
+
+ private void saveFilter() {
+ // TODO: check for modifications, only save if something changed
+ String words = mEditTextWords.getText().toString();
+
+ if (StringUtils.isBlank(words) && mSpinnerFilterMode.getSelectedItemPosition() != NOTIFICATION_FILTER_MODE_NONE) {
+ Toast.makeText(NotificationFilterActivity.this, R.string.toast_notification_filter_words_empty_hint, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ try (DBHandler db = GBApplication.acquireDB()) {
+ NotificationFilterDao notificationFilterDao = db.getDaoSession().getNotificationFilterDao();
+ NotificationFilterEntryDao notificationFilterEntryDao = db.getDaoSession().getNotificationFilterEntryDao();
+
+ debugOutput(notificationFilterDao);
+
+ mNotificationFilter.setNotificationFilterMode(mSpinnerFilterMode.getSelectedItemPosition());
+ mNotificationFilter.setNotificationFilterSubMode(mSpinnerFilterSubMode.getSelectedItemPosition());
+
+ notificationFilterEntryDao.deleteByKeyInTx(mFilterEntryIds);
+
+ long filterId = notificationFilterDao.insertOrReplace(mNotificationFilter);
+
+ // only save words if filter mode != none
+ if (mNotificationFilter.getNotificationFilterMode() != NOTIFICATION_FILTER_MODE_NONE) {
+ String[] wordsSplitted = words.split("\n");
+ for (String temp : wordsSplitted) {
+
+ if (StringUtils.isBlank(temp)) {
+ continue;
+ }
+
+ temp = temp.trim();
+ NotificationFilterEntry notificationFilterEntry = new NotificationFilterEntry();
+ notificationFilterEntry.setNotificationFilterContent(temp);
+ notificationFilterEntry.setNotificationFilterId(filterId);
+ notificationFilterEntryDao.insert(notificationFilterEntry);
+ }
+ }
+
+ Toast.makeText(NotificationFilterActivity.this, R.string.toast_notification_filter_saved_successfully, Toast.LENGTH_SHORT).show();
+ NotificationFilterActivity.this.finish();
+
+ } catch (Exception e) {
+ GB.toast(NotificationFilterActivity.this, "Error accessing the database: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
+ }
+ }
+
+ /**
+ * Only used for debugging purposes
+ *
+ * @param notificationFilterDao {@link NotificationFilterDao}
+ */
+ private void debugOutput(NotificationFilterDao notificationFilterDao) {
+ if (BuildConfig.DEBUG) {
+
+ List<NotificationFilter> filters = notificationFilterDao.loadAll();
+
+ LOG.info("Saved filters");
+
+ for (NotificationFilter temp : filters) {
+ LOG.info("Filter: " + temp.getId() + " " + temp.getAppIdentifier());
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/NotificationManagementActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/NotificationManagementActivity.java
new file mode 100644
index 0000000..fd04ebd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/NotificationManagementActivity.java
@@ -0,0 +1,149 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities;
+
+import android.content.Intent;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.Settings;
+
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceFragmentCompat;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class NotificationManagementActivity extends AbstractSettingsActivityV2 {
+ private static final int RINGTONE_REQUEST_CODE = 4712;
+ private static final String DEFAULT_RINGTONE_URI = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE).toString();
+
+ @Override
+ protected String fragmentTag() {
+ return NotificationPreferencesFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return new NotificationPreferencesFragment();
+ }
+
+ public static class NotificationPreferencesFragment extends AbstractPreferenceFragment {
+ private static final Logger LOG = LoggerFactory.getLogger(NotificationPreferencesFragment.class);
+
+ static final String FRAGMENT_TAG = "NOTIFICATION_PREFERENCES_FRAGMENT";
+
+ @Override
+ protected void onSharedPreferenceChanged(final Preference preference) {
+ if (GBPrefs.PING_TONE.equals(preference.getKey())) {
+ try {
+ final Prefs prefs = GBApplication.getPrefs();
+ // This fails on some ROMs. The actual implementation falls-back to an internal ping tone
+ preference.setSummary(RingtoneManager.getRingtone(requireContext(), Uri.parse(prefs.getString(GBPrefs.PING_TONE, DEFAULT_RINGTONE_URI))).getTitle(requireContext()));
+ } catch (final Exception e) {
+ LOG.error("Failed to find the configured ping ringtone", e);
+ preference.setSummary("-");
+ }
+ }
+ }
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ setPreferencesFromResource(R.xml.notifications_preferences, rootKey);
+
+ Preference pref = findPreference("notifications_generic");
+ pref.setOnPreferenceClickListener(preference -> {
+ final Intent enableIntent = new Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS");
+ startActivity(enableIntent);
+ return true;
+ });
+
+ pref = findPreference(GBPrefs.PING_TONE);
+ pref.setOnPreferenceClickListener(preference -> {
+ final Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER);
+ intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, "Set Ping tone");
+ intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false);
+ intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true);
+ intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_ALL);
+ startActivityForResult(intent, RINGTONE_REQUEST_CODE);
+ return true;
+ });
+
+ pref = findPreference("pref_key_blacklist");
+ pref.setOnPreferenceClickListener(preference -> {
+ final Intent enableIntent = new Intent(requireContext(), AppBlacklistActivity.class);
+ startActivity(enableIntent);
+ return true;
+ });
+
+ pref = findPreference("notifications_settings");
+ pref.setOnPreferenceClickListener(preference -> {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) {
+ LOG.warn("This preference should not be displayed in Android < O");
+ return true;
+ }
+
+ final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireActivity().getPackageName());
+ //This could open notification channel settings, if needed...:
+ //Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS);
+ //intent.putExtra(Settings.EXTRA_CHANNEL_ID, GB.NOTIFICATION_CHANNEL_ID_TRANSFER);
+ startActivity(intent);
+ return true;
+ });
+
+ final PreferenceCategory notificationsCategory = findPreference("pref_key_notifications");
+
+ if (!GBApplication.isRunningMarshmallowOrLater()) {
+ pref = findPreference("notification_filter");
+ notificationsCategory.removePreference(pref);
+ }
+
+ if (GBApplication.isRunningTenOrLater()) {
+ pref = findPreference("minimize_priority");
+ notificationsCategory.removePreference(pref);
+ }
+
+ if (!GBApplication.isRunningOreoOrLater()) {
+ pref = findPreference("notifications_settings");
+ notificationsCategory.removePreference(pref);
+ }
+ }
+
+ // TODO: Migrate this to ActivityResultContract
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, final Intent intent) {
+ if (requestCode == RINGTONE_REQUEST_CODE && intent != null) {
+ if (intent.getExtras().getParcelable(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) != null) {
+ final Uri uri = intent.getExtras().getParcelable(RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
+ GBApplication.getPrefs()
+ .getPreferences()
+ .edit()
+ .putString(GBPrefs.PING_TONE, uri.toString())
+ .apply();
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/OpenFwAppInstallerActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/OpenFwAppInstallerActivity.java
new file mode 100644
index 0000000..6dda0ca
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/OpenFwAppInstallerActivity.java
@@ -0,0 +1,93 @@
+/* Copyright (C) 2022-2024 Petr Van臎k
+
+ 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.activities;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class OpenFwAppInstallerActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(AppManagerActivity.class);
+ private int READ_REQUEST_CODE = 42;
+ private GBDevice device;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ device = extras.getParcelable(GBDevice.EXTRA_DEVICE);
+ } else {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+
+ setContentView(R.layout.activity_open_fw_app_installer);
+ Button pickFileButton = findViewById(R.id.open_fw_installer_pick_button);
+ TextView label = findViewById(R.id.open_fw_installer_no_device);
+ label.setText(String.format(getString(R.string.open_fw_installer_select_file), device.getAliasOrName()));
+
+ final List<GBDevice> devices = ((GBApplication) getApplicationContext()).getDeviceManager().getSelectedDevices();
+ switch (devices.size()) {
+ case 0:
+ label.setText(R.string.open_fw_installer_connect_minimum_one_device);
+ pickFileButton.setEnabled(false);
+ break;
+ case 1:
+ label.setText(String.format(getString(R.string.open_fw_installer_select_file), device.getAliasOrName()));
+ pickFileButton.setEnabled(true);
+ break;
+ default:
+ label.setText(R.string.open_fw_installer_connect_maximum_one_device);
+ pickFileButton.setEnabled(false);
+ }
+
+ pickFileButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("*/*");
+ startActivityForResult(intent, READ_REQUEST_CODE);
+ }
+ });
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
+ super.onActivityResult(requestCode, resultCode, resultData);
+ if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
+ Intent startIntent = new Intent(OpenFwAppInstallerActivity.this, FwAppInstallerActivity.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ startIntent.setAction(Intent.ACTION_VIEW);
+ startIntent.setDataAndType(resultData.getData(), null);
+ startActivity(startIntent);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/PermissionsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/PermissionsActivity.java
new file mode 100644
index 0000000..22713c3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/PermissionsActivity.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.os.Bundle;
+
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentTransaction;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.welcome.WelcomeFragmentPermissions;
+
+public class PermissionsActivity extends AbstractGBActivity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_permissions);
+
+ WelcomeFragmentPermissions permissionsFragment = new WelcomeFragmentPermissions();
+ FragmentManager fragmentManager = getSupportFragmentManager();
+ FragmentTransaction transaction = fragmentManager.beginTransaction();
+ transaction.replace(R.id.fragment_container, permissionsFragment).commit();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ReminderDetails.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ReminderDetails.java
new file mode 100644
index 0000000..aec7cea
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ReminderDetails.java
@@ -0,0 +1,232 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver, Daniel Dakhno, Jos茅 Rebelo,
+ Johannes Krude
+
+ 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.activities;
+
+import android.app.DatePickerDialog;
+import android.app.TimePickerDialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.TextWatcher;
+import android.text.format.DateFormat;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.DatePicker;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.TimePicker;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.TimeZone;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.Reminder;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class ReminderDetails extends AbstractGBActivity implements TimePickerDialog.OnTimeSetListener, DatePickerDialog.OnDateSetListener {
+ private Reminder reminder;
+ private GBDevice device;
+
+ ArrayAdapter<String> repeatAdapter;
+
+ SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
+ SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm", Locale.getDefault());
+
+ TextView reminderRepeat;
+ TextView reminderDate;
+ TextView reminderTime;
+ EditText reminderText;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_reminder_details);
+
+ reminder = (Reminder) getIntent().getSerializableExtra(Reminder.EXTRA_REMINDER);
+
+ if (reminder == null) {
+ GB.toast("No reminder provided to ReminderDetails Activity", Toast.LENGTH_LONG, GB.ERROR);
+ finish();
+ return;
+ }
+
+ reminderRepeat = findViewById(R.id.reminder_repeat);
+ reminderDate = findViewById(R.id.reminder_date);
+ reminderTime = findViewById(R.id.reminder_time);
+ reminderText = findViewById(R.id.reminder_message);
+
+ device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+
+ final String[] repeatStrings = getResources().getStringArray(R.array.reminder_repeat);
+ repeatAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, repeatStrings);
+
+ final View cardRepeat = findViewById(R.id.card_repeat);
+ cardRepeat.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ new MaterialAlertDialogBuilder(ReminderDetails.this).setAdapter(repeatAdapter, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ reminder.setRepetition(i);
+ updateUiFromReminder();
+ }
+ }).create().show();
+ }
+ });
+
+ final View cardDate = findViewById(R.id.card_date);
+ cardDate.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ final Calendar date = new GregorianCalendar();
+ date.setTime(reminder.getDate());
+ new DatePickerDialog(
+ ReminderDetails.this,
+ ReminderDetails.this,
+ date.get(Calendar.YEAR),
+ date.get(Calendar.MONTH),
+ date.get(Calendar.DAY_OF_MONTH)
+ ).show();
+ }
+ });
+
+ final View cardTime = findViewById(R.id.card_time);
+ if (coordinator.getRemindersHaveTime()) {
+ cardTime.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ new TimePickerDialog(
+ ReminderDetails.this,
+ ReminderDetails.this,
+ reminder.getDate().getHours(),
+ reminder.getDate().getMinutes(),
+ DateFormat.is24HourFormat(GBApplication.getContext())
+ ).show();
+ }
+ });
+ } else {
+ cardTime.setVisibility(View.GONE);
+ }
+
+ reminderText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(coordinator.getMaximumReminderMessageLength())});
+ reminderText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(final CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(final CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(final Editable s) {
+ reminder.setMessage(s.toString());
+ }
+ });
+
+ final FloatingActionButton fab = findViewById(R.id.fab_save);
+ fab.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ updateReminder();
+ ReminderDetails.this.setResult(1);
+ finish();
+ }
+ });
+
+ updateUiFromReminder();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ // TODO confirm when exiting without saving
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void updateReminder() {
+ DBHelper.store(reminder);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull final Bundle state) {
+ super.onSaveInstanceState(state);
+ state.putSerializable("reminder", reminder);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ reminder = (Reminder) savedInstanceState.getSerializable("reminder");
+ updateUiFromReminder();
+ }
+
+ @Override
+ public void onTimeSet(TimePicker timePicker, int hour, int minute) {
+ reminder.getDate().setHours(hour);
+ reminder.getDate().setMinutes(minute);
+ updateUiFromReminder();
+ }
+
+ @Override
+ public void onDateSet(DatePicker datePicker, int year, int month, int dayOfMonth) {
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+
+ if (coordinator.getRemindersHaveTime()) {
+ final Calendar date = new GregorianCalendar(year, month, dayOfMonth);
+ reminder.setDate(new Date(date.getTimeInMillis()));
+ } else {
+ Calendar noonUTC = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+ noonUTC.set(year, month, dayOfMonth, 12, 0);
+ reminder.setDate(noonUTC.getTime());
+ }
+ updateUiFromReminder();
+ }
+
+ public void updateUiFromReminder() {
+ reminderRepeat.setText(repeatAdapter.getItem(reminder.getRepetition()));
+ reminderText.setText(reminder.getMessage());
+
+ if (reminder.getDate() != null) {
+ reminderDate.setText(dateFormat.format(reminder.getDate()));
+ reminderTime.setText(timeFormat.format(reminder.getDate()));
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java
new file mode 100644
index 0000000..4483dc1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java
@@ -0,0 +1,588 @@
+/* Copyright (C) 2015-2024 0nse, Andreas Shimokawa, Anemograph, Arjan
+ Schrijver, Carsten Pfeiffer, Daniel Dakhno, Daniele Gobbetti, Felix Konstantin
+ Maurer, Jos茅 Rebelo, Martin, Normano64, Pavel Elagin, Petr Van臎k, Sebastian
+ Kranz, Taavi Eom盲e
+
+ 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.activities;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.database.Cursor;
+import android.location.Criteria;
+import android.location.Location;
+import android.location.LocationListener;
+import android.location.LocationManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.DocumentsContract;
+import android.text.InputType;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.Spinner;
+import android.widget.Toast;
+
+import androidx.core.app.ActivityCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+
+import com.google.android.material.color.DynamicColors;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsPreferencesActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryPairingPreferenceActivity;
+import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver;
+import nodomain.freeyourgadget.gadgetbridge.model.Weather;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class SettingsActivity extends AbstractSettingsActivityV2 {
+ public static final String PREF_MEASUREMENT_SYSTEM = "measurement_system";
+
+ @Override
+ protected String fragmentTag() {
+ return SettingsFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return new SettingsFragment();
+ }
+
+ public static class SettingsFragment extends AbstractPreferenceFragment {
+ private static final Logger LOG = LoggerFactory.getLogger(SettingsActivity.class);
+
+ static final String FRAGMENT_TAG = "SETTINGS_FRAGMENT";
+
+ private static final int EXPORT_LOCATION_FILE_REQUEST_CODE = 4711;
+ private EditText fitnessAppEditText = null;
+ private int fitnessAppSelectionListSpinnerFirstRun = 0;
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ setPreferencesFromResource(R.xml.preferences, rootKey);
+
+ setInputTypeFor("rtl_max_line_length", InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor("location_latitude", InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
+ setInputTypeFor("location_longitude", InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
+ setInputTypeFor("auto_export_interval", InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor("auto_fetch_interval_limit", InputType.TYPE_CLASS_NUMBER);
+
+ Prefs prefs = GBApplication.getPrefs();
+ Preference pref = findPreference("pref_category_activity_personal");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ Intent enableIntent = new Intent(requireContext(), AboutUserPreferencesActivity.class);
+ startActivity(enableIntent);
+ return true;
+ });
+ }
+
+ pref = findPreference("pref_charts");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ Intent enableIntent = new Intent(requireContext(), ChartsPreferencesActivity.class);
+ startActivity(enableIntent);
+ return true;
+ });
+ }
+
+ pref = findPreference("datetime_synconconnect");
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener((preference, newVal) -> {
+ if (Boolean.TRUE.equals(newVal)) {
+ TimeChangeReceiver.scheduleNextDstChangeOrPeriodicSync(requireContext());
+ GBApplication.deviceService().onSetTime();
+ }
+ return true;
+ });
+ }
+
+ pref = findPreference("log_to_file");
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener((preference, newVal) -> {
+ boolean doEnable = Boolean.TRUE.equals(newVal);
+ try {
+ if (doEnable) {
+ FileUtils.getExternalFilesDir(); // ensures that it is created
+ }
+ GBApplication.setupLogging(doEnable);
+ } catch (IOException ex) {
+ GB.toast(requireContext().getApplicationContext(),
+ getString(R.string.error_creating_directory_for_logfiles, ex.getLocalizedMessage()),
+ Toast.LENGTH_LONG,
+ GB.ERROR,
+ ex);
+ }
+ return true;
+ });
+
+ // If we didn't manage to initialize file logging, disable the preference
+ if (!GBApplication.getLogging().isFileLoggerInitialized()) {
+ pref.setEnabled(false);
+ pref.setSummary(R.string.pref_write_logfiles_not_available);
+ }
+ }
+
+ pref = findPreference("cache_weather");
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener((preference, newVal) -> {
+ boolean doEnable = Boolean.TRUE.equals(newVal);
+
+ Weather.getInstance().setCacheFile(requireContext().getCacheDir(), doEnable);
+
+ return true;
+ });
+ }
+
+ pref = findPreference("language");
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener((preference, newVal) -> {
+ String newLang = newVal.toString();
+ try {
+ GBApplication.setLanguage(newLang);
+ requireActivity().recreate();
+ } catch (Exception ex) {
+ GB.toast(requireContext().getApplicationContext(),
+ "Error setting language: " + ex.getLocalizedMessage(),
+ Toast.LENGTH_LONG,
+ GB.ERROR,
+ ex);
+ }
+ return true;
+ });
+ }
+
+ pref = findPreference("display_add_device_fab");
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener((preference, newValue) -> {
+ sendThemeChangeIntent();
+ return true;
+ });
+ }
+ pref = findPreference("display_bottom_navigation_bar");
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener((preference, newVal) -> {
+ sendThemeChangeIntent();
+ return true;
+ });
+ }
+
+ final Preference unit = findPreference(PREF_MEASUREMENT_SYSTEM);
+ if (unit != null) {
+ unit.setOnPreferenceChangeListener((preference, newVal) -> {
+ invokeLater(() -> GBApplication.deviceService().onSendConfiguration(PREF_MEASUREMENT_SYSTEM));
+ return true;
+ });
+ }
+
+ pref = findPreference("location_aquire");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ if (ActivityCompat.checkSelfPermission(requireContext().getApplicationContext(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(requireActivity(), new String[]{Manifest.permission.ACCESS_COARSE_LOCATION}, 0);
+ }
+
+ LocationManager locationManager = (LocationManager) requireContext().getSystemService(Context.LOCATION_SERVICE);
+ Criteria criteria = new Criteria();
+ String provider = locationManager.getBestProvider(criteria, false);
+ if (provider != null) {
+ Location location = locationManager.getLastKnownLocation(provider);
+ if (location != null) {
+ setLocationPreferences(location);
+ } else {
+ locationManager.requestSingleUpdate(provider, new LocationListener() {
+ @Override
+ public void onLocationChanged(Location location) {
+ setLocationPreferences(location);
+ }
+
+ @Override
+ public void onStatusChanged(String provider, int status, Bundle extras) {
+ LOG.info("provider status changed to " + status + " (" + provider + ")");
+ }
+
+ @Override
+ public void onProviderEnabled(String provider) {
+ LOG.info("provider enabled (" + provider + ")");
+ }
+
+ @Override
+ public void onProviderDisabled(String provider) {
+ LOG.info("provider disabled (" + provider + ")");
+ GB.toast(requireContext(), getString(R.string.toast_enable_networklocationprovider), 3000, 0);
+ }
+ }, null);
+ }
+ } else {
+ LOG.warn("No location provider found, did you deny location permission?");
+ }
+ return true;
+ });
+ }
+
+ pref = findPreference("weather_city");
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener((preference, newVal) -> {
+ // reset city id and force a new lookup
+ GBApplication.getPrefs().getPreferences().edit().putString("weather_cityid", null).apply();
+ Intent intent = new Intent("GB_UPDATE_WEATHER");
+ intent.setPackage(BuildConfig.APPLICATION_ID);
+ requireContext().sendBroadcast(intent);
+ return true;
+ });
+ }
+
+
+ pref = findPreference(GBPrefs.AUTO_EXPORT_LOCATION);
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ Intent i = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+ i.setType("application/x-sqlite3");
+ i.addCategory(Intent.CATEGORY_OPENABLE);
+ i.putExtra(Intent.EXTRA_TITLE, "Gadgetbridge.db");
+ i.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ String title = requireContext().getApplicationContext().getString(R.string.choose_auto_export_location);
+ startActivityForResult(Intent.createChooser(i, title), EXPORT_LOCATION_FILE_REQUEST_CODE);
+ return true;
+ });
+ pref.setSummary(getAutoExportLocationSummary());
+ }
+
+ pref = findPreference(GBPrefs.AUTO_EXPORT_INTERVAL);
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener((preference, autoExportInterval) -> {
+ String summary = String.format(
+ requireContext().getApplicationContext().getString(R.string.pref_summary_auto_export_interval),
+ Integer.valueOf((String) autoExportInterval));
+ preference.setSummary(summary);
+ boolean auto_export_enabled = GBApplication.getPrefs().getBoolean(GBPrefs.AUTO_EXPORT_ENABLED, false);
+ PeriodicExporter.scheduleAlarm(requireContext().getApplicationContext(), Integer.valueOf((String) autoExportInterval), auto_export_enabled);
+ return true;
+ });
+ int autoExportInterval = GBApplication.getPrefs().getInt(GBPrefs.AUTO_EXPORT_INTERVAL, 0);
+ String summary = String.format(
+ requireContext().getApplicationContext().getString(R.string.pref_summary_auto_export_interval),
+ autoExportInterval);
+ pref.setSummary(summary);
+ }
+
+ pref = findPreference(GBPrefs.AUTO_EXPORT_ENABLED);
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener((preference, autoExportEnabled) -> {
+ int autoExportInterval = GBApplication.getPrefs().getInt(GBPrefs.AUTO_EXPORT_INTERVAL, 0);
+ PeriodicExporter.scheduleAlarm(requireContext().getApplicationContext(), autoExportInterval, (boolean) autoExportEnabled);
+ return true;
+ });
+ }
+
+ pref = findPreference("auto_fetch_interval_limit");
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener((preference, autoFetchInterval) -> {
+ String summary = String.format(
+ requireContext().getApplicationContext().getString(R.string.pref_auto_fetch_limit_fetches_summary),
+ Integer.valueOf((String) autoFetchInterval));
+ preference.setSummary(summary);
+ return true;
+ });
+
+ int autoFetchInterval = GBApplication.getPrefs().getInt("auto_fetch_interval_limit", 0);
+ String summary = String.format(
+ requireContext().getApplicationContext().getString(R.string.pref_auto_fetch_limit_fetches_summary),
+ autoFetchInterval);
+ pref.setSummary(summary);
+ }
+
+ final ListPreference audioPlayer = findPreference("audio_player");
+ if (audioPlayer != null) {
+ // Get all receivers of Media Buttons
+ Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
+
+ PackageManager pm = requireContext().getPackageManager();
+ List<ResolveInfo> mediaReceivers = pm.queryBroadcastReceivers(mediaButtonIntent,
+ PackageManager.GET_INTENT_FILTERS | PackageManager.GET_RESOLVED_FILTER);
+
+ CharSequence[] newEntries = new CharSequence[mediaReceivers.size() + 1];
+ CharSequence[] newValues = new CharSequence[mediaReceivers.size() + 1];
+ newEntries[0] = getString(R.string.pref_default);
+ newValues[0] = "default";
+
+ int i = 1;
+ Set<String> existingNames = new HashSet<>();
+ for (ResolveInfo resolveInfo : mediaReceivers) {
+ newEntries[i] = resolveInfo.activityInfo.loadLabel(pm) + " (" + resolveInfo.activityInfo.packageName + ")";
+ if (existingNames.contains(newEntries[i].toString().trim())) {
+ newEntries[i] = resolveInfo.activityInfo.loadLabel(pm) + " (" + resolveInfo.activityInfo.name + ")";
+ } else {
+ existingNames.add(newEntries[i].toString().trim());
+ }
+ newValues[i] = resolveInfo.activityInfo.packageName;
+ i++;
+ }
+
+ audioPlayer.setEntries(newEntries);
+ audioPlayer.setEntryValues(newValues);
+ audioPlayer.setDefaultValue(newValues[0]);
+ }
+
+ pref = findPreference("pref_category_dashboard");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ Intent enableIntent = new Intent(requireContext(), DashboardPreferencesActivity.class);
+ startActivity(enableIntent);
+ return true;
+ });
+ }
+
+ pref = findPreference("pref_category_sleepasandroid");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ Intent enableIntent = new Intent(requireContext(), SleepAsAndroidPreferencesActivity.class);
+ startActivity(enableIntent);
+ return true;
+ });
+ }
+
+ pref = findPreference("pref_category_notifications");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ Intent enableIntent = new Intent(requireContext(), NotificationManagementActivity.class);
+ startActivity(enableIntent);
+ return true;
+ });
+ }
+
+ final Preference theme = findPreference("pref_key_theme");
+ final Preference amoled_black = findPreference("pref_key_theme_amoled_black");
+
+ if (amoled_black != null) {
+ String selectedTheme = prefs.getString("pref_key_theme", requireContext().getString(R.string.pref_theme_value_system));
+ if (selectedTheme.equals("light"))
+ amoled_black.setEnabled(false);
+ else
+ amoled_black.setEnabled(true);
+ amoled_black.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ sendThemeChangeIntent();
+ return true;
+ }
+ });
+ }
+
+ if (theme != null) {
+ theme.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ final String val = newVal.toString();
+ if (amoled_black != null) {
+ if (val.equals("light"))
+ amoled_black.setEnabled(false);
+ else
+ amoled_black.setEnabled(true);
+ }
+ // Warn user if dynamic colors are not available
+ if (val.equals(requireContext().getString(R.string.pref_theme_value_dynamic)) && !DynamicColors.isDynamicColorAvailable()) {
+ new MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.warning)
+ .setMessage(R.string.pref_theme_dynamic_colors_not_available_warning)
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(R.string.ok, (dialog, whichButton) -> {
+ sendThemeChangeIntent();
+ })
+ .show();
+ } else {
+ sendThemeChangeIntent();
+ }
+ return true;
+ }
+ });
+ }
+
+ pref = findPreference("pref_discovery_pairing");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ Intent enableIntent = new Intent(requireContext(), DiscoveryPairingPreferenceActivity.class);
+ startActivity(enableIntent);
+ return true;
+ });
+ }
+
+ //fitness app (OpenTracks) package name selection for OpenTracks observer
+ pref = findPreference("pref_key_opentracks_packagename");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ final LinearLayout outerLayout = new LinearLayout(requireContext());
+ outerLayout.setOrientation(LinearLayout.VERTICAL);
+ final LinearLayout innerLayout = new LinearLayout(requireContext());
+ innerLayout.setOrientation(LinearLayout.HORIZONTAL);
+ innerLayout.setPadding(20, 0, 20, 0);
+ final Spinner selectionListSpinner = new Spinner(requireContext());
+ String[] appListArray = getResources().getStringArray(R.array.fitness_tracking_apps_package_names);
+ ArrayAdapter<String> spinnerArrayAdapter = new ArrayAdapter<String>(requireContext(),
+ android.R.layout.simple_spinner_dropdown_item, appListArray);
+ selectionListSpinner.setAdapter(spinnerArrayAdapter);
+ fitnessAppSelectionListSpinnerFirstRun = 0;
+ addListenerOnSpinnerDeviceSelection(selectionListSpinner);
+ Prefs prefs1 = GBApplication.getPrefs();
+ String packageName = prefs1.getString("opentracks_packagename", "de.dennisguse.opentracks");
+ // Set the spinner to the selected package name by default
+ for (int i = 0; i < appListArray.length; i++) {
+ if (appListArray[i].equals(packageName)) {
+ selectionListSpinner.setSelection(i);
+ break;
+ }
+ }
+ fitnessAppEditText = new EditText(requireContext());
+ fitnessAppEditText.setText(packageName);
+ innerLayout.addView(fitnessAppEditText);
+ outerLayout.addView(selectionListSpinner);
+ outerLayout.addView(innerLayout);
+
+ new MaterialAlertDialogBuilder(requireContext())
+ .setCancelable(true)
+ .setTitle(R.string.pref_title_opentracks_packagename)
+ .setView(outerLayout)
+ .setPositiveButton(R.string.ok, (dialog, which) -> {
+ SharedPreferences.Editor editor = GBApplication.getPrefs().getPreferences().edit();
+ editor.putString("opentracks_packagename", fitnessAppEditText.getText().toString());
+ editor.apply();
+ })
+ .setNegativeButton(R.string.Cancel, (dialog, which) -> {})
+ .show();
+ return false;
+ });
+ }
+ }
+
+ private void addListenerOnSpinnerDeviceSelection(Spinner spinner) {
+ spinner.setOnItemSelectedListener(new CustomOnDeviceSelectedListener());
+ }
+
+ public class CustomOnDeviceSelectedListener implements AdapterView.OnItemSelectedListener {
+ public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
+ if (++fitnessAppSelectionListSpinnerFirstRun > 1) { //this prevents the setText to be set when spinner just is being initialized
+ fitnessAppEditText.setText(parent.getItemAtPosition(pos).toString());
+ }
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> arg0) {
+ // TODO Auto-generated method stub
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent intent) {
+ if (requestCode == EXPORT_LOCATION_FILE_REQUEST_CODE && intent != null) {
+ Uri uri = intent.getData();
+ requireContext().getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ GBApplication.getPrefs().getPreferences()
+ .edit()
+ .putString(GBPrefs.AUTO_EXPORT_LOCATION, uri.toString())
+ .apply();
+ String summary = getAutoExportLocationSummary();
+ findPreference(GBPrefs.AUTO_EXPORT_LOCATION).setSummary(summary);
+ boolean autoExportEnabled = GBApplication
+ .getPrefs().getBoolean(GBPrefs.AUTO_EXPORT_ENABLED, false);
+ int autoExportPeriod = GBApplication
+ .getPrefs().getInt(GBPrefs.AUTO_EXPORT_INTERVAL, 0);
+ PeriodicExporter.scheduleAlarm(requireContext().getApplicationContext(), autoExportPeriod, autoExportEnabled);
+ }
+ }
+
+ /*
+ Either returns the file path of the selected document, or the display name, or an empty string
+ */
+ public String getAutoExportLocationSummary() {
+ String autoExportLocation = GBApplication.getPrefs().getString(GBPrefs.AUTO_EXPORT_LOCATION, null);
+ if (autoExportLocation == null) {
+ return "";
+ }
+ Uri uri = Uri.parse(autoExportLocation);
+ try {
+ return AndroidUtils.getFilePath(requireContext().getApplicationContext(), uri);
+ } catch (IllegalArgumentException e) {
+ try {
+ Cursor cursor = requireContext().getContentResolver().query(
+ uri,
+ new String[]{DocumentsContract.Document.COLUMN_DISPLAY_NAME},
+ null, null, null, null
+ );
+ if (cursor != null && cursor.moveToFirst()) {
+ return cursor.getString(cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));
+ }
+ } catch (Exception fdfsdfds) {
+ LOG.warn("fuck");
+ }
+ }
+ return "";
+ }
+
+ /*
+ * delayed execution so that the preferences are applied first
+ */
+ private void invokeLater(Runnable runnable) {
+ getListView().post(runnable);
+ }
+
+ private void setLocationPreferences(Location location) {
+ String latitude = String.format(Locale.US, "%.6g", location.getLatitude());
+ String longitude = String.format(Locale.US, "%.6g", location.getLongitude());
+ LOG.info("got location. Lat: {} Lng: {}", latitude, longitude);
+ GB.toast(requireContext(), getString(R.string.toast_aqurired_networklocation), 2000, 0);
+ GBApplication.getPrefs().getPreferences()
+ .edit()
+ .putString("location_latitude", latitude)
+ .putString("location_longitude", longitude)
+ .apply();
+ }
+
+ /**
+ * Signal running activities that the theme has changed
+ */
+ private void sendThemeChangeIntent() {
+ Intent intent = new Intent();
+ intent.setAction(GBApplication.ACTION_THEME_CHANGE);
+ LocalBroadcastManager.getInstance(requireContext()).sendBroadcast(intent);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SleepAlarmWidgetConfigurationActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SleepAlarmWidgetConfigurationActivity.java
new file mode 100644
index 0000000..e7cc18e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SleepAlarmWidgetConfigurationActivity.java
@@ -0,0 +1,154 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver, Damien Gaignon, Daniel Dakhno,
+ Petr Van臎k
+
+ 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.activities;
+
+import android.app.Activity;
+import android.appwidget.AppWidgetManager;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Pair;
+import android.widget.ListView;
+
+import androidx.appcompat.app.AlertDialog;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.WidgetPreferenceStorage;
+
+public class SleepAlarmWidgetConfigurationActivity extends Activity implements GBActivity {
+
+ // modified copy of WidgetConfigurationActivity
+ // if we knew which widget is calling this config activity, we could only use a single configuration
+ // activity and customize the filter in getAllDevices based on the caller.
+
+ private static final Logger LOG = LoggerFactory.getLogger(SleepAlarmWidgetConfigurationActivity.class);
+ int mAppWidgetId;
+
+ LinkedHashMap<String, Pair<String, Integer>> allDevices;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ AbstractGBActivity.init(this, AbstractGBActivity.NO_ACTIONBAR);
+
+ super.onCreate(savedInstanceState);
+
+ setResult(RESULT_CANCELED);
+
+ Intent intent = getIntent();
+ Bundle extras = intent.getExtras();
+
+ if (extras != null) {
+ mAppWidgetId = extras.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID);
+ }
+ // make the result intent and set the result to canceled
+ Intent resultValue;
+ resultValue = new Intent();
+ resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
+ setResult(RESULT_CANCELED, resultValue);
+
+ if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ finish();
+ }
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(SleepAlarmWidgetConfigurationActivity.this);
+ builder.setTitle(R.string.widget_settings_select_device_title);
+
+ allDevices = getAllDevices(getApplicationContext());
+
+ List<String> list = new ArrayList<>();
+ for (Map.Entry<String, Pair<String, Integer>> item : allDevices.entrySet()) {
+ list.add(item.getKey());
+ }
+ String[] allDevicesString = list.toArray(new String[0]);
+
+ builder.setSingleChoiceItems(allDevicesString, 0, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ ListView lw = ((AlertDialog) dialog).getListView();
+ int selectedItemPosition = lw.getCheckedItemPosition();
+
+ if (selectedItemPosition > -1) {
+ Map.Entry<String, Pair<String, Integer>> selectedItem =
+ (Map.Entry<String, Pair<String, Integer>>) allDevices.entrySet().toArray()[selectedItemPosition];
+ WidgetPreferenceStorage widgetPreferenceStorage = new WidgetPreferenceStorage();
+ widgetPreferenceStorage.saveWidgetPrefs(getApplicationContext(), String.valueOf(mAppWidgetId), selectedItem.getValue().first);
+ }
+ Intent resultValue = new Intent();
+ resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
+ setResult(RESULT_OK, resultValue);
+ finish();
+ }
+ });
+ builder.setCancelable(false);
+
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ }
+
+ public LinkedHashMap getAllDevices(Context appContext) {
+ DaoSession daoSession;
+ GBApplication gbApp = (GBApplication) appContext;
+ LinkedHashMap<String, Pair<String, Integer>> newMap = new LinkedHashMap<>(1);
+ List<? extends GBDevice> devices = gbApp.getDeviceManager().getDevices();
+
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ daoSession = handler.getDaoSession();
+ for (GBDevice device : devices) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ Device dbDevice = DBHelper.findDevice(device, daoSession);
+ int icon = device.getEnabledDisabledIconResource();
+ if (dbDevice != null && coordinator != null
+ && (coordinator.getAlarmSlotCount(device) > 0)
+ && !newMap.containsKey(device.getAliasOrName())) {
+ newMap.put(device.getAliasOrName(), new Pair(device.getAddress(), icon));
+ }
+ }
+ } catch (Exception e) {
+ LOG.error("Error getting list of all devices: " + e);
+ }
+ return newMap;
+ }
+
+ @Override
+ public void setLanguage(Locale language, boolean invalidateLanguage) {
+ AndroidUtils.setLanguage(this, language);
+ }
+}
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 0000000..438f918
--- /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<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]));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/TextReceiverActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/TextReceiverActivity.java
new file mode 100644
index 0000000..5d38694
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/TextReceiverActivity.java
@@ -0,0 +1,80 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.content.Intent;
+import android.os.Bundle;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
+
+/**
+ * Receive any shared plaintext and forward it directly to the devices as a notification.
+ */
+public class TextReceiverActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(TextReceiverActivity.class);
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Intent intent = getIntent();
+
+ final String action = intent.getAction();
+ if (!Intent.ACTION_SEND.equals(action)) {
+ LOG.warn("Unknown action '{}'", action);
+ finish();
+ return;
+ }
+
+ final String type = intent.getType();
+ if (!"text/plain".equals(type)) {
+ LOG.warn("Unknown type '{}'", type);
+ finish();
+ return;
+ }
+
+ final String text = intent.getStringExtra(Intent.EXTRA_TEXT);
+ if (StringUtils.isBlank(text)) {
+ LOG.warn("Text is null or empty");
+ finish();
+ return;
+ }
+
+ LOG.info("Sending '{}' to all devices", text);
+
+ final NotificationSpec notificationSpec = new NotificationSpec();
+ final String appName = getApplicationContext().getApplicationInfo()
+ .loadLabel(getApplicationContext().getPackageManager())
+ .toString();
+ notificationSpec.title = appName;
+ notificationSpec.body = text;
+ notificationSpec.sourceAppId = BuildConfig.APPLICATION_ID;
+ notificationSpec.sourceName = appName;
+ notificationSpec.type = NotificationType.UNKNOWN;
+ notificationSpec.pebbleColor = notificationSpec.type.color;
+
+ GBApplication.deviceService().onNotification(notificationSpec);
+ finish();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/VibrationActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/VibrationActivity.java
new file mode 100644
index 0000000..5805cdc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/VibrationActivity.java
@@ -0,0 +1,59 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.activities;
+
+import android.os.Bundle;
+import android.widget.SeekBar;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+
+public class VibrationActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(VibrationActivity.class);
+ private SeekBar seekBar;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_vibration);
+
+ seekBar = (SeekBar) findViewById(R.id.vibration_seekbar);
+ seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ if (progress > 0) { // 1-16
+ progress = progress * 16 - 1; // max 255
+ }
+ GBApplication.deviceService().onSetConstantVibration(progress);
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WakeActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WakeActivity.java
new file mode 100644
index 0000000..fbf9693
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WakeActivity.java
@@ -0,0 +1,66 @@
+/* Copyright (C) 2022-2024 Ganblejs
+
+ 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.activities;
+
+import android.app.Activity;
+import android.app.KeyguardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.WindowManager;
+
+import androidx.annotation.Nullable;
+
+public class WakeActivity extends Activity {
+ /*
+ By starting this activity via an intent sent e.g. by a Bangle.js the keyguard can be
+ dismissed automatically if the android device is in a trusted state (e.g. using a GB Device (Bangle.js) as a
+ trusted device via smart lock settings)
+
+ First try to start the activity you want to start with an intent and then start this activity with a second intent, both initiated on the Bangle.js, or other device.
+ */
+
+ public static void start(final Context context) {
+ Intent intent = new Intent(context, WakeActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ context.startActivity(intent);
+ }
+
+ private void dismissKeyguard() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ setTurnScreenOn(true);
+ KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
+ keyguardManager.requestDismissKeyguard(this, null);
+ } else {
+ this.getWindow().addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON|WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
+ }
+ }
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(null);
+ // Unlock the android device if it's in a trusted state, otherwise request user action to unlock
+ dismissKeyguard();
+ // Go back to last activity, which can have been waiting to start under
+ // the lock screen, e.g. it was previously initiated via intent message from Bangle.js
+ this.onBackPressed();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetAlarmsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetAlarmsActivity.java
new file mode 100644
index 0000000..8d56e9e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetAlarmsActivity.java
@@ -0,0 +1,144 @@
+/* Copyright (C) 2019-2024 Dmitry Markin, Jos茅 Rebelo, Petr Van臎k,
+ Taavi Eom盲e
+
+ 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.activities;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class WidgetAlarmsActivity extends Activity implements View.OnClickListener {
+
+ TextView textView;
+ GBDevice deviceForWidget;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ Context appContext = this.getApplicationContext();
+
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ deviceForWidget = extras.getParcelable(GBDevice.EXTRA_DEVICE);
+ } else {
+ GB.toast(this,
+ "Error no device",
+ Toast.LENGTH_LONG, GB.ERROR);
+ return;
+ }
+
+ if (appContext instanceof GBApplication) {
+
+ if (deviceForWidget == null || !deviceForWidget.isInitialized()) {
+ GB.toast(this,
+ this.getString(R.string.not_connected),
+ Toast.LENGTH_LONG, GB.INFO);
+
+ } else {
+ setContentView(R.layout.widget_alarms_activity_list);
+ int userSleepDuration = new ActivityUser().getSleepDurationGoal();
+ textView = findViewById(R.id.alarm5);
+ if (userSleepDuration > 0) {
+ Resources res = getResources();
+ textView.setText(res.getQuantityString(R.plurals.widget_alarm_target_hours, userSleepDuration, userSleepDuration));
+ } else {
+ textView.setVisibility(View.GONE);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onClick(final View v) {
+ final int viewId = v.getId();
+ if (viewId == R.id.alarm1) {
+ setAlarm(5);
+ } else if (viewId == R.id.alarm2) {
+ setAlarm(10);
+ } else if (viewId == R.id.alarm3) {
+ setAlarm(20);
+ } else if (viewId == R.id.alarm4) {
+ setAlarm(60);
+ } else if (viewId == R.id.alarm5) {
+ setAlarm(0);
+ }
+
+ //this is to prevent screen flashing during closing
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ finish();
+ }
+ }, 150);
+ }
+
+ public void setAlarm(int duration) {
+ // current timestamp
+ GregorianCalendar calendar = new GregorianCalendar();
+ if (duration > 0) {
+ calendar.add(Calendar.MINUTE, duration);
+ } else {
+ int userSleepDuration = new ActivityUser().getSleepDurationGoal();
+ // add preferred sleep duration
+ if (userSleepDuration > 0) {
+ calendar.add(Calendar.HOUR_OF_DAY, userSleepDuration);
+ } else { // probably testing
+ calendar.add(Calendar.MINUTE, 1);
+ }
+ }
+
+ // overwrite the first alarm and activate it, without
+
+ if (deviceForWidget == null || !deviceForWidget.isInitialized()) {
+ GB.toast(this,
+ this.getString(R.string.appwidget_not_connected),
+ Toast.LENGTH_LONG, GB.WARN);
+ return;
+ }
+
+ int hours = calendar.get(Calendar.HOUR_OF_DAY);
+ int minutes = calendar.get(Calendar.MINUTE);
+
+ GB.toast(this,
+ this.getString(R.string.appwidget_setting_alarm, hours, minutes),
+ Toast.LENGTH_SHORT, GB.INFO);
+
+ Alarm alarm = AlarmUtils.createSingleShot(0, true, true, calendar);
+ ArrayList<Alarm> alarms = new ArrayList<>(1);
+ alarms.add(alarm);
+ GBApplication.deviceService(deviceForWidget).onSetAlarms(alarms);
+
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetConfigurationActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetConfigurationActivity.java
new file mode 100644
index 0000000..056411c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WidgetConfigurationActivity.java
@@ -0,0 +1,147 @@
+/* Copyright (C) 2020-2024 Arjan Schrijver, Daniel Dakhno, Petr Van臎k
+
+ 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.activities;
+
+import android.app.Activity;
+import android.appwidget.AppWidgetManager;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Pair;
+import android.widget.ListView;
+
+import androidx.appcompat.app.AlertDialog;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.WidgetPreferenceStorage;
+
+public class WidgetConfigurationActivity extends Activity implements GBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(WidgetConfigurationActivity.class);
+ int mAppWidgetId;
+
+ LinkedHashMap<String, Pair<String, Integer>> allDevices;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ AbstractGBActivity.init(this, AbstractGBActivity.NO_ACTIONBAR);
+
+ super.onCreate(savedInstanceState);
+
+ setResult(RESULT_CANCELED);
+
+ Intent intent = getIntent();
+ Bundle extras = intent.getExtras();
+
+ if (extras != null) {
+ mAppWidgetId = extras.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID);
+ }
+ // make the result intent and set the result to canceled
+ Intent resultValue; resultValue = new Intent();
+ resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
+ setResult(RESULT_CANCELED, resultValue);
+
+ if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ finish();
+ }
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(WidgetConfigurationActivity.this);
+ builder.setTitle(R.string.widget_settings_select_device_title);
+
+ allDevices = getAllDevices(getApplicationContext());
+
+ List<String> list = new ArrayList<>();
+ for (Map.Entry<String, Pair<String, Integer>> item : allDevices.entrySet()) {
+ list.add(item.getKey());
+ }
+ String[] allDevicesString = list.toArray(new String[0]);
+
+ builder.setSingleChoiceItems(allDevicesString, 0, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ ListView lw = ((AlertDialog) dialog).getListView();
+ int selectedItemPosition = lw.getCheckedItemPosition();
+
+ if (selectedItemPosition > -1) {
+ Map.Entry<String, Pair<String, Integer>> selectedItem =
+ (Map.Entry<String, Pair<String, Integer>>) allDevices.entrySet().toArray()[selectedItemPosition];
+ WidgetPreferenceStorage widgetPreferenceStorage = new WidgetPreferenceStorage();
+ widgetPreferenceStorage.saveWidgetPrefs(getApplicationContext(), String.valueOf(mAppWidgetId), selectedItem.getValue().first);
+ }
+ Intent resultValue = new Intent();
+ resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
+ setResult(RESULT_OK, resultValue);
+ finish();
+ }
+ });
+ builder.setCancelable(false);
+
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ }
+
+ public LinkedHashMap getAllDevices(Context appContext) {
+ DaoSession daoSession;
+ GBApplication gbApp = (GBApplication) appContext;
+ LinkedHashMap<String, Pair<String, Integer>> newMap = new LinkedHashMap<>(1);
+ List<? extends GBDevice> devices = gbApp.getDeviceManager().getDevices();
+
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ daoSession = handler.getDaoSession();
+ for (GBDevice device : devices) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ Device dbDevice = DBHelper.findDevice(device, daoSession);
+ int icon = device.getEnabledDisabledIconResource();
+ if (dbDevice != null && coordinator != null
+ && (coordinator.supportsActivityDataFetching() || coordinator.supportsActivityTracking())
+ && !newMap.containsKey(device.getAliasOrName())) {
+ newMap.put(device.getAliasOrName(), new Pair(device.getAddress(), icon));
+ }
+ }
+ } catch (Exception e) {
+ LOG.error("Error getting list of all devices: " + e);
+ }
+ return newMap;
+ }
+
+ @Override
+ public void setLanguage(Locale language, boolean invalidateLanguage) {
+ AndroidUtils.setLanguage(this, language);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WorldClockDetails.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WorldClockDetails.java
new file mode 100644
index 0000000..9d48ac3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/WorldClockDetails.java
@@ -0,0 +1,220 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities;
+
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.TextWatcher;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import java.util.TimeZone;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.WorldClock;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+public class WorldClockDetails extends AbstractGBActivity {
+ private WorldClock worldClock;
+ private GBDevice device;
+
+ ArrayAdapter<String> timezoneAdapter;
+
+ TextView worldClockTimezone;
+ EditText worldClockLabel;
+ EditText worldClockCode;
+ View worldClockEnabledCard;
+ CheckBox worldClockEnabled;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_world_clock_details);
+
+ worldClock = (WorldClock) getIntent().getSerializableExtra(WorldClock.EXTRA_WORLD_CLOCK);
+
+ if (worldClock == null) {
+ GB.toast("No worldClock provided to WorldClockDetails Activity", Toast.LENGTH_LONG, GB.ERROR);
+ finish();
+ return;
+ }
+
+ worldClockEnabledCard = findViewById(R.id.card_enabled);
+ worldClockEnabled = findViewById(R.id.world_clock_enabled);
+ worldClockTimezone = findViewById(R.id.world_clock_timezone);
+ worldClockLabel = findViewById(R.id.world_clock_label);
+ worldClockCode = findViewById(R.id.world_clock_code);
+
+ device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (device == null) {
+ GB.toast("No device provided to WorldClockDetails Activity", Toast.LENGTH_LONG, GB.ERROR);
+ finish();
+ return;
+ }
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+
+ final String[] timezoneIDs = TimeZone.getAvailableIDs();
+ timezoneAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, timezoneIDs);
+
+ final View cardTimezone = findViewById(R.id.card_timezone);
+ cardTimezone.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ new MaterialAlertDialogBuilder(WorldClockDetails.this).setAdapter(timezoneAdapter, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ worldClock.setTimeZoneId(timezoneIDs[i]);
+ updateUiFromWorldClock();
+ }
+ }).create().show();
+ }
+ });
+
+ if (coordinator.supportsDisabledWorldClocks()) {
+ worldClockEnabled.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ worldClock.setEnabled(isChecked);
+ });
+ } else {
+ worldClockEnabledCard.setVisibility(View.GONE);
+ }
+
+ worldClockLabel.setFilters(new InputFilter[]{new InputFilter.LengthFilter(coordinator.getWorldClocksLabelLength())});
+ worldClockLabel.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(final CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(final CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(final Editable s) {
+ worldClock.setLabel(s.toString());
+ }
+ });
+
+ worldClockCode.setFilters(new InputFilter[]{new InputFilter.LengthFilter(3)});
+ worldClockCode.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(final CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(final CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(final Editable s) {
+ worldClock.setCode(s.toString());
+ }
+ });
+
+ final FloatingActionButton fab = findViewById(R.id.fab_save);
+ fab.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ updateWorldClock();
+ WorldClockDetails.this.setResult(1);
+ finish();
+ }
+ });
+
+ updateUiFromWorldClock();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ // TODO confirm when exiting without saving
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void updateWorldClock() {
+ DBHelper.store(worldClock);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull final Bundle state) {
+ super.onSaveInstanceState(state);
+ state.putSerializable("worldClock", worldClock);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ worldClock = (WorldClock) savedInstanceState.getSerializable("worldClock");
+ updateUiFromWorldClock();
+ }
+
+ public void updateUiFromWorldClock() {
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final int maxLabelLength = coordinator.getWorldClocksLabelLength();
+
+ worldClockEnabled.setChecked(worldClock.getEnabled() == null || worldClock.getEnabled());
+
+ final String oldTimezone = worldClockTimezone.getText().toString();
+
+ worldClockTimezone.setText(worldClock.getTimeZoneId());
+
+ // Check if the label was still the default (the timezone city name)
+ // If so, and if the user changed the timezone, update the label to match the new city name
+ if (!StringUtils.isNullOrEmpty(oldTimezone) && !oldTimezone.equals(worldClock.getTimeZoneId())) {
+ final String[] oldTimezoneParts = oldTimezone.split("/");
+ final String[] newTimezoneParts = worldClock.getTimeZoneId().split("/");
+ final String newLabel = StringUtils.truncate(newTimezoneParts[newTimezoneParts.length - 1], maxLabelLength);
+ final String oldLabel = StringUtils.truncate(oldTimezoneParts[oldTimezoneParts.length - 1], maxLabelLength);
+ final String userLabel = worldClockLabel.getText().toString();
+ if (StringUtils.isNullOrEmpty(userLabel) || userLabel.equals(oldLabel)) {
+ // The label was still the original, so let's override it with the new city
+ worldClock.setLabel(newLabel);
+ }
+ final String newCode = StringUtils.truncate(newLabel, 3).toUpperCase();
+ final String oldCode = StringUtils.truncate(oldLabel, 3).toUpperCase();
+ final String userCode = worldClockCode.getText().toString();
+ if (StringUtils.isNullOrEmpty(userCode) || userCode.equals(oldCode)) {
+ // The code was still the original, so let's override it with the new one
+ worldClock.setCode(newCode);
+ }
+ }
+
+ worldClockLabel.setText(worldClock.getLabel());
+ worldClockCode.setText(worldClock.getCode());
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsActivity.java
new file mode 100644
index 0000000..5a172b7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsActivity.java
@@ -0,0 +1,76 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.activities.app_specific_notifications;
+
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.appcompat.widget.SearchView;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.adapter.AppSpecificNotificationSettingsAppListAdapter;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AppSpecificNotificationSettingsActivity extends AbstractGBActivity {
+
+ private static final Logger LOG = LoggerFactory.getLogger(AppSpecificNotificationSettingsActivity.class);
+
+ private AppSpecificNotificationSettingsAppListAdapter appListAdapter;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_app_specific_notification_settings);
+ RecyclerView appListView = findViewById(R.id.appListView);
+ appListView.setLayoutManager(new LinearLayoutManager(this));
+
+ GBDevice device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ appListAdapter = new AppSpecificNotificationSettingsAppListAdapter(R.layout.item_app_specific_notification_app_list, this, device);
+
+ appListView.setAdapter(appListAdapter);
+
+ SearchView searchView = findViewById(R.id.appListViewSearch);
+ searchView.setIconifiedByDefault(false);
+ searchView.setIconified(false);
+ searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String query) {
+ return false;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String newText) {
+ appListAdapter.getFilter().filter(newText);
+ return true;
+ }
+ });
+ }
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ onBackPressed();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsDetailActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsDetailActivity.java
new file mode 100644
index 0000000..27af770
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsDetailActivity.java
@@ -0,0 +1,184 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs, Daniel Dakhno
+
+ 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.activities.app_specific_notifications;
+
+import android.os.Bundle;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.Spinner;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.adapter.AppSpecificNotificationSettingsAppListAdapter;
+import nodomain.freeyourgadget.gadgetbridge.database.AppSpecificNotificationSettingsRepository;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.AppSpecificNotificationSetting;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.AbstractNotificationPattern;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+
+public class AppSpecificNotificationSettingsDetailActivity extends AbstractGBActivity {
+ private AppSpecificNotificationSettingsRepository repository = null;
+ private String bundleId = null;
+
+ private GBDevice mDevice;
+ private DeviceCoordinator mCoordinator;
+
+ private List<String> mLedPatternValues = new ArrayList<>();
+ private List<String> mVibrationPatternValues = new ArrayList<>();
+ private List<String> mVibrationCountValues = new ArrayList<>();
+
+ private Spinner mSpinnerLedPattern;
+ private Spinner mSpinnerVibrationPattern;
+ private Spinner mSpinnerVibrationCount;
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_notification_per_app_setting_detail);
+ Button mButtonSave = findViewById(R.id.buttonSaveSettings);
+ Button mButtonDelete = findViewById(R.id.buttonDeleteSettings);
+ mSpinnerLedPattern = findViewById(R.id.spinnerLedType);
+ mSpinnerVibrationPattern = findViewById(R.id.spinnerVibraType);
+ mSpinnerVibrationCount = findViewById(R.id.spinnerVibraCount);
+
+ mDevice = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ mCoordinator = mDevice.getDeviceCoordinator();
+
+ mLedPatternValues.clear();
+ for(AbstractNotificationPattern p: mCoordinator.getNotificationLedPatterns())
+ mLedPatternValues.add(p.getValue());
+
+ mVibrationPatternValues.clear();
+ for(AbstractNotificationPattern p: mCoordinator.getNotificationVibrationPatterns())
+ mVibrationPatternValues.add(p.getValue());
+
+ mVibrationCountValues.clear();
+ for(AbstractNotificationPattern p: mCoordinator.getNotificationVibrationRepetitionPatterns())
+ mVibrationCountValues.add(p.getValue());
+
+ if(!mCoordinator.supportsNotificationLedPatterns()) {
+ mSpinnerLedPattern.setEnabled(false);
+ } else {
+ mSpinnerLedPattern.setAdapter(
+ createAdapterFromArrayAddingDefault(mCoordinator.getNotificationLedPatterns())
+ );
+ }
+
+ if(!mCoordinator.supportsNotificationVibrationPatterns()) {
+ mSpinnerVibrationPattern.setEnabled(false);
+ } else {
+ mSpinnerVibrationPattern.setAdapter(
+ createAdapterFromArrayAddingDefault(mCoordinator.getNotificationVibrationPatterns())
+ );
+ }
+
+ if(!mCoordinator.supportsNotificationVibrationRepetitionPatterns()) {
+ mSpinnerVibrationCount.setEnabled(false);
+ } else {
+ mSpinnerVibrationCount.setAdapter(
+ createAdapterFromArrayAddingDefault(mCoordinator.getNotificationVibrationRepetitionPatterns())
+ );
+ }
+
+ String title = getIntent().getStringExtra(AppSpecificNotificationSettingsAppListAdapter.STRING_EXTRA_PACKAGE_TITLE);
+ setTitle(title);
+ bundleId = getIntent().getStringExtra(AppSpecificNotificationSettingsAppListAdapter.STRING_EXTRA_PACKAGE_NAME);
+
+ repository = new AppSpecificNotificationSettingsRepository(mDevice);
+ mButtonDelete.setOnClickListener(view -> {
+ repository.setSettingsForAppId(bundleId, null);
+ finish();
+ });
+
+ mButtonSave.setOnClickListener(view -> {
+ saveSettings();
+ finish();
+ });
+
+ AppSpecificNotificationSetting setting = repository.getSettingsForAppId(bundleId);
+ if(setting != null) {
+ if(setting.getLedPattern() != null) {
+ int idx = mLedPatternValues.indexOf(setting.getLedPattern());
+ if(idx >= 0) {
+ mSpinnerLedPattern.setSelection(idx + 1);
+ }
+ } else {
+ mSpinnerLedPattern.setSelection(0);
+ }
+
+ if(setting.getVibrationPattern() != null) {
+ int idx = mVibrationPatternValues.indexOf(setting.getVibrationPattern());
+ if(idx >= 0) {
+ mSpinnerVibrationPattern.setSelection(idx + 1);
+ }
+ } else {
+ mSpinnerVibrationPattern.setSelection(0);
+ }
+
+ if(setting.getVibrationRepetition() != null) {
+ int idx = mVibrationCountValues.indexOf(setting.getVibrationRepetition());
+ if(idx >= 0) {
+ mSpinnerVibrationCount.setSelection(idx + 1);
+ }
+ } else {
+ mSpinnerVibrationCount.setSelection(0);
+ }
+ }
+ }
+
+ private ArrayAdapter<String> createAdapterFromArrayAddingDefault(AbstractNotificationPattern[] array) {
+ List<String> allOptions = new ArrayList<>();
+ allOptions.add(getString(R.string.pref_default));
+ for(AbstractNotificationPattern s: array) allOptions.add(s.getUserReadableName(getApplicationContext()));
+
+ return new ArrayAdapter<String>(this, android.R.layout.simple_spinner_item, allOptions);
+ }
+
+ private void saveSettings() {
+ String led = null;
+ String vibra = null;
+ String vibraTimes = null;
+
+ if(mSpinnerLedPattern.getSelectedItemPosition() > 0) {
+ led = mLedPatternValues.get(mSpinnerLedPattern.getSelectedItemPosition() - 1);
+ }
+
+ if(mSpinnerVibrationPattern.getSelectedItemPosition() > 0) {
+ vibra = mVibrationPatternValues.get(mSpinnerVibrationPattern.getSelectedItemPosition() - 1);
+ }
+
+ if(mSpinnerVibrationCount.getSelectedItemPosition() > 0) {
+ vibraTimes = mVibrationCountValues.get(mSpinnerVibrationCount.getSelectedItemPosition() - 1);
+ }
+
+ AppSpecificNotificationSetting setting = new AppSpecificNotificationSetting(
+ bundleId,
+ 0,
+ led,
+ vibra,
+ vibraTimes
+ );
+ repository.setSettingsForAppId(bundleId, setting);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java
new file mode 100644
index 0000000..3ddd57e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AbstractAppManagerFragment.java
@@ -0,0 +1,787 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo, Konrad Iturbe,
+ TylerWilliamson
+
+ 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.activities.appmanager;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport.QHYBRID_ACTION_DOWNLOADED_FILE;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.PopupMenu;
+import android.widget.Toast;
+
+import androidx.core.content.FileProvider;
+import androidx.fragment.app.Fragment;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.recyclerview.widget.ItemTouchHelper;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import org.apache.commons.lang3.StringUtils;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.ExternalPebbleJSActivity;
+import nodomain.freeyourgadget.gadgetbridge.adapter.GBDeviceAppAdapter;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilFileReader;
+import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilHRInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.QHybridConstants;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.PebbleProtocol;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GridAutoFitLayoutManager;
+import nodomain.freeyourgadget.gadgetbridge.util.PebbleUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Version;
+
+
+public abstract class AbstractAppManagerFragment extends Fragment {
+ public static final String ACTION_REFRESH_APPLIST
+ = "nodomain.freeyourgadget.gadgetbridge.appmanager.action.refresh_applist";
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractAppManagerFragment.class);
+ private static final int CHILD_ACTIVITY_WATCHFACE_EDITOR = 0;
+
+ private ItemTouchHelper appManagementTouchHelper;
+
+ protected final List<GBDeviceApp> appList = new ArrayList<>();
+ private GBDeviceAppAdapter mGBDeviceAppAdapter;
+ protected GBDevice mGBDevice = null;
+ protected DeviceCoordinator mCoordinator = null;
+ private Class<? extends Activity> watchfaceDesignerActivity;
+
+ protected abstract List<GBDeviceApp> getSystemAppsInCategory();
+
+ protected abstract String getSortFilename();
+
+ protected abstract boolean isCacheManager();
+
+ protected abstract boolean filterApp(GBDeviceApp gbDeviceApp);
+
+ public void startDragging(RecyclerView.ViewHolder viewHolder) {
+ appManagementTouchHelper.startDrag(viewHolder);
+ }
+
+ protected void onChangedAppOrder() {
+ List<UUID> uuidList = new ArrayList<>();
+ for (GBDeviceApp gbDeviceApp : mGBDeviceAppAdapter.getAppList()) {
+ uuidList.add(gbDeviceApp.getUUID());
+ }
+ AppManagerActivity.rewriteAppOrderFile(getSortFilename(), uuidList);
+ }
+
+ protected void refreshList() {
+ appList.clear();
+ ArrayList<UUID> uuids = AppManagerActivity.getUuidsFromFile(getSortFilename());
+ List<GBDeviceApp> systemApps = getSystemAppsInCategory();
+ boolean needsRewrite = false;
+ for (GBDeviceApp systemApp : systemApps) {
+ if (!uuids.contains(systemApp.getUUID())) {
+ uuids.add(systemApp.getUUID());
+ needsRewrite = true;
+ }
+ }
+ if (needsRewrite) {
+ AppManagerActivity.rewriteAppOrderFile(getSortFilename(), uuids);
+ }
+ appList.addAll(getCachedApps(uuids));
+ }
+
+ private void refreshListFromDevice(Intent intent) {
+ final Map<UUID, GBDeviceApp> cachedAppsMap = getCachedAppsMap(null);
+
+ appList.clear();
+ int appCount = intent.getIntExtra("app_count", 0);
+ for (int i = 0; i < appCount; i++) {
+ String appName = intent.getStringExtra("app_name" + i);
+ String appCreator = intent.getStringExtra("app_creator" + i);
+ String appVersion = intent.getStringExtra("app_version" + i);
+ UUID uuid = UUID.fromString(intent.getStringExtra("app_uuid" + i));
+ GBDeviceApp.Type appType = GBDeviceApp.Type.values()[intent.getIntExtra("app_type" + i, 0)];
+ Bitmap previewImage = getAppPreviewImage(uuid.toString());
+
+ // Fill out information from the cached app if missing
+ final GBDeviceApp cachedApp = cachedAppsMap.get(uuid);
+ if (cachedApp != null) {
+ if (StringUtils.isBlank(appName)) {
+ appName = cachedApp.getName();
+ }
+ if (StringUtils.isBlank(appCreator)) {
+ appCreator = cachedApp.getCreator();
+ }
+ } else {
+ if (StringUtils.isBlank(appName)) {
+ // If the app does not have a name, fallback to uuid
+ appName = uuid.toString();
+ }
+ }
+
+ GBDeviceApp app = new GBDeviceApp(uuid, appName, appCreator, appVersion, appType, previewImage);
+ app.setOnDevice(true);
+ if (mGBDevice.getType() == DeviceType.FOSSILQHYBRID) {
+ if ((app.getType() == GBDeviceApp.Type.WATCHFACE) && (!QHybridConstants.HYBRIDHR_WATCHFACE_VERSION.equals(appVersion))) {
+ app.setUpToDate(false);
+ }
+ try {
+ if ((app.getType() == GBDeviceApp.Type.APP_GENERIC) && ((new Version(app.getVersion())).smallerThan(new Version(QHybridConstants.KNOWN_WAPP_VERSIONS.get(app.getName()))))) {
+ app.setUpToDate(false);
+ }
+ } catch (IllegalArgumentException e) {
+ LOG.warn("App JSON: " + app.getJSON().toString());
+ LOG.warn("Couldn't read app version", e);
+ }
+ }
+
+ if (filterApp(app)) {
+ appList.add(app);
+ }
+ }
+ if (mGBDevice.getType() == DeviceType.FOSSILQHYBRID) {
+ List<GBDeviceApp> systemApps = getSystemAppsInCategory();
+ for (GBDeviceApp systemApp : systemApps) {
+ appList.add(systemApp);
+ }
+ }
+ }
+
+ private Bitmap getAppPreviewImage(String name) {
+ Bitmap previewImage = null;
+ try {
+ File cacheDir = mCoordinator.getAppCacheDir();
+ File previewImgFile = new File(cacheDir, name + "_preview.png");
+ if (previewImgFile.exists()) {
+ previewImage = BitmapFactory.decodeFile(previewImgFile.getAbsolutePath());
+ }
+ } catch (IOException e) {
+ LOG.warn("Couldn't load watch app preview image", e);
+ }
+ return previewImage;
+ }
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ switch (action) {
+ case ACTION_REFRESH_APPLIST: {
+ if (intent.hasExtra("app_count")) {
+ LOG.info("got app info from device");
+ if (!isCacheManager()) {
+ LOG.info("will refresh list based on data from device");
+ refreshListFromDevice(intent);
+ }
+ } else if (mCoordinator.supportsAppListFetching()) {
+ refreshList();
+ } else if (isCacheManager()) {
+ refreshList();
+ }
+ mGBDeviceAppAdapter.notifyDataSetChanged();
+ break;
+ }
+ case QHYBRID_ACTION_DOWNLOADED_FILE: {
+ if (!intent.getBooleanExtra("EXTRA_SUCCESS", false)) {
+ LOG.warn("wapp download was not successful");
+ GB.toast(context.getString(R.string.appmanager_download_app_error), Toast.LENGTH_LONG, GB.ERROR);
+ break;
+ }
+ if (!intent.getBooleanExtra("EXTRA_TOCACHE", false)) {
+ break;
+ }
+ String path = intent.getStringExtra("EXTRA_PATH");
+ String name = intent.getStringExtra("EXTRA_NAME");
+ LOG.info("Attempting to add downloaded app " + name + " to cache");
+ FossilFileReader fileReader;
+ try {
+ fileReader = new FossilFileReader(Uri.fromFile(new File(path)), context);
+ } catch (IOException e) {
+ LOG.warn("Could not find downloaded wapp", e);
+ break;
+ }
+ if (FossilHRInstallHandler.saveAppInCache(fileReader, fileReader.getBackground(), fileReader.getPreview(), mCoordinator, context)) {
+ LOG.info("Successfully moved downloaded app " + name + " to cache");
+ GB.toast(String.format(context.getString(R.string.appmanager_downloaded_to_cache), name), Toast.LENGTH_LONG, GB.INFO);
+ if (isCacheManager()) {
+ refreshList();
+ mGBDeviceAppAdapter.notifyDataSetChanged();
+ }
+ (new File(path)).delete();
+ } else {
+ LOG.warn("Parsing downloaded wapp was not successful");
+ GB.toast(context.getString(R.string.appmanager_download_app_error), Toast.LENGTH_LONG, GB.ERROR);
+ }
+ break;
+ }
+ }
+ }
+ };
+
+ protected Map<UUID, GBDeviceApp> getCachedAppsMap(final List<UUID> uuids) {
+ final List<GBDeviceApp> cachedApps = getCachedApps(uuids);
+ final Map<UUID, GBDeviceApp> cachedAppsMap = new HashMap<>();
+ for (GBDeviceApp cachedApp : cachedApps) {
+ cachedAppsMap.put(cachedApp.getUUID(), cachedApp);
+ }
+ return cachedAppsMap;
+ }
+
+ protected List<GBDeviceApp> getCachedApps(List<UUID> uuids) {
+ List<GBDeviceApp> cachedAppList = new ArrayList<>();
+ File cachePath;
+ try {
+ cachePath = mCoordinator.getAppCacheDir();
+ } catch (IOException e) {
+ LOG.warn("could not get external dir while reading app cache.");
+ return cachedAppList;
+ }
+
+ if (cachePath == null) {
+ LOG.warn("Cached apps path is null");
+ return Collections.emptyList();
+ }
+
+ File[] files;
+ if (uuids == null) {
+ files = cachePath.listFiles();
+ } else {
+ files = new File[uuids.size()];
+ int index = 0;
+ for (UUID uuid : uuids) {
+ files[index++] = new File(uuid.toString() + mCoordinator.getAppFileExtension());
+ }
+ }
+ if (files != null) {
+ for (File file : files) {
+ if (file.getName().endsWith(mCoordinator.getAppFileExtension())) {
+ String baseName = file.getName().substring(0, file.getName().length() - mCoordinator.getAppFileExtension().length());
+ //metadata
+ File jsonFile = new File(cachePath, baseName + ".json");
+ //configuration
+ File configFile = new File(cachePath, baseName + "_config.js");
+ try {
+ String jsonstring = FileUtils.getStringFromFile(jsonFile);
+ JSONObject json = new JSONObject(jsonstring);
+ GBDeviceApp app = new GBDeviceApp(json, configFile.exists(), getAppPreviewImage(baseName));
+ if (mGBDevice.getType() == DeviceType.FOSSILQHYBRID) {
+ if ((app.getType() == GBDeviceApp.Type.WATCHFACE) && (!QHybridConstants.HYBRIDHR_WATCHFACE_VERSION.equals(app.getVersion()))) {
+ app.setUpToDate(false);
+ }
+ try {
+ if ((app.getType() == GBDeviceApp.Type.APP_GENERIC) && ((new Version(app.getVersion())).smallerThan(new Version(QHybridConstants.KNOWN_WAPP_VERSIONS.get(app.getName()))))) {
+ app.setUpToDate(false);
+ }
+ } catch (IllegalArgumentException e) {
+ LOG.warn("Couldn't read app version", e);
+ }
+ }
+ cachedAppList.add(app);
+ } catch (Exception e) {
+ LOG.info("could not read json file for " + baseName);
+ if (mGBDevice.getType() == DeviceType.PEBBLE) {
+ //FIXME: this is really ugly, if we do not find system uuids in pbw cache add them manually. Also duplicated code
+ switch (baseName) {
+ case "8f3c8686-31a1-4f5f-91f5-01600c9bdc59":
+ cachedAppList.add(new GBDeviceApp(UUID.fromString(baseName), "Tic Toc (System)", "Pebble Inc.", "", GBDeviceApp.Type.WATCHFACE_SYSTEM));
+ break;
+ case "1f03293d-47af-4f28-b960-f2b02a6dd757":
+ cachedAppList.add(new GBDeviceApp(UUID.fromString(baseName), "Music (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ break;
+ case "b2cae818-10f8-46df-ad2b-98ad2254a3c1":
+ cachedAppList.add(new GBDeviceApp(UUID.fromString(baseName), "Notifications (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ break;
+ case "67a32d95-ef69-46d4-a0b9-854cc62f97f9":
+ cachedAppList.add(new GBDeviceApp(UUID.fromString(baseName), "Alarms (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ break;
+ case "18e443ce-38fd-47c8-84d5-6d0c775fbe55":
+ cachedAppList.add(new GBDeviceApp(UUID.fromString(baseName), "Watchfaces (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ break;
+ case "0863fc6a-66c5-4f62-ab8a-82ed00a98b5d":
+ cachedAppList.add(new GBDeviceApp(UUID.fromString(baseName), "Send Text (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ break;
+ }
+ /*
+ else if (baseName.equals("4dab81a6-d2fc-458a-992c-7a1f3b96a970")) {
+ cachedAppList.add(new GBDeviceApp(UUID.fromString("4dab81a6-d2fc-458a-992c-7a1f3b96a970"), "Sports (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ } else if (baseName.equals("cf1e816a-9db0-4511-bbb8-f60c48ca8fac")) {
+ cachedAppList.add(new GBDeviceApp(UUID.fromString("cf1e816a-9db0-4511-bbb8-f60c48ca8fac"), "Golf (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ }
+ */
+ if (mGBDevice != null) {
+ if (PebbleUtils.hasHealth(mGBDevice.getModel())) {
+ if (baseName.equals(PebbleProtocol.UUID_PEBBLE_HEALTH.toString())) {
+ cachedAppList.add(new GBDeviceApp(PebbleProtocol.UUID_PEBBLE_HEALTH, "Health (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ continue;
+ }
+ }
+ if (PebbleUtils.hasHRM(mGBDevice.getModel())) {
+ if (baseName.equals(PebbleProtocol.UUID_WORKOUT.toString())) {
+ cachedAppList.add(new GBDeviceApp(PebbleProtocol.UUID_WORKOUT, "Workout (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ continue;
+ }
+ }
+ if (PebbleUtils.getFwMajor(mGBDevice.getFirmwareVersion()) >= 4) {
+ if (baseName.equals("3af858c3-16cb-4561-91e7-f1ad2df8725f")) {
+ cachedAppList.add(new GBDeviceApp(UUID.fromString(baseName), "Kickstart (System)", "Pebble Inc.", "", GBDeviceApp.Type.WATCHFACE_SYSTEM));
+ }
+ if (baseName.equals(PebbleProtocol.UUID_WEATHER.toString())) {
+ cachedAppList.add(new GBDeviceApp(PebbleProtocol.UUID_WEATHER, "Weather (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ }
+ }
+ }
+ if (uuids == null) {
+ cachedAppList.add(new GBDeviceApp(UUID.fromString(baseName), baseName, "N/A", "", GBDeviceApp.Type.UNKNOWN));
+ }
+ }
+ }
+ }
+ }
+ }
+ return cachedAppList;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(ACTION_REFRESH_APPLIST);
+ filter.addAction(QHYBRID_ACTION_DOWNLOADED_FILE);
+
+ LocalBroadcastManager.getInstance(getContext()).registerReceiver(mReceiver, filter);
+
+ if (mCoordinator.supportsAppListFetching()) {
+ GBApplication.deviceService(mGBDevice).onAppInfoReq();
+ if (isCacheManager()) {
+ refreshList();
+ }
+ } else {
+ refreshList();
+ }
+
+ try {
+ File appCacheDir = mCoordinator.getAppCacheDir();
+ File appTempDir = new File(appCacheDir, "temp_sharing");
+ if (appTempDir.isDirectory()) {
+ for (File child : appTempDir.listFiles())
+ child.delete();
+ appTempDir.delete();
+ }
+ } catch (IOException e) {
+ LOG.warn("Could not delete temporary app cache directory", e);
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mGBDevice = ((AppManagerActivity) getActivity()).getGBDevice();
+ mCoordinator = mGBDevice.getDeviceCoordinator();
+
+ final FloatingActionButton appListFab = ((FloatingActionButton) getActivity().findViewById(R.id.fab));
+ final FloatingActionButton appListFabNew = ((FloatingActionButton) getActivity().findViewById(R.id.fab_new));
+ watchfaceDesignerActivity = mCoordinator.getWatchfaceDesignerActivity();
+ View rootView = inflater.inflate(R.layout.activity_appmanager, container, false);
+
+ RecyclerView appListView = (RecyclerView) (rootView.findViewById(R.id.appListView));
+
+ appListView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+ @Override
+ public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
+ if (dy > 0) {
+ appListFab.hide();
+ appListFabNew.hide();
+ } else if (dy < 0) {
+ appListFab.show();
+ if (watchfaceDesignerActivity != null) {
+ appListFabNew.show();
+ }
+ }
+ }
+ });
+ appListView.setLayoutManager(new GridAutoFitLayoutManager(getActivity(), 300));
+ mGBDeviceAppAdapter = new GBDeviceAppAdapter(
+ appList,
+ R.layout.item_appmanager_watchapp,
+ this,
+ mCoordinator.supportsAppReordering() || isCacheManager()
+ );
+ appListView.setAdapter(mGBDeviceAppAdapter);
+
+ ItemTouchHelper.Callback appItemTouchHelperCallback = new AppItemTouchHelperCallback(mGBDeviceAppAdapter);
+ appManagementTouchHelper = new ItemTouchHelper(appItemTouchHelperCallback);
+
+ appManagementTouchHelper.attachToRecyclerView(appListView);
+
+ if ((watchfaceDesignerActivity != null) && (appListFabNew != null)) {
+ appListFabNew.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent startIntent = new Intent(getContext(), watchfaceDesignerActivity);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, mGBDevice);
+ getContext().startActivity(startIntent);
+ }
+ });
+ appListFabNew.show();
+ }
+
+ return rootView;
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
+ if (requestCode == CHILD_ACTIVITY_WATCHFACE_EDITOR) {
+ refreshList();
+ }
+ }
+
+ protected void sendOrderToDevice(String concatFilename) {
+ ArrayList<UUID> uuids = new ArrayList<>();
+ for (GBDeviceApp gbDeviceApp : mGBDeviceAppAdapter.getAppList()) {
+ uuids.add(gbDeviceApp.getUUID());
+ }
+ if (concatFilename != null) {
+ ArrayList<UUID> concatUuids = AppManagerActivity.getUuidsFromFile(concatFilename);
+ uuids.addAll(concatUuids);
+ }
+ GBApplication.deviceService(mGBDevice).onAppReorder(uuids.toArray(new UUID[uuids.size()]));
+ }
+
+ public void onItemClick(View view, GBDeviceApp deviceApp) {
+ openPopupMenu(view, deviceApp);
+ }
+
+ public boolean openPopupMenu(View view, GBDeviceApp deviceApp) {
+ PopupMenu popupMenu = new PopupMenu(getContext(), view);
+ popupMenu.getMenuInflater().inflate(R.menu.appmanager_context, popupMenu.getMenu());
+ Menu menu = popupMenu.getMenu();
+ final GBDeviceApp selectedApp = deviceApp;
+
+ if (!selectedApp.isOnDevice() || selectedApp.getType() != GBDeviceApp.Type.WATCHFACE) {
+ menu.removeItem(R.id.appmanager_watchface_activate);
+ }
+ if (!selectedApp.isOnDevice() || selectedApp.getType() != GBDeviceApp.Type.APP_GENERIC) {
+ menu.removeItem(R.id.appmanager_app_start);
+ }
+ if (!selectedApp.isInCache()) {
+ menu.removeItem(R.id.appmanager_app_edit);
+ menu.removeItem(R.id.appmanager_app_reinstall);
+ menu.removeItem(R.id.appmanager_app_share);
+ menu.removeItem(R.id.appmanager_app_delete_cache);
+ }
+ if (!PebbleProtocol.UUID_PEBBLE_HEALTH.equals(selectedApp.getUUID())) {
+ menu.removeItem(R.id.appmanager_health_activate);
+ menu.removeItem(R.id.appmanager_health_deactivate);
+ }
+ if (!PebbleProtocol.UUID_WORKOUT.equals(selectedApp.getUUID())) {
+ menu.removeItem(R.id.appmanager_hrm_activate);
+ menu.removeItem(R.id.appmanager_hrm_deactivate);
+ }
+ if (!PebbleProtocol.UUID_WEATHER.equals(selectedApp.getUUID())) {
+ menu.removeItem(R.id.appmanager_weather_activate);
+ menu.removeItem(R.id.appmanager_weather_deactivate);
+ menu.removeItem(R.id.appmanager_weather_install_provider);
+ }
+ if (selectedApp.getType() == GBDeviceApp.Type.APP_SYSTEM || selectedApp.getType() == GBDeviceApp.Type.WATCHFACE_SYSTEM) {
+ menu.removeItem(R.id.appmanager_app_delete);
+ }
+ if (!selectedApp.isConfigurable()) {
+ menu.removeItem(R.id.appmanager_app_configure);
+ }
+
+ if (PebbleProtocol.UUID_WEATHER.equals(selectedApp.getUUID())) {
+ PackageManager pm = getActivity().getPackageManager();
+ try {
+ pm.getPackageInfo("ru.gelin.android.weather.notification", PackageManager.GET_ACTIVITIES);
+ menu.removeItem(R.id.appmanager_weather_install_provider);
+ } catch (PackageManager.NameNotFoundException e) {
+ //menu.removeItem(R.id.appmanager_weather_activate);
+ //menu.removeItem(R.id.appmanager_weather_deactivate);
+ }
+ }
+
+ if ((mGBDevice.getType() != DeviceType.FOSSILQHYBRID) || (selectedApp.getType() != GBDeviceApp.Type.WATCHFACE)) {
+ menu.removeItem(R.id.appmanager_app_edit);
+ }
+ if ((mGBDevice.getType() != DeviceType.FOSSILQHYBRID) || (!selectedApp.isOnDevice()) || ((selectedApp.getType() != GBDeviceApp.Type.WATCHFACE) && (selectedApp.getType() != GBDeviceApp.Type.APP_GENERIC))) {
+ menu.removeItem(R.id.appmanager_app_download);
+ }
+ if (mGBDevice.getType() == DeviceType.FOSSILQHYBRID && selectedApp.getName().equals("workoutApp")) {
+ menu.removeItem(R.id.appmanager_app_delete);
+ }
+
+ if (mGBDevice.getType() == DeviceType.PEBBLE) {
+ switch (selectedApp.getType()) {
+ case WATCHFACE:
+ case APP_GENERIC:
+ case APP_ACTIVITYTRACKER:
+ break;
+ default:
+ menu.removeItem(R.id.appmanager_app_openinstore);
+ }
+ } else {
+ menu.removeItem(R.id.appmanager_app_openinstore);
+ }
+ //menu.setHeaderTitle(selectedApp.getName());
+ popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
+ public boolean onMenuItemClick(MenuItem item) {
+ return onContextItemSelected(item, selectedApp);
+ }
+ }
+ );
+
+ popupMenu.show();
+ return true;
+ }
+
+ private boolean onContextItemSelected(final MenuItem item, final GBDeviceApp selectedApp) {
+ final File appCacheDir;
+ try {
+ appCacheDir = mCoordinator.getAppCacheDir();
+ } catch (final IOException e) {
+ LOG.warn("could not get external dir while trying to access app cache.");
+ return true;
+ }
+ final int itemId = item.getItemId();
+ if (itemId == R.id.appmanager_app_delete_cache) {
+ deleteAppConfirm(selectedApp, true);
+ return true;
+ } else if (itemId == R.id.appmanager_app_delete) {
+ deleteAppConfirm(selectedApp, false);
+ return true;
+ } else if (itemId == R.id.appmanager_app_start || itemId == R.id.appmanager_watchface_activate) {
+ GBApplication.deviceService(mGBDevice).onAppStart(selectedApp.getUUID(), true);
+ return true;
+ } else if (itemId == R.id.appmanager_app_download) {
+ GBApplication.deviceService(mGBDevice).onAppDownload(selectedApp.getUUID());
+ GB.toast(requireContext().getString(R.string.appmanager_download_started), Toast.LENGTH_LONG, GB.INFO);
+ return true;
+ } else if (itemId == R.id.appmanager_app_reinstall) {
+ final File cachePath = new File(appCacheDir, selectedApp.getUUID() + mCoordinator.getAppFileExtension());
+ GBApplication.deviceService(mGBDevice).onInstallApp(Uri.fromFile(cachePath));
+ return true;
+ } else if (itemId == R.id.appmanager_app_share) {
+ final File origFilePath = new File(appCacheDir, selectedApp.getUUID() + mCoordinator.getAppFileExtension());
+ final File appTempDir = new File(appCacheDir, "temp_sharing");
+ final File sharedAppFile = new File(appTempDir, selectedApp.getName() + mCoordinator.getAppFileExtension());
+ try {
+ appTempDir.mkdirs();
+ FileUtils.copyFile(origFilePath, sharedAppFile);
+ } catch (final IOException e) {
+ return true;
+ }
+ final Uri contentUri = FileProvider.getUriForFile(requireContext(), requireContext().getApplicationContext().getPackageName() + ".screenshot_provider", sharedAppFile);
+ final Intent shareIntent = new Intent(Intent.ACTION_SEND);
+ shareIntent.putExtra(Intent.EXTRA_STREAM, contentUri);
+ shareIntent.setType("*/*");
+ try {
+ startActivity(Intent.createChooser(shareIntent, null));
+ } catch (ActivityNotFoundException e) {
+ LOG.warn("Sharing watchface failed", e);
+ }
+ return true;
+ } else if (itemId == R.id.appmanager_health_activate) {
+ GBApplication.deviceService(mGBDevice).onInstallApp(Uri.parse("fake://health"));
+ return true;
+ } else if (itemId == R.id.appmanager_hrm_activate) {
+ GBApplication.deviceService(mGBDevice).onInstallApp(Uri.parse("fake://hrm"));
+ return true;
+ } else if (itemId == R.id.appmanager_weather_activate) {
+ GBApplication.deviceService(mGBDevice).onInstallApp(Uri.parse("fake://weather"));
+ return true;
+ } else if (itemId == R.id.appmanager_health_deactivate || itemId == R.id.appmanager_hrm_deactivate || itemId == R.id.appmanager_weather_deactivate) {
+ GBApplication.deviceService(mGBDevice).onAppDelete(selectedApp.getUUID());
+ return true;
+ } else if (itemId == R.id.appmanager_weather_install_provider) {
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://f-droid.org/app/ru.gelin.android.weather.notification")));
+ return true;
+ } else if (itemId == R.id.appmanager_app_configure) {
+ GBApplication.deviceService(mGBDevice).onAppStart(selectedApp.getUUID(), true);
+
+ final Intent startIntent = new Intent(getContext().getApplicationContext(), ExternalPebbleJSActivity.class);
+ startIntent.putExtra(DeviceService.EXTRA_APP_UUID, selectedApp.getUUID());
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, mGBDevice);
+ startIntent.putExtra(ExternalPebbleJSActivity.SHOW_CONFIG, true);
+ startActivity(startIntent);
+ return true;
+ } else if (itemId == R.id.appmanager_app_openinstore) {
+ final String url = "https://apps.rebble.io/en_US/search/" + ((selectedApp.getType() == GBDeviceApp.Type.WATCHFACE) ? "watchfaces" : "watchapps") + "/1/?native=true&?query=" + Uri.encode(selectedApp.getName());
+ final Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse(url));
+ startActivity(intent);
+ return true;
+ } else if (itemId == R.id.appmanager_app_edit) {
+ final Intent editWatchfaceIntent = new Intent(getContext(), watchfaceDesignerActivity);
+ editWatchfaceIntent.putExtra(GBDevice.EXTRA_DEVICE, mGBDevice);
+ editWatchfaceIntent.putExtra(GBDevice.EXTRA_UUID, selectedApp.getUUID().toString());
+ startActivityForResult(editWatchfaceIntent, CHILD_ACTIVITY_WATCHFACE_EDITOR);
+ return true;
+ }
+
+ return super.onContextItemSelected(item);
+ }
+
+ private void deleteAppConfirm(final GBDeviceApp selectedApp, final boolean deleteFromCache) {
+ new MaterialAlertDialogBuilder(getContext())
+ .setTitle(R.string.Delete)
+ .setMessage(requireContext().getString(R.string.contact_delete_confirm_description, selectedApp.getName()))
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
+ if (deleteFromCache) {
+ deleteAppFromCache(selectedApp);
+ }
+ deleteAppFromDevice(selectedApp);
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+ }
+
+ private void deleteAppFromCache(final GBDeviceApp selectedApp) {
+ final File appCacheDir;
+ try {
+ appCacheDir = mCoordinator.getAppCacheDir();
+ } catch (final IOException e) {
+ LOG.warn("Could not get external dir while trying to access app cache", e);
+ return;
+ }
+
+ String baseName = selectedApp.getUUID().toString();
+ String[] suffixToDelete = new String[]{mCoordinator.getAppFileExtension(), ".json", "_config.js", "_preset.json", ".png", "_preview.png", "_bg.png"};
+ for (String suffix : suffixToDelete) {
+ File fileToDelete = new File(appCacheDir, baseName + suffix);
+ if (!fileToDelete.delete()) {
+ LOG.warn("Could not delete file from app cache: {}", fileToDelete);
+ } else {
+ LOG.debug("Deleted from app cache: {}", fileToDelete);
+ }
+ }
+ AppManagerActivity.deleteFromAppOrderFile(getSortFilename(), selectedApp.getUUID()); // FIXME: only if successful
+ Intent refreshIntent = new Intent(AbstractAppManagerFragment.ACTION_REFRESH_APPLIST);
+ LocalBroadcastManager.getInstance(requireContext()).sendBroadcast(refreshIntent);
+ }
+
+ private void deleteAppFromDevice(final GBDeviceApp selectedApp) {
+ if (mCoordinator.supportsAppReordering()) {
+ AppManagerActivity.deleteFromAppOrderFile(mGBDevice.getAddress() + ".watchapps", selectedApp.getUUID()); // FIXME: only if successful
+ AppManagerActivity.deleteFromAppOrderFile(mGBDevice.getAddress() + ".watchfaces", selectedApp.getUUID()); // FIXME: only if successful
+ Intent refreshIntent = new Intent(AbstractAppManagerFragment.ACTION_REFRESH_APPLIST);
+ LocalBroadcastManager.getInstance(requireContext()).sendBroadcast(refreshIntent);
+ }
+
+ GBApplication.deviceService(mGBDevice).onAppDelete(selectedApp.getUUID());
+ }
+
+ /**
+ * Sort an app list by the UUIDs in the sort filename.
+ *
+ * @param appList the app list to sort in-place
+ */
+ protected void sortAppList(final List<GBDeviceApp> appList) {
+ final ArrayList<UUID> uuids = AppManagerActivity.getUuidsFromFile(getSortFilename());
+ final Map<UUID, Integer> uuidPosMap = new HashMap<>();
+ for (int i = 0; i < uuids.size(); i++) {
+ uuidPosMap.put(uuids.get(i), i);
+ }
+
+ Collections.sort(appList, (a1, a2) -> {
+ final Integer pos1 = uuidPosMap.get(a1.getUUID());
+ final Integer pos2 = uuidPosMap.get(a2.getUUID());
+
+ if (pos1 != null && pos2 != null) return Integer.compare(pos1, pos2);
+ if (pos1 == null && pos2 == null) return a1.getName().compareToIgnoreCase(a2.getName());
+ if (pos1 != null) return -1;
+
+ return 1;
+ });
+ }
+
+ @Override
+ public void onDestroy() {
+ LocalBroadcastManager.getInstance(getContext()).unregisterReceiver(mReceiver);
+ super.onDestroy();
+ }
+
+ public class AppItemTouchHelperCallback extends ItemTouchHelper.Callback {
+
+ private final GBDeviceAppAdapter gbDeviceAppAdapter;
+
+ public AppItemTouchHelperCallback(GBDeviceAppAdapter gbDeviceAppAdapter) {
+ this.gbDeviceAppAdapter = gbDeviceAppAdapter;
+ }
+
+ @Override
+ public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
+ if (!mCoordinator.supportsAppReordering() && !isCacheManager()) {
+ return 0;
+ }
+ //we only support up and down movement and only for moving, not for swiping apps away
+ return makeMovementFlags(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0);
+ }
+
+ @Override
+ public boolean isLongPressDragEnabled() {
+ return false;
+ }
+
+ @Override
+ public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) {
+ gbDeviceAppAdapter.onItemMove(source.getAdapterPosition(), target.getAdapterPosition());
+ return true;
+ }
+
+ @Override
+ public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
+ //nothing to do
+ }
+
+ @Override
+ public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
+ super.clearView(recyclerView, viewHolder);
+ onChangedAppOrder();
+ }
+
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerActivity.java
new file mode 100644
index 0000000..2398e91
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerActivity.java
@@ -0,0 +1,217 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniele Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.appmanager;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import androidx.core.app.NavUtils;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.viewpager.widget.ViewPager;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractFragmentPagerAdapter;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragmentActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.FwAppInstallerActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+
+
+public class AppManagerActivity extends AbstractGBFragmentActivity {
+
+ private static final Logger LOG = LoggerFactory.getLogger(AppManagerActivity.class);
+ private int READ_REQUEST_CODE = 42;
+
+ private GBDevice mGBDevice = null;
+
+ private List<String> enabledTabsList;
+
+ public GBDevice getGBDevice() {
+ return mGBDevice;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_fragmentappmanager);
+
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ mGBDevice = extras.getParcelable(GBDevice.EXTRA_DEVICE);
+ }
+ if (mGBDevice == null) {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+
+ final DeviceCoordinator coordinator = mGBDevice.getDeviceCoordinator();
+
+ enabledTabsList = new ArrayList<>();
+
+ if (coordinator.supportsCachedAppManagement(mGBDevice)) {
+ enabledTabsList.add("cache");
+ }
+ if (coordinator.supportsInstalledAppManagement(mGBDevice)) {
+ enabledTabsList.add("apps");
+ }
+ if (coordinator.supportsWatchfaceManagement(mGBDevice)) {
+ enabledTabsList.add("watchfaces");
+ }
+
+ FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
+ assert fab != null;
+ fab.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("*/*");
+ startActivityForResult(intent, READ_REQUEST_CODE);
+ }
+ });
+
+ // Set up the ViewPager with the sections adapter.
+ ViewPager viewPager = (ViewPager) findViewById(R.id.appmanager_pager);
+ if (viewPager != null) {
+ viewPager.setAdapter(getPagerAdapter());
+ }
+ }
+
+ @Override
+ protected AbstractFragmentPagerAdapter createFragmentPagerAdapter(FragmentManager fragmentManager) {
+ return new SectionsPagerAdapter(fragmentManager);
+ }
+
+ public static synchronized void deleteFromAppOrderFile(String filename, UUID uuid) {
+ ArrayList<UUID> uuids = getUuidsFromFile(filename);
+ uuids.remove(uuid);
+ rewriteAppOrderFile(filename, uuids);
+ }
+
+ public class SectionsPagerAdapter extends AbstractFragmentPagerAdapter {
+
+ SectionsPagerAdapter(FragmentManager fm) {
+ super(fm);
+ }
+
+ @Override
+ public Fragment getItem(int position) {
+ // getItem is called to instantiate the fragment for the given page.
+ switch (enabledTabsList.get(position)) {
+ case "cache":
+ return new AppManagerFragmentCache();
+ case "apps":
+ return new AppManagerFragmentInstalledApps();
+ case "watchfaces":
+ return new AppManagerFragmentInstalledWatchfaces();
+ }
+ return null;
+ }
+
+ @Override
+ public int getCount() {
+ return enabledTabsList.toArray().length;
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ switch (enabledTabsList.get(position)) {
+ case "cache":
+ return getString(R.string.appmanager_cached_watchapps_watchfaces);
+ case "apps":
+ return getString(R.string.appmanager_installed_watchapps);
+ case "watchfaces":
+ return getString(R.string.appmanager_installed_watchfaces);
+ }
+ return super.getPageTitle(position);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+
+ static synchronized void rewriteAppOrderFile(String filename, List<UUID> uuids) {
+ try (BufferedWriter out = new BufferedWriter(new FileWriter(FileUtils.getExternalFilesDir() + "/" + filename))) {
+ for (UUID uuid : uuids) {
+ out.write(uuid.toString());
+ out.newLine();
+ }
+ } catch (IOException e) {
+ LOG.warn("can't write app order to file!");
+ }
+ }
+
+ synchronized public static void addToAppOrderFile(String filename, UUID uuid) {
+ ArrayList<UUID> uuids = getUuidsFromFile(filename);
+ if (!uuids.contains(uuid)) {
+ uuids.add(uuid);
+ rewriteAppOrderFile(filename, uuids);
+ }
+ }
+
+ static synchronized ArrayList<UUID> getUuidsFromFile(String filename) {
+ ArrayList<UUID> uuids = new ArrayList<>();
+ try (BufferedReader in = new BufferedReader(new FileReader(FileUtils.getExternalFilesDir() + "/" + filename))) {
+ String line;
+ while ((line = in.readLine()) != null) {
+ uuids.add(UUID.fromString(line));
+ }
+ } catch (IOException e) {
+ LOG.warn("could not read sort file");
+ }
+ return uuids;
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
+ super.onActivityResult(requestCode, resultCode, resultData);
+ if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
+ Intent startIntent = new Intent(AppManagerActivity.this, FwAppInstallerActivity.class);
+ startIntent.setAction(Intent.ACTION_VIEW);
+ startIntent.setDataAndType(resultData.getData(), null);
+ startActivity(startIntent);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentCache.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentCache.java
new file mode 100644
index 0000000..c603dfb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentCache.java
@@ -0,0 +1,51 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.appmanager;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+
+public class AppManagerFragmentCache extends AbstractAppManagerFragment {
+ @Override
+ public void refreshList() {
+ appList.clear();
+ final List<GBDeviceApp> cachedApps = getCachedApps(null);
+ sortAppList(cachedApps);
+ appList.addAll(cachedApps);
+ }
+
+ @Override
+ protected boolean isCacheManager() {
+ return true;
+ }
+
+ @Override
+ protected List<GBDeviceApp> getSystemAppsInCategory() {
+ return null;
+ }
+
+ @Override
+ public String getSortFilename() {
+ return mCoordinator.getAppCacheSortFilename();
+ }
+
+ @Override
+ protected boolean filterApp(GBDeviceApp gbDeviceApp) {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentInstalledApps.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentInstalledApps.java
new file mode 100644
index 0000000..2f07bf9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentInstalledApps.java
@@ -0,0 +1,83 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Arjan Schrijver, Daniele
+ Gobbetti
+
+ 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.activities.appmanager;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.PebbleProtocol;
+import nodomain.freeyourgadget.gadgetbridge.util.PebbleUtils;
+
+public class AppManagerFragmentInstalledApps extends AbstractAppManagerFragment {
+
+ @Override
+ protected List<GBDeviceApp> getSystemAppsInCategory() {
+ List<GBDeviceApp> systemApps = new ArrayList<>();
+
+ if (mGBDevice.getType() == DeviceType.PEBBLE) {
+ //systemApps.add(new GBDeviceApp(UUID.fromString("4dab81a6-d2fc-458a-992c-7a1f3b96a970"), "Sports (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ //systemApps.add(new GBDeviceApp(UUID.fromString("cf1e816a-9db0-4511-bbb8-f60c48ca8fac"), "Golf (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ systemApps.add(new GBDeviceApp(UUID.fromString("1f03293d-47af-4f28-b960-f2b02a6dd757"), "Music (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ systemApps.add(new GBDeviceApp(PebbleProtocol.UUID_NOTIFICATIONS, "Notifications (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ systemApps.add(new GBDeviceApp(UUID.fromString("67a32d95-ef69-46d4-a0b9-854cc62f97f9"), "Alarms (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ systemApps.add(new GBDeviceApp(UUID.fromString("18e443ce-38fd-47c8-84d5-6d0c775fbe55"), "Watchfaces (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+
+ if (mGBDevice != null) {
+ if (PebbleUtils.hasHealth(mGBDevice.getModel())) {
+ systemApps.add(new GBDeviceApp(UUID.fromString("0863fc6a-66c5-4f62-ab8a-82ed00a98b5d"), "Send Text (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ systemApps.add(new GBDeviceApp(PebbleProtocol.UUID_PEBBLE_HEALTH, "Health (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ }
+ if (PebbleUtils.hasHRM(mGBDevice.getModel())) {
+ systemApps.add(new GBDeviceApp(PebbleProtocol.UUID_WORKOUT, "Workout (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ }
+ if (PebbleUtils.getFwMajor(mGBDevice.getFirmwareVersion()) >= 4) {
+ systemApps.add(new GBDeviceApp(PebbleProtocol.UUID_WEATHER, "Weather (System)", "Pebble Inc.", "", GBDeviceApp.Type.APP_SYSTEM));
+ }
+ }
+ } else if (mGBDevice.getType() == DeviceType.FOSSILQHYBRID) {
+ systemApps.add(new GBDeviceApp(UUID.nameUUIDFromBytes("workoutApp".getBytes(StandardCharsets.UTF_8)), "workoutApp", "", "", GBDeviceApp.Type.APP_ACTIVITYTRACKER));
+ }
+
+ return systemApps;
+ }
+
+ @Override
+ protected boolean isCacheManager() {
+ return false;
+ }
+
+ @Override
+ protected String getSortFilename() {
+ return mGBDevice.getAddress() + ".watchapps";
+ }
+
+ @Override
+ protected void onChangedAppOrder() {
+ super.onChangedAppOrder();
+ sendOrderToDevice(mGBDevice.getAddress() + ".watchfaces");
+ }
+
+ @Override
+ protected boolean filterApp(GBDeviceApp gbDeviceApp) {
+ return gbDeviceApp.getType() == GBDeviceApp.Type.APP_ACTIVITYTRACKER || gbDeviceApp.getType() == GBDeviceApp.Type.APP_GENERIC;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentInstalledWatchfaces.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentInstalledWatchfaces.java
new file mode 100644
index 0000000..20faeb8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/AppManagerFragmentInstalledWatchfaces.java
@@ -0,0 +1,62 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Arjan Schrijver
+
+ 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.activities.appmanager;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class AppManagerFragmentInstalledWatchfaces extends AbstractAppManagerFragment {
+
+ @Override
+ protected List<GBDeviceApp> getSystemAppsInCategory() {
+ List<GBDeviceApp> systemWatchfaces = new ArrayList<>();
+ if (mGBDevice.getType() != DeviceType.PEBBLE) {
+ return systemWatchfaces;
+ }
+ systemWatchfaces.add(new GBDeviceApp(UUID.fromString("8f3c8686-31a1-4f5f-91f5-01600c9bdc59"), "Tic Toc (System)", "Pebble Inc.", "", GBDeviceApp.Type.WATCHFACE_SYSTEM));
+ systemWatchfaces.add(new GBDeviceApp(UUID.fromString("3af858c3-16cb-4561-91e7-f1ad2df8725f"), "Kickstart (System)", "Pebble Inc.", "", GBDeviceApp.Type.WATCHFACE_SYSTEM));
+ return systemWatchfaces;
+ }
+
+ @Override
+ protected boolean isCacheManager() {
+ return false;
+ }
+
+ @Override
+ protected String getSortFilename() {
+ return mGBDevice.getAddress() + ".watchfaces";
+ }
+
+ @Override
+ protected void onChangedAppOrder() {
+ super.onChangedAppOrder();
+ sendOrderToDevice(mGBDevice.getAddress() + ".watchapps");
+ }
+
+ @Override
+ protected boolean filterApp(GBDeviceApp gbDeviceApp) {
+ if (gbDeviceApp.getType() == GBDeviceApp.Type.WATCHFACE) {
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java
new file mode 100644
index 0000000..1fdccd5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java
@@ -0,0 +1,462 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo, a0z
+
+ 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.activities.charts;
+
+import android.util.TypedValue;
+
+import androidx.core.content.ContextCompat;
+
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import org.apache.commons.lang3.NotImplementedException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public abstract class AbstractActivityChartFragment<D extends ChartsData> extends AbstractChartFragment<D> {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractActivityChartFragment.class);
+
+ public static final float Y_VALUE_DEEP_SLEEP = 0.01f;
+
+ public boolean supportsHeartrate(GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.supportsHeartRateMeasurement(device);
+ }
+
+ public boolean supportsRemSleep(GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.supportsRemSleep();
+ }
+
+ public boolean supportsAwakeSleep(GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.supportsAwakeSleep();
+ }
+
+ protected static final class ActivityConfig {
+ public final ActivityKind type;
+ public final String label;
+ public final Integer color;
+
+ public ActivityConfig(ActivityKind kind, String label, Integer color) {
+ this.type = kind;
+ this.label = label;
+ this.color = color;
+ }
+ }
+
+ protected ActivityConfig akActivity;
+ protected ActivityConfig akLightSleep;
+ protected ActivityConfig akDeepSleep;
+ protected ActivityConfig akRemSleep;
+ protected ActivityConfig akAwakeSleep;
+ protected ActivityConfig akNotWorn;
+
+ protected int BACKGROUND_COLOR;
+ protected int DESCRIPTION_COLOR;
+ protected int CHART_TEXT_COLOR;
+ protected int LEGEND_TEXT_COLOR;
+ protected int HEARTRATE_COLOR;
+ protected int HEARTRATE_FILL_COLOR;
+ protected int AK_ACTIVITY_COLOR;
+ protected int AK_DEEP_SLEEP_COLOR;
+ protected int AK_REM_SLEEP_COLOR;
+ protected int AK_AWAKE_SLEEP_COLOR;
+ protected int AK_LIGHT_SLEEP_COLOR;
+ protected int AK_NOT_WORN_COLOR;
+
+ protected String HEARTRATE_LABEL;
+ protected String HEARTRATE_AVERAGE_LABEL;
+
+ @Override
+ protected void init() {
+ Prefs prefs = GBApplication.getPrefs();
+ TypedValue runningColor = new TypedValue();
+ BACKGROUND_COLOR = GBApplication.getBackgroundColor(getContext());
+ LEGEND_TEXT_COLOR = DESCRIPTION_COLOR = GBApplication.getTextColor(getContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext());
+ if (prefs.getBoolean("chart_heartrate_color", false)) {
+ HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_alternative);
+ }else{
+ HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate);
+ }
+ HEARTRATE_FILL_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_fill);
+
+ getContext().getTheme().resolveAttribute(R.attr.chart_activity, runningColor, true);
+ AK_ACTIVITY_COLOR = runningColor.data;
+ getContext().getTheme().resolveAttribute(R.attr.chart_deep_sleep, runningColor, true);
+ AK_DEEP_SLEEP_COLOR = runningColor.data;
+ getContext().getTheme().resolveAttribute(R.attr.chart_light_sleep, runningColor, true);
+ AK_LIGHT_SLEEP_COLOR = runningColor.data;
+ getContext().getTheme().resolveAttribute(R.attr.chart_rem_sleep, runningColor, true);
+ AK_REM_SLEEP_COLOR = runningColor.data;
+ getContext().getTheme().resolveAttribute(R.attr.chart_awake_sleep, runningColor, true);
+ AK_AWAKE_SLEEP_COLOR = runningColor.data;
+ getContext().getTheme().resolveAttribute(R.attr.chart_not_worn, runningColor, true);
+ AK_NOT_WORN_COLOR = runningColor.data;
+
+ HEARTRATE_LABEL = getContext().getString(R.string.charts_legend_heartrate);
+ HEARTRATE_AVERAGE_LABEL = getContext().getString(R.string.charts_legend_heartrate_average);
+
+ akActivity = new ActivityConfig(ActivityKind.ACTIVITY, getString(R.string.abstract_chart_fragment_kind_activity), AK_ACTIVITY_COLOR);
+ akLightSleep = new ActivityConfig(ActivityKind.LIGHT_SLEEP, getString(R.string.abstract_chart_fragment_kind_light_sleep), AK_LIGHT_SLEEP_COLOR);
+ akDeepSleep = new ActivityConfig(ActivityKind.DEEP_SLEEP, getString(R.string.abstract_chart_fragment_kind_deep_sleep), AK_DEEP_SLEEP_COLOR);
+ akRemSleep = new ActivityConfig(ActivityKind.REM_SLEEP, getString(R.string.abstract_chart_fragment_kind_rem_sleep), AK_REM_SLEEP_COLOR);
+ akAwakeSleep = new ActivityConfig(ActivityKind.REM_SLEEP, getString(R.string.abstract_chart_fragment_kind_awake_sleep), AK_AWAKE_SLEEP_COLOR);
+ akNotWorn = new ActivityConfig(ActivityKind.NOT_WORN, getString(R.string.abstract_chart_fragment_kind_not_worn), AK_NOT_WORN_COLOR);
+ }
+
+ @Override
+ protected boolean isSingleDay() {
+ return false;
+ }
+
+ protected Integer getColorFor(ActivityKind activityKind) {
+ switch (activityKind) {
+ case DEEP_SLEEP:
+ return akDeepSleep.color;
+ case LIGHT_SLEEP:
+ return akLightSleep.color;
+ case REM_SLEEP:
+ return akRemSleep.color;
+ case AWAKE_SLEEP:
+ return akAwakeSleep.color;
+ case ACTIVITY:
+ return akActivity.color;
+ }
+ return akActivity.color;
+ }
+
+ protected SampleProvider<? extends AbstractActivitySample> getProvider(DBHandler db, GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.getSampleProvider(device, db.getDaoSession());
+ }
+
+ /**
+ * Returns all kinds of samples for the given device.
+ * To be called from a background thread.
+ *
+ * @param device
+ * @param tsFrom
+ * @param tsTo
+ */
+ protected List<? extends ActivitySample> getAllSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ SampleProvider<? extends ActivitySample> provider = getProvider(db, device);
+ return provider.getAllActivitySamples(tsFrom, tsTo);
+ }
+
+ protected List<? extends ActivitySample> getAllSamplesHighRes(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ SampleProvider<? extends ActivitySample> provider = getProvider(db, device);
+ // Only retrieve if the provider signals it has high res data, otherwise it is useless
+ if (provider.hasHighResData())
+ return provider.getAllActivitySamplesHighRes(tsFrom, tsTo);
+ return null;
+ }
+
+ protected List<? extends AbstractActivitySample> getActivitySamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ SampleProvider<? extends AbstractActivitySample> provider = getProvider(db, device);
+ return provider.getActivitySamples(tsFrom, tsTo);
+ }
+
+ public DefaultChartsData<LineData> refresh(GBDevice gbDevice, List<? extends ActivitySample> samples) {
+ // If there is no high res samples, all the samples are high res samples
+ return refresh(gbDevice, samples, samples);
+ }
+
+ public DefaultChartsData<LineData> refresh(GBDevice gbDevice, List<? extends ActivitySample> samples, List<? extends ActivitySample> highResSamples) {
+ TimestampTranslation tsTranslation = new TimestampTranslation();
+ LOG.info("{}: number of samples: {}", getTitle(), samples.size());
+ LOG.info("{}: number of high res samples: {}", getTitle(), highResSamples.size());
+ LineData lineData;
+
+ if (samples.isEmpty()) {
+ lineData = new LineData();
+ ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm");
+ return new DefaultChartsData<>(lineData, xValueFormatter);
+ }
+
+ ActivityKind last_type = ActivityKind.UNKNOWN;
+ float last_value = 0;
+
+ int numEntries = samples.size();
+ List<List<Entry>> entries = new ArrayList<>();
+ for (int i = 0; i < 6; i++) {
+ entries.add(new ArrayList<>());
+ }
+
+ for (int i = 0; i < numEntries; i++) {
+ ActivitySample sample = samples.get(i);
+ ActivityKind type = sample.getKind();
+ int ts = tsTranslation.shorten(sample.getTimestamp());
+ final float value;
+ if (type != ActivityKind.NOT_WORN) {
+ if (ActivityKind.isSleep(type) && sample.getIntensity() < 0) {
+ switch (type) {
+ case SLEEP_ANY:
+ case AWAKE_SLEEP:
+ value = 0.25f;
+ break;
+ case DEEP_SLEEP:
+ value = 0.10f;
+ break;
+ case LIGHT_SLEEP:
+ value = 0.15f;
+ break;
+ case REM_SLEEP:
+ value = 0.20f;
+ break;
+ default:
+ value = Y_VALUE_DEEP_SLEEP;
+ break;
+ }
+ } else {
+ value = sample.getIntensity();
+ }
+ } else {
+ value = Y_VALUE_DEEP_SLEEP;
+ }
+
+ // do not interpolate NOT_WORN on any side
+ boolean interpolate = !(last_type == ActivityKind.NOT_WORN || type == ActivityKind.NOT_WORN);
+ float interpolation_value = interpolate ? value : last_value;
+
+ // filled charts
+ int index = getIndexOfActivity(type);
+ int last_index = getIndexOfActivity(last_type);
+ if (last_type != type) {
+ entries.get(index).add(createLineEntry(0, ts));
+ entries.get(last_index).add(createLineEntry(interpolation_value, ts));
+ entries.get(last_index).add(createLineEntry(0, ts));
+ }
+ entries.get(index).add(createLineEntry(value, ts));
+
+ last_type = type;
+ last_value = value;
+ }
+
+ boolean hr = supportsHeartrate(gbDevice);
+ final List<Entry> heartRateLineEntries = new ArrayList<>();
+ final List<ILineDataSet> heartRateDataSets = new ArrayList<>();
+ int lastTsShorten = 0;
+ HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance();
+
+ // Currently only for HR
+ if (hr) {
+ for (ActivitySample sample : highResSamples) {
+ if (sample.getKind() != ActivityKind.NOT_WORN && heartRateUtilsInstance.isValidHeartRateValue(sample.getHeartRate())) {
+ int tsShorten = tsTranslation.shorten(sample.getTimestamp());
+ if (lastTsShorten == 0 || (tsShorten - lastTsShorten) <= 60 * HeartRateUtils.MAX_HR_MEASUREMENTS_GAP_MINUTES) {
+ heartRateLineEntries.add(new Entry(tsShorten, sample.getHeartRate()));
+ } else {
+ if (!heartRateLineEntries.isEmpty()) {
+ List<Entry> clone = new ArrayList<>(heartRateLineEntries.size());
+ clone.addAll(heartRateLineEntries);
+ heartRateDataSets.add(createHeartrateSet(clone, "Heart Rate"));
+ heartRateLineEntries.clear();
+ }
+ }
+ lastTsShorten = tsShorten;
+ heartRateLineEntries.add(new Entry(tsShorten, sample.getHeartRate()));
+ }
+ }
+ }
+ if (!heartRateLineEntries.isEmpty()) {
+ heartRateDataSets.add(createHeartrateSet(heartRateLineEntries, "Heart Rate"));
+ }
+
+ // convert Entry Lists to Datasets
+ List<ILineDataSet> lineDataSets = new ArrayList<>();
+
+ lineDataSets.add(createDataSet(
+ entries.get(getIndexOfActivity(ActivityKind.ACTIVITY)), akActivity.color, "Activity"
+ ));
+ lineDataSets.add(createDataSet(
+ entries.get(getIndexOfActivity(ActivityKind.DEEP_SLEEP)), akDeepSleep.color, "Deep Sleep"
+ ));
+ lineDataSets.add(createDataSet(
+ entries.get(getIndexOfActivity(ActivityKind.LIGHT_SLEEP)), akLightSleep.color, "Light Sleep"
+ ));
+ lineDataSets.add(createDataSet(
+ entries.get(getIndexOfActivity(ActivityKind.NOT_WORN)), akNotWorn.color, "Not worn"
+ ));
+
+ if (supportsRemSleep(gbDevice)) {
+ lineDataSets.add(createDataSet(
+ entries.get(getIndexOfActivity(ActivityKind.REM_SLEEP)), akRemSleep.color, "REM Sleep"
+ ));
+ }
+ if (supportsAwakeSleep(gbDevice)) {
+ lineDataSets.add(createDataSet(
+ entries.get(getIndexOfActivity(ActivityKind.AWAKE_SLEEP)), akAwakeSleep.color, "Awake Sleep"
+ ));
+ }
+ if (hr && !heartRateDataSets.isEmpty()) {
+ lineDataSets.addAll(heartRateDataSets);
+ }
+
+ lineData = new LineData(lineDataSets);
+
+ ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm");
+ return new DefaultChartsData<>(lineData, xValueFormatter);
+ }
+
+ protected int getIndexOfActivity(ActivityKind kind) {
+ switch (kind) {
+ case DEEP_SLEEP: return 0;
+ case LIGHT_SLEEP: return 1;
+ case REM_SLEEP: return 2;
+ case AWAKE_SLEEP: return 3;
+ case NOT_WORN: return 4;
+ default: return 5; // treated as ActivityKind.ACTIVITY
+ }
+ }
+
+ protected Entry createLineEntry(float value, int xValue) {
+ return new Entry(xValue, value);
+ }
+
+ protected LineDataSet createDataSet(List<Entry> values, Integer color, String label) {
+ LineDataSet set1 = new LineDataSet(values, label);
+ set1.setColor(color);
+ set1.setDrawFilled(true);
+ set1.setDrawCircles(false);
+ set1.setFillColor(color);
+ set1.setFillAlpha(255);
+ set1.setDrawValues(false);
+ set1.setValueTextColor(CHART_TEXT_COLOR);
+ set1.setAxisDependency(YAxis.AxisDependency.LEFT);
+ return set1;
+ }
+
+ protected LineDataSet createHeartrateSet(List<Entry> values, String label) {
+ LineDataSet set1 = new LineDataSet(values, label);
+ set1.setLineWidth(2.2f);
+ set1.setColor(HEARTRATE_COLOR);
+ set1.setMode(LineDataSet.Mode.HORIZONTAL_BEZIER);
+ set1.setCubicIntensity(0.1f);
+ set1.setDrawCircles(false);
+ set1.setDrawValues(true);
+ set1.setValueTextColor(CHART_TEXT_COLOR);
+ set1.setAxisDependency(YAxis.AxisDependency.RIGHT);
+ return set1;
+ }
+
+ /**
+ * Implement this to supply the samples to be displayed.
+ */
+ protected abstract List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo);
+
+ /**
+ * Implement this to supply high resolution data
+ */
+ protected List<? extends ActivitySample> getSamplesHighRes(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ throw new NotImplementedException("High resolution samples have not been implemented for this chart.");
+ }
+
+ protected List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device) {
+ int tsStart = getTSStart();
+ int tsEnd = getTSEnd();
+ List<ActivitySample> samples = (List<ActivitySample>) getSamples(db, device, tsStart, tsEnd);
+ ensureStartAndEndSamples(samples, tsStart, tsEnd);
+// List<ActivitySample> samples2 = new ArrayList<>();
+// int min = Math.min(samples.size(), 10);
+// int min = Math.min(samples.size(), 10);
+// for (int i = 0; i < min; i++) {
+// samples2.add(samples.get(i));
+// }
+// return samples2;
+ return samples;
+ }
+
+ protected List<? extends ActivitySample> getSamplesHighRes(DBHandler db, GBDevice device) {
+ int tsStart = getTSStart();
+ int tsEnd = getTSEnd();
+ return getSamplesHighRes(db, device, tsStart, tsEnd);
+ }
+
+ protected List<? extends ActivitySample> getSamplesofSleep(DBHandler db, GBDevice device) {
+ int SLEEP_HOUR_LIMIT = 12;
+
+ int tsStart = getTSStart();
+ Calendar day = GregorianCalendar.getInstance();
+ day.setTimeInMillis(tsStart * 1000L);
+ day.set(Calendar.HOUR_OF_DAY, SLEEP_HOUR_LIMIT);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ tsStart = toTimestamp(day.getTime());
+
+ int tsEnd = getTSEnd();
+ day.setTimeInMillis(tsEnd* 1000L);
+ day.set(Calendar.HOUR_OF_DAY, SLEEP_HOUR_LIMIT);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ tsEnd = toTimestamp(day.getTime());
+
+ List<ActivitySample> samples = (List<ActivitySample>) getSamples(db, device, tsStart, tsEnd);
+ ensureStartAndEndSamples(samples, tsStart, tsEnd);
+ return samples;
+ }
+
+ protected void ensureStartAndEndSamples(List<ActivitySample> samples, int tsStart, int tsEnd) {
+ if (samples == null || samples.isEmpty()) {
+ return;
+ }
+ ActivitySample lastSample = samples.get(samples.size() - 1);
+ if (lastSample.getTimestamp() < tsEnd) {
+ samples.add(createTrailingActivitySample(lastSample, tsEnd));
+ }
+
+ ActivitySample firstSample = samples.get(0);
+ if (firstSample.getTimestamp() > tsStart) {
+ samples.add(createTrailingActivitySample(firstSample, tsStart));
+ }
+ }
+
+ private ActivitySample createTrailingActivitySample(ActivitySample referenceSample, int timestamp) {
+ TrailingActivitySample sample = new TrailingActivitySample();
+ if (referenceSample instanceof AbstractActivitySample) {
+ AbstractActivitySample reference = (AbstractActivitySample) referenceSample;
+ sample.setUserId(reference.getUserId());
+ sample.setDeviceId(reference.getDeviceId());
+ sample.setProvider(reference.getProvider());
+ }
+ sample.setTimestamp(timestamp);
+ return sample;
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractChartFragment.java
new file mode 100644
index 0000000..6f6c581
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractChartFragment.java
@@ -0,0 +1,403 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Dikay900, Jos茅 Rebelo, Pavel Elagin, Petr Van臎k, walkjivefly
+
+ 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.activities.charts;
+
+import android.annotation.SuppressLint;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.text.format.DateUtils;
+import android.view.View;
+
+import androidx.fragment.app.FragmentActivity;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.github.mikephil.charting.charts.BarChart;
+import com.github.mikephil.charting.charts.BarLineChartBase;
+import com.github.mikephil.charting.charts.Chart;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
+import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+/**
+ * A base class fragment to be used with ChartsActivity. The fragment can supply
+ * a title to be displayed in the activity by returning non-null in #getTitle()
+ * Broadcast events can be received by overriding #onReceive(Context,Intent).
+ * The chart can be refreshed by calling #refresh()
+ * Implement refreshInBackground(DBHandler, GBDevice) to fetch the samples from the DB,
+ * and add the samples to the chart. The actual rendering, which must be performed in the UI
+ * thread, must be done in #renderCharts().
+ * Access functionality of the hosting activity with #getHost()
+ * <p/>
+ * The hosting ChartsHost activity provides a section for displaying a date or date range
+ * being the basis for the chart, as well as two buttons for moving backwards and forward
+ * in time. The date is held by the activity, so that it can be shared by multiple chart
+ * fragments. It is still the responsibility of the (currently visible) chart fragment
+ * to set the desired date in the ChartsActivity via #setDateRange(Date,Date).
+ * The default implementations #handleDatePrev(Date,Date) and #handleDateNext(Date,Date)
+ * shift the date by one day.
+ */
+public abstract class AbstractChartFragment<D extends ChartsData> extends AbstractGBFragment {
+ protected final int ANIM_TIME = 250;
+
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractChartFragment.class);
+ @SuppressLint("SimpleDateFormat")
+ protected final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
+
+ private final Set<String> mIntentFilterActions;
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ AbstractChartFragment.this.onReceive(context, intent);
+ }
+ };
+
+ private boolean mChartDirty = true;
+ private AsyncTask refreshTask;
+
+ protected AbstractChartFragment(String... intentFilterActions) {
+ mIntentFilterActions = new HashSet<>();
+ if (intentFilterActions != null) {
+ mIntentFilterActions.addAll(Arrays.asList(intentFilterActions));
+ }
+ mIntentFilterActions.add(ChartsHost.DATE_NEXT_DAY);
+ mIntentFilterActions.add(ChartsHost.DATE_PREV_DAY);
+ mIntentFilterActions.add(ChartsHost.DATE_NEXT_WEEK);
+ mIntentFilterActions.add(ChartsHost.DATE_PREV_WEEK);
+ mIntentFilterActions.add(ChartsHost.DATE_NEXT_MONTH);
+ mIntentFilterActions.add(ChartsHost.DATE_PREV_MONTH);
+ mIntentFilterActions.add(ChartsHost.REFRESH);
+ }
+
+ @Override
+ public abstract String getTitle();
+
+ /**
+ * Called in the fragment's onCreate, initializes this fragment.
+ */
+ protected abstract void init();
+
+ /**
+ * This method reads the data from the database, analyzes and prepares it for
+ * the charts. This will be called from a background task, so there must not be
+ * any UI access. #updateChartsInUIThread and #renderCharts will be automatically called after this method.
+ */
+ protected abstract D refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device);
+
+ /**
+ * Triggers the actual (re-) rendering of the chart.
+ * Always called from the UI thread.
+ */
+ protected abstract void renderCharts();
+
+ protected abstract void setupLegend(Chart<?> chart);
+
+ protected abstract void updateChartsnUIThread(D chartsData);
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ init();
+
+ final IntentFilter filter = new IntentFilter();
+ for (String action : mIntentFilterActions) {
+ filter.addAction(action);
+ }
+ LocalBroadcastManager.getInstance(requireActivity()).registerReceiver(mReceiver, filter);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ LocalBroadcastManager.getInstance(requireActivity()).unregisterReceiver(mReceiver);
+ }
+
+ /**
+ * Called when this fragment has been fully scrolled into the activity.
+ *
+ * @see #isVisibleInActivity()
+ * @see #onMadeInvisibleInActivity()
+ */
+ @Override
+ protected void onMadeVisibleInActivity() {
+ super.onMadeVisibleInActivity();
+ showDateBar(true);
+ updateDateInfo(getStartDate(), getEndDate());
+ if (mChartDirty) {
+ refresh();
+ }
+ }
+
+ protected ChartsHost getChartsHost() {
+ return (ChartsHost) requireActivity();
+ }
+
+ private void setStartDate(Date date) {
+ getChartsHost().setStartDate(date);
+ }
+
+ private void setEndDate(Date date) {
+ getChartsHost().setEndDate(date);
+ }
+
+ public Date getStartDate() {
+ return getChartsHost().getStartDate();
+ }
+
+ public Date getEndDate() {
+ return getChartsHost().getEndDate();
+ }
+
+ protected int getTSEnd() {
+ return toTimestamp(getEndDate());
+ }
+
+ protected int getTSStart() {
+ return toTimestamp(getStartDate());
+ }
+
+ /**
+ * Whether this chart shows data only for a single day. The day is expected to match
+ * the end timestamp.
+ */
+ protected boolean isSingleDay() {
+ return true;
+ }
+
+ protected int toTimestamp(Date date) {
+ return (int) ((date.getTime() / 1000));
+ }
+
+ protected void showDateBar(boolean show) {
+ getChartsHost().getDateBar().setVisibility(show ? View.VISIBLE : View.GONE);
+ }
+
+ protected void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (ChartsHost.REFRESH.equals(action)) {
+ updateDateInfo(getStartDate(), getEndDate());
+ refresh();
+ } else if (ChartsHost.DATE_NEXT_DAY.equals(action)) {
+ handleDate(getStartDate(), getEndDate(), +1);
+ } else if (ChartsHost.DATE_PREV_DAY.equals(action)) {
+ handleDate(getStartDate(), getEndDate(), -1);
+ } else if (ChartsHost.DATE_NEXT_WEEK.equals(action)) {
+ handleDate(getStartDate(), getEndDate(), +7);
+ } else if (ChartsHost.DATE_PREV_WEEK.equals(action)) {
+ handleDate(getStartDate(), getEndDate(), -7);
+ } else if (ChartsHost.DATE_NEXT_MONTH.equals(action)) {
+ //calculate dates to jump by month but keep subsequent logic working
+ int time1 = DateTimeUtils.shiftMonths((int) (getStartDate().getTime() / 1000), 1);
+ int time2 = DateTimeUtils.shiftMonths((int) (getEndDate().getTime() / 1000), 1);
+ Date date1 = DateTimeUtils.shiftByDays(new Date(time1 * 1000L), 30);
+ Date date2 = DateTimeUtils.shiftByDays(new Date(time2 * 1000L), 30);
+ handleDate(date1, date2, -30);
+ } else if (ChartsHost.DATE_PREV_MONTH.equals(action)) {
+ int time1 = DateTimeUtils.shiftMonths((int) (getStartDate().getTime() / 1000), -1);
+ int time2 = DateTimeUtils.shiftMonths((int) (getEndDate().getTime() / 1000), -1);
+ Date date1 = DateTimeUtils.shiftByDays(new Date(time1 * 1000L), -30);
+ Date date2 = DateTimeUtils.shiftByDays(new Date(time2 * 1000L), -30);
+ handleDate(date1, date2, 30);
+ }
+ }
+
+ /**
+ * Default implementation shifts the dates by one day, if visible
+ * and calls #refreshIfVisible().
+ *
+ * @param startDate the start date
+ * @param endDate the end date
+ * @param offset the offset, in days
+ */
+ private void handleDate(Date startDate, Date endDate, Integer offset) {
+ if (isVisibleInActivity()) {
+ if (!shiftDates(startDate, endDate, offset)) {
+ return;
+ }
+
+ updateDateInfo(getStartDate(), getEndDate());
+ }
+ refreshIfVisible();
+ }
+
+ private void refreshIfVisible() {
+ if (isVisibleInActivity()) {
+ refresh();
+ } else {
+ mChartDirty = true;
+ }
+ }
+
+ /**
+ * Shifts the given dates by offset days. offset may be positive or negative.
+ *
+ * @param startDate the start date
+ * @param endDate the end date
+ * @param offset a positive or negative number of days to shift the dates
+ * @return true if the shift was successful and false otherwise
+ */
+ private boolean shiftDates(Date startDate, Date endDate, int offset) {
+ Date newStart = DateTimeUtils.shiftByDays(startDate, offset);
+ Date newEnd = DateTimeUtils.shiftByDays(endDate, offset);
+ Date now = new Date();
+ if (newEnd.after(now)) { //allow to jump to the end (now) if week/month reach after now
+ newEnd = now;
+ newStart = DateTimeUtils.shiftByDays(now, -1);
+ }
+ return setDateRange(newStart, newEnd);
+ }
+
+ protected void configureChartDefaults(Chart<?> chart) {
+ chart.getXAxis().setValueFormatter(new TimestampValueFormatter());
+ chart.getDescription().setText("");
+
+ // if enabled, the chart will always start at zero on the y-axis
+ chart.setNoDataText(getString(R.string.chart_no_data_synchronize));
+
+ // disable value highlighting
+ chart.setHighlightPerTapEnabled(false);
+
+ // enable touch gestures
+ chart.setTouchEnabled(true);
+
+// commented out: this has weird bugs/side-effects at least on WeekStepsCharts
+// where only the first Day-label is drawn, because AxisRenderer.computeAxisValues(float,float)
+// appears to have an overflow when calculating 'n' (number of entries)
+// chart.getXAxis().setGranularity(60*5);
+
+ setupLegend(chart);
+ }
+
+ protected void configureBarLineChartDefaults(BarLineChartBase<?> chart) {
+ configureChartDefaults(chart);
+ if (chart instanceof BarChart) {
+ ((BarChart) chart).setFitBars(true);
+ }
+
+ // enable scaling and dragging
+ chart.setDragEnabled(true);
+ chart.setScaleEnabled(true);
+
+ // if disabled, scaling can be done on x- and y-axis separately
+// chart.setPinchZoom(true);
+
+ chart.setDrawGridBackground(false);
+ }
+
+ /**
+ * This method will invoke a background task to read the data from the
+ * database, analyze it, prepare it for the charts and eventually call
+ * #renderCharts
+ */
+ protected void refresh() {
+ LOG.info("Refreshing data for {} from {} to {}", getTitle(), sdf.format(getStartDate()), sdf.format(getEndDate()));
+
+ ChartsHost chartsHost = getChartsHost();
+ if (chartsHost != null) {
+ if (chartsHost.getDevice() != null) {
+ mChartDirty = false;
+ if (refreshTask != null && refreshTask.getStatus() != AsyncTask.Status.FINISHED) {
+ refreshTask.cancel(true);
+ }
+ refreshTask = createRefreshTask("Visualizing data", getActivity()).execute();
+ }
+ }
+ }
+
+ private RefreshTask createRefreshTask(final String task, final Context context) {
+ return new RefreshTask(task, context);
+ }
+
+ @SuppressLint("StaticFieldLeak")
+ private final class RefreshTask extends DBAccess {
+ private D chartsData;
+
+ public RefreshTask(final String task, final Context context) {
+ super(task, context);
+ }
+
+ @Override
+ protected void doInBackground(final DBHandler db) {
+ final ChartsHost chartsHost = getChartsHost();
+ if (chartsHost != null) {
+ chartsData = refreshInBackground(chartsHost, db, chartsHost.getDevice());
+ } else {
+ cancel(true);
+ }
+ }
+
+ @Override
+ protected void onPostExecute(final Object o) {
+ super.onPostExecute(o);
+ final FragmentActivity activity = getActivity();
+ if (activity != null && !activity.isFinishing() && !activity.isDestroyed()) {
+ updateChartsnUIThread(chartsData);
+ renderCharts();
+ } else {
+ LOG.info("Not rendering charts because activity is not available anymore");
+ }
+ }
+ }
+
+ /**
+ * Returns true if the date was successfully shifted, and false if the shift
+ * was ignored, e.g. when the to-value is in the future.
+ *
+ * @param from the start date
+ * @param to the end date
+ */
+ private boolean setDateRange(final Date from, final Date to) {
+ if (from.compareTo(to) > 0) {
+ throw new IllegalArgumentException("Bad date range: " + from + ".." + to);
+ }
+ final Date now = new Date();
+ if (to.after(now) || //do not refresh chart if we reached now
+ to.getTime() / 10000 == (getEndDate().getTime() / 10000)) {
+ return false;
+ }
+ setStartDate(from);
+ setEndDate(to);
+ return true;
+ }
+
+ private void updateDateInfo(final Date from, final Date to) {
+ int dateFlags = DateUtils.FORMAT_SHOW_WEEKDAY;
+ if (isSingleDay() || from.equals(to)) {
+ getChartsHost().setDateInfo(DateTimeUtils.formatDate(to, dateFlags));
+ } else {
+ getChartsHost().setDateInfo(DateTimeUtils.formatDateRange(from, to, dateFlags));
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractChartsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractChartsActivity.java
new file mode 100644
index 0000000..d979092
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractChartsActivity.java
@@ -0,0 +1,361 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.app.Activity;
+import android.app.DatePickerDialog;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResult;
+import androidx.activity.result.ActivityResultCallback;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.ActionBar;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+import androidx.viewpager.widget.ViewPager;
+
+import com.google.android.material.tabs.TabLayout;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragmentActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public abstract class AbstractChartsActivity extends AbstractGBFragmentActivity implements ChartsHost {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractChartsActivity.class);
+
+ public static final String STATE_START_DATE = "stateStartDate";
+ public static final String STATE_END_DATE = "stateEndDate";
+
+ public static final String EXTRA_FRAGMENT_ID = "fragmentId";
+ public static final String EXTRA_SINGLE_FRAGMENT_NAME = "singleFragmentName";
+ public static final String EXTRA_ACTIONBAR_TITLE = "actionbarTitle";
+ public static final String EXTRA_TIMESTAMP = "timestamp";
+ public static final String EXTRA_MODE = "mode";
+
+ private TextView mDateControl;
+
+ private Date mStartDate;
+ private Date mEndDate;
+ private SwipeRefreshLayout swipeLayout;
+
+ List<String> enabledTabsList;
+
+ private GBDevice mGBDevice;
+ private ViewGroup dateBar;
+
+ private ActivityResultLauncher<Intent> chartsPreferencesLauncher;
+ private final ActivityResultCallback<ActivityResult> chartsPreferencesCallback = result -> {
+ recreate();
+ };
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ final String action = intent.getAction();
+ //noinspection SwitchStatementWithTooFewBranches
+ switch (Objects.requireNonNull(action)) {
+ case GBDevice.ACTION_DEVICE_CHANGED:
+ final GBDevice dev = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (dev != null) {
+ refreshBusyState(dev);
+ }
+ break;
+ }
+ }
+ };
+
+ private void refreshBusyState(final GBDevice dev) {
+ if (dev.isBusy()) {
+ swipeLayout.setRefreshing(true);
+ } else {
+ final boolean wasBusy = swipeLayout.isRefreshing();
+ swipeLayout.setRefreshing(false);
+ if (wasBusy) {
+ LocalBroadcastManager.getInstance(this).sendBroadcast(new Intent(REFRESH));
+ }
+ }
+ enableSwipeRefresh(true);
+ }
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_charts);
+
+ final Bundle extras = getIntent().getExtras();
+ if (extras == null) {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+
+ mGBDevice = extras.getParcelable(GBDevice.EXTRA_DEVICE);
+
+ chartsPreferencesLauncher = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ chartsPreferencesCallback
+ );
+
+ // Set start and end date
+ if (savedInstanceState != null) {
+ setEndDate(new Date(savedInstanceState.getLong(STATE_END_DATE, System.currentTimeMillis())));
+ } else if (extras.containsKey(EXTRA_TIMESTAMP)) {
+ final int endTimestamp = extras.getInt(EXTRA_TIMESTAMP, 0);
+ setEndDate(new Date(endTimestamp * 1000L));
+ } else {
+ setEndDate(new Date());
+ }
+ setStartDate(DateTimeUtils.shiftByDays(getEndDate(), -1));
+
+ final IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(GBDevice.ACTION_DEVICE_CHANGED);
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filterLocal);
+
+ // Open the specified fragment, if any, and setup single page view if specified
+ final int tabFragmentIdToOpen = extras.getInt(EXTRA_FRAGMENT_ID, -1);
+ final String singleFragmentName = extras.getString(EXTRA_SINGLE_FRAGMENT_NAME, null);
+ final int actionbarTitle = extras.getInt(EXTRA_ACTIONBAR_TITLE, 0);
+
+ if (tabFragmentIdToOpen >= 0 && singleFragmentName != null) {
+ throw new IllegalArgumentException("Must specify either fragment ID or single fragment name, not both");
+ }
+
+ if (singleFragmentName != null) {
+ enabledTabsList = Collections.singletonList(singleFragmentName);
+ } else {
+ enabledTabsList = fillChartsTabsList();
+ }
+
+ swipeLayout = findViewById(R.id.activity_swipe_layout);
+ swipeLayout.setOnRefreshListener(this::fetchRecordedData);
+ enableSwipeRefresh(true);
+
+ // Set up the ViewPager with the sections adapter.
+ final NonSwipeableViewPager viewPager = findViewById(R.id.charts_pager);
+ viewPager.setAdapter(getPagerAdapter());
+ if (tabFragmentIdToOpen > -1) {
+ viewPager.setCurrentItem(tabFragmentIdToOpen); // open the tab as specified in the intent
+ }
+
+ viewPager.setAllowSwipe(singleFragmentName == null && GBApplication.getPrefs().getBoolean("charts_allow_swipe", true));
+
+ if (singleFragmentName != null) {
+ final TabLayout tabLayout = findViewById(R.id.charts_pagerTabStrip);
+ tabLayout.setVisibility(TextView.GONE);
+ }
+
+ if (actionbarTitle != 0) {
+ final ActionBar actionBar = getSupportActionBar();
+
+ if (actionBar != null) {
+ actionBar.setTitle(actionbarTitle);
+ }
+ }
+
+ viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ enableSwipeRefresh(state == ViewPager.SCROLL_STATE_IDLE);
+ }
+ });
+
+ dateBar = findViewById(R.id.charts_date_bar);
+ mDateControl = findViewById(R.id.charts_text_date);
+ mDateControl.setOnClickListener(v -> {
+ String detailedDuration = formatDetailedDuration();
+ new ShowDurationDialog(detailedDuration, AbstractChartsActivity.this).show();
+ });
+
+ final Button mPrevButton = findViewById(R.id.charts_previous_day);
+ mPrevButton.setOnClickListener(v -> handleButtonClicked(DATE_PREV_DAY));
+ final Button mNextButton = findViewById(R.id.charts_next_day);
+ mNextButton.setOnClickListener(v -> handleButtonClicked(DATE_NEXT_DAY));
+
+ final Button mPrevWeekButton = findViewById(R.id.charts_previous_week);
+ mPrevWeekButton.setOnClickListener(v -> handleButtonClicked(DATE_PREV_WEEK));
+ final Button mNextWeekButton = findViewById(R.id.charts_next_week);
+ mNextWeekButton.setOnClickListener(v -> handleButtonClicked(DATE_NEXT_WEEK));
+
+ final Button mPrevMonthButton = findViewById(R.id.charts_previous_month);
+ mPrevMonthButton.setOnClickListener(v -> handleButtonClicked(DATE_PREV_MONTH));
+ final Button mNextMonthButton = findViewById(R.id.charts_next_month);
+ mNextMonthButton.setOnClickListener(v -> handleButtonClicked(DATE_NEXT_MONTH));
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull final Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ outState.putLong(STATE_END_DATE, getEndDate().getTime());
+ outState.putLong(STATE_START_DATE, getStartDate().getTime());
+ }
+
+ @Override
+ protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+
+ setEndDate(new Date(savedInstanceState.getLong(STATE_END_DATE, System.currentTimeMillis())));
+ setStartDate(new Date(savedInstanceState.getLong(STATE_START_DATE, DateTimeUtils.shiftByDays(getEndDate(), -1).getTime())));
+ }
+
+ protected abstract List<String> fillChartsTabsList();
+
+ private String formatDetailedDuration() {
+ final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault());
+ final String dateStringFrom = dateFormat.format(getStartDate());
+ final String dateStringTo = dateFormat.format(getEndDate());
+
+ return getString(R.string.sleep_activity_date_range, dateStringFrom, dateStringTo);
+ }
+
+ protected void initDates() {
+ setEndDate(new Date());
+ setStartDate(DateTimeUtils.shiftByDays(getEndDate(), -1));
+ }
+
+ @Override
+ public GBDevice getDevice() {
+ return mGBDevice;
+ }
+
+ @Override
+ public void setStartDate(Date startDate) {
+ mStartDate = startDate;
+ }
+
+ @Override
+ public void setEndDate(Date endDate) {
+ mEndDate = endDate;
+ }
+
+ @Override
+ public Date getStartDate() {
+ return mStartDate;
+ }
+
+ @Override
+ public Date getEndDate() {
+ return mEndDate;
+ }
+
+ @Override
+ public void setDateInfo(final String dateInfo) {
+ mDateControl.setText(dateInfo);
+ }
+
+ @Override
+ public ViewGroup getDateBar() {
+ return dateBar;
+ }
+
+ private void handleButtonClicked(final String action) {
+ LocalBroadcastManager.getInstance(this).sendBroadcast(new Intent(action));
+ }
+
+ @Override
+ protected void onDestroy() {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
+ super.onDestroy();
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ getMenuInflater().inflate(R.menu.menu_charts, menu);
+
+ if (!mGBDevice.isConnected() || !supportsRefresh()) {
+ menu.removeItem(R.id.charts_fetch_activity_data);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == R.id.charts_fetch_activity_data) {
+ fetchRecordedData();
+ return true;
+ } else if (itemId == R.id.charts_set_date) {
+ final Calendar currentDate = Calendar.getInstance();
+ currentDate.setTime(getEndDate());
+ new DatePickerDialog(this, (view, year, monthOfYear, dayOfMonth) -> {
+ currentDate.set(year, monthOfYear, dayOfMonth);
+ setEndDate(currentDate.getTime());
+ setStartDate(DateTimeUtils.shiftByDays(getEndDate(), -1));
+ LocalBroadcastManager.getInstance(this).sendBroadcast(new Intent(REFRESH));
+ }, currentDate.get(Calendar.YEAR), currentDate.get(Calendar.MONTH), currentDate.get(Calendar.DATE)).show();
+ } else if (itemId == R.id.prefs_charts_menu) {
+ final Intent settingsIntent = new Intent(this, ChartsPreferencesActivity.class);
+ chartsPreferencesLauncher.launch(settingsIntent);
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public void enableSwipeRefresh(final boolean enable) {
+ swipeLayout.setEnabled(enable && allowRefresh());
+ }
+
+ protected abstract boolean supportsRefresh();
+
+ protected abstract boolean allowRefresh();
+
+ protected abstract int getRecordedDataType();
+
+ private void fetchRecordedData() {
+ if (getDevice().isInitialized()) {
+ GBApplication.deviceService(getDevice()).onFetchRecordedData(getRecordedDataType());
+ } else {
+ swipeLayout.setRefreshing(false);
+ GB.toast(this, getString(R.string.device_not_connected), Toast.LENGTH_SHORT, GB.ERROR);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractCollectionFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractCollectionFragment.java
new file mode 100644
index 0000000..aa61fe7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractCollectionFragment.java
@@ -0,0 +1,120 @@
+/* Copyright (C) 2024 a0z, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentManager;
+import androidx.viewpager2.widget.ViewPager2;
+
+import com.google.android.material.tabs.TabLayout;
+import com.google.android.material.tabs.TabLayoutMediator;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
+import nodomain.freeyourgadget.gadgetbridge.adapter.NestedFragmentAdapter;
+
+public abstract class AbstractCollectionFragment extends AbstractGBFragment {
+ protected static final String ARG_ALLOW_SWIPE = "allow_swipe";
+
+ protected NestedFragmentAdapter nestedFragmentsAdapter;
+ protected ViewPager2 viewPager;
+ private int last_position = 0;
+ private boolean allowSwipe;
+
+ public abstract NestedFragmentAdapter getNestedFragmentAdapter(final AbstractGBFragment fragment,
+ final FragmentManager childFragmentManager);
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (getArguments() != null) {
+ allowSwipe = getArguments().getBoolean(ARG_ALLOW_SWIPE, false);
+ }
+ }
+
+ @Override
+ protected void onMadeVisibleInActivity() {
+ super.onMadeVisibleInActivity();
+ nestedFragmentsAdapter.updateFragments(last_position);
+ }
+
+ @Override
+ public void onMadeInvisibleInActivity() {
+ if (nestedFragmentsAdapter != null) {
+ nestedFragmentsAdapter.updateFragments(-1);
+ }
+ }
+
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_nested_tabs, container, false);
+ nestedFragmentsAdapter = getNestedFragmentAdapter(this, getChildFragmentManager());
+ viewPager = rootView.findViewById(R.id.pager);
+ viewPager.setAdapter(nestedFragmentsAdapter);
+ if (!allowSwipe) {
+ viewPager.setOrientation(ViewPager2.ORIENTATION_VERTICAL);
+ viewPager.setUserInputEnabled(false);
+ }
+ viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
+ @Override
+ public void onPageSelected(int position) {
+ super.onPageSelected(position);
+ last_position = position;
+ viewPager.post(new Runnable() {
+ @Override
+ public void run() {
+ if (isVisibleInActivity()) {
+ nestedFragmentsAdapter.updateFragments(position);
+ }
+ }
+ });
+ }
+ });
+
+ return rootView;
+ }
+
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ TabLayout tabLayout = view.findViewById(R.id.tab_layout);
+ new TabLayoutMediator(tabLayout, viewPager, (tab, position) -> {
+ switch (position) {
+ case 0:
+ tab.setText(getString(R.string.calendar_day));
+ break;
+ case 1:
+ tab.setText(getString(R.string.calendar_week));
+ break;
+ case 2:
+ tab.setText(getString(R.string.calendar_month));
+ break;
+ }
+ }).attach();
+ }
+
+ @Nullable
+ @Override
+ protected CharSequence getTitle() {
+ return null;
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractWeekChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractWeekChartFragment.java
new file mode 100644
index 0000000..96f3eaf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractWeekChartFragment.java
@@ -0,0 +1,392 @@
+/* Copyright (C) 2017-2024 Alberto, Andreas Shimokawa, Carsten Pfeiffer,
+ Daniele Gobbetti, Jos茅 Rebelo, Pavel Elagin, Petr Van臎k, a0z
+
+ 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.activities.charts;
+
+import android.app.Activity;
+import android.graphics.Color;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.github.mikephil.charting.charts.BarChart;
+import com.github.mikephil.charting.components.LimitLine;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.BarData;
+import com.github.mikephil.charting.data.BarDataSet;
+import com.github.mikephil.charting.data.BarEntry;
+import com.github.mikephil.charting.data.ChartData;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmounts;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.SleepScoreSample;
+import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
+
+
+public abstract class AbstractWeekChartFragment extends AbstractActivityChartFragment<AbstractWeekChartFragment.MyChartsData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(AbstractWeekChartFragment.class);
+ protected int TOTAL_DAYS = getRangeDays();
+ protected int TOTAL_DAYS_FOR_AVERAGE = 0;
+
+ protected Locale mLocale;
+ protected int mTargetValue = 0;
+
+ protected BarChart mWeekChart;
+ protected TextView mBalanceView;
+
+ private int mOffsetHours = getOffsetHours();
+
+ protected String getWeeksChartsLabel(Calendar day){
+ if (TOTAL_DAYS > 7) {
+ //month, show day date
+ return String.valueOf(day.get(Calendar.DAY_OF_MONTH));
+ }
+ else{
+ //week, show short day name
+ return day.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, mLocale);
+ }
+ }
+ protected WeekChartsData<BarData> refreshWeekBeforeData(DBHandler db, BarChart barChart, Calendar day, GBDevice device) {
+ day = (Calendar) day.clone(); // do not modify the caller's argument
+ day.add(Calendar.DATE, -TOTAL_DAYS + 1);
+ List<BarEntry> entries = new ArrayList<>();
+ ArrayList<String> labels = new ArrayList<String>();
+
+ long balance = 0;
+ long daily_balance = 0;
+ TOTAL_DAYS_FOR_AVERAGE=0;
+ List<Entry> sleepScoreEntities = new ArrayList<>();
+ final List<ILineDataSet> sleepScoreDataSets = new ArrayList<>();
+ for (int counter = 0; counter < TOTAL_DAYS; counter++) {
+ // Sleep stages
+ ActivityAmounts amounts = getActivityAmountsForDay(db, day, device);
+ daily_balance=calculateBalance(amounts);
+ if (daily_balance > 0) {
+ TOTAL_DAYS_FOR_AVERAGE++;
+ }
+ balance += daily_balance;
+ entries.add(new BarEntry(counter, getTotalsForActivityAmounts(amounts)));
+ labels.add(getWeeksChartsLabel(day));
+ // Sleep score
+ if (supportsSleepScore()) {
+ List<? extends SleepScoreSample> sleepScoreSamples = getSleepScoreSamples(db, device, day);
+ if (!sleepScoreSamples.isEmpty() && sleepScoreSamples.get(0).getSleepScore() > 0) {
+ sleepScoreEntities.add(new Entry(counter, sleepScoreSamples.get(0).getSleepScore()));
+ } else {
+ if (!sleepScoreEntities.isEmpty()) {
+ List<Entry> clone = new ArrayList<>(sleepScoreEntities.size());
+ clone.addAll(sleepScoreEntities);
+ sleepScoreDataSets.add(createSleepScoreDataSet(clone));
+ sleepScoreEntities.clear();
+ }
+ }
+ }
+ day.add(Calendar.DATE, 1);
+ }
+ if (!sleepScoreEntities.isEmpty()) {
+ sleepScoreDataSets.add(createSleepScoreDataSet(sleepScoreEntities));
+ }
+ final LineData sleepScoreLineData = new LineData(sleepScoreDataSets);
+ sleepScoreLineData.setHighlightEnabled(false);
+
+ BarDataSet set = new BarDataSet(entries, "");
+ set.setColors(getColors());
+ set.setValueFormatter(getBarValueFormatter());
+
+ BarData barData = new BarData(set);
+ barData.setValueTextColor(Color.GRAY); //prevent tearing other graph elements with the black text. Another approach would be to hide the values cmpletely with data.setDrawValues(false);
+ barData.setValueTextSize(10f);
+
+ barChart.getAxisLeft().setAxisMaximum(Math.max(set.getYMax(), mTargetValue) + 60);
+
+ LimitLine target = new LimitLine(mTargetValue);
+ target.setLineWidth(1.5f);
+ target.enableDashedLine(15f, 10f, 0f);
+ target.setLineColor(getResources().getColor(R.color.chart_deep_sleep_dark));
+ barChart.getAxisLeft().removeAllLimitLines();
+ barChart.getAxisLeft().addLimitLine(target);
+
+ float average = 0;
+ if (TOTAL_DAYS_FOR_AVERAGE > 0) {
+ average = Math.abs(balance / TOTAL_DAYS_FOR_AVERAGE);
+ }
+ LimitLine average_line = new LimitLine(average);
+ average_line.setLineWidth(1.5f);
+ average_line.enableDashedLine(15f, 10f, 0f);
+ average_line.setLabel(getString(R.string.average, getAverage(average)));
+
+ if (average > (mTargetValue)) {
+ average_line.setLineColor(Color.GREEN);
+ average_line.setTextColor(Color.GREEN);
+ }
+ else {
+ average_line.setLineColor(Color.RED);
+ average_line.setTextColor(Color.RED);
+ }
+ if (average > 0) {
+ if (GBApplication.getPrefs().getBoolean("charts_show_average", true)) {
+ barChart.getAxisLeft().addLimitLine(average_line);
+ }
+ }
+
+ if (supportsSleepScore()) {
+ return new WeekChartsData(barData, new PreformattedXIndexLabelFormatter(labels), getBalanceMessage(balance, mTargetValue), sleepScoreLineData);
+ }
+ return new WeekChartsData(barData, new PreformattedXIndexLabelFormatter(labels), getBalanceMessage(balance, mTargetValue));
+ }
+
+ protected List<SleepScoreSample> getSleepScoreSamples(DBHandler db, GBDevice device, Calendar day) {
+ int startTs;
+ int endTs;
+
+ day = (Calendar) day.clone(); // do not modify the caller's argument
+ day.set(Calendar.HOUR_OF_DAY, 0);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ day.add(Calendar.HOUR, 0);
+ startTs = (int) (day.getTimeInMillis() / 1000);
+ endTs = startTs + 24 * 60 * 60 - 1;
+
+ TimeSampleProvider<? extends SleepScoreSample> provider = device.getDeviceCoordinator().getSleepScoreProvider(device, db.getDaoSession());
+ return (List<SleepScoreSample>) provider.getAllSamples(startTs * 1000L, endTs * 1000L);
+ }
+
+ protected LineDataSet createSleepScoreDataSet(final List<Entry> values) {
+ final LineDataSet lineDataSet = new LineDataSet(values, getString(R.string.sleep_score));
+ lineDataSet.setColor(getResources().getColor(R.color.chart_light_sleep_light));
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setLineWidth(2f);
+ lineDataSet.setFillAlpha(255);
+ lineDataSet.setCircleRadius(5f);
+ lineDataSet.setDrawCircles(true);
+ lineDataSet.setDrawCircleHole(true);
+ lineDataSet.setCircleColor(getResources().getColor(R.color.chart_light_sleep_light));
+ lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
+ lineDataSet.setDrawValues(true);
+ lineDataSet.setValueTextSize(10f);
+ lineDataSet.setValueTextColor(CHART_TEXT_COLOR);
+ lineDataSet.setValueFormatter(new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ return String.format(Locale.ROOT, "%d", (int) value);
+ }
+ });
+ return lineDataSet;
+ };
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ mLocale = getResources().getConfiguration().locale;
+
+ View rootView = inflater.inflate(R.layout.fragment_weeksteps_chart, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ final int goal = getGoal();
+ if (goal >= 0) {
+ mTargetValue = goal;
+ }
+
+ mWeekChart = rootView.findViewById(R.id.weekstepschart);
+ mBalanceView = rootView.findViewById(R.id.balance);
+
+ setupWeekChart();
+
+ // refresh immediately instead of use refreshIfVisible(), for perceived performance
+ refresh();
+
+ return rootView;
+ }
+
+ protected void setupWeekChart() {
+ mWeekChart.setBackgroundColor(BACKGROUND_COLOR);
+ mWeekChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ mWeekChart.getDescription().setText("");
+ mWeekChart.setFitBars(true);
+
+ configureBarLineChartDefaults(mWeekChart);
+
+ XAxis x = mWeekChart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setDrawLimitLinesBehindData(true);
+ x.setPosition(XAxis.XAxisPosition.BOTTOM);
+
+ YAxis y = mWeekChart.getAxisLeft();
+ y.setDrawGridLines(false);
+ y.setDrawTopYLabelEntry(false);
+ y.setTextColor(CHART_TEXT_COLOR);
+ y.setDrawZeroLine(true);
+ y.setSpaceBottom(0);
+ y.setAxisMinimum(0);
+ y.setValueFormatter(getYAxisFormatter());
+ y.setEnabled(true);
+
+ YAxis yAxisRight = mWeekChart.getAxisRight();
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setEnabled(false);
+ yAxisRight.setDrawLabels(false);
+ yAxisRight.setDrawTopYLabelEntry(false);
+ yAxisRight.setTextColor(CHART_TEXT_COLOR);
+ }
+
+ private List<? extends ActivitySample> getSamplesOfDay(DBHandler db, Calendar day, int offsetHours, GBDevice device) {
+ int startTs;
+ int endTs;
+
+ day = (Calendar) day.clone(); // do not modify the caller's argument
+ day.set(Calendar.HOUR_OF_DAY, 0);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ day.add(Calendar.HOUR, offsetHours);
+
+ startTs = (int) (day.getTimeInMillis() / 1000);
+ endTs = startTs + 24 * 60 * 60 - 1;
+
+ return getSamples(db, device, startTs, endTs);
+ }
+
+ @Override
+ protected List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ return super.getAllSamples(db, device, tsFrom, tsTo);
+ }
+
+ protected static class MyChartsData extends ChartsData {
+ private final WeekChartsData<BarData> weekBeforeData;
+
+ MyChartsData(WeekChartsData<BarData> weekBeforeData) {
+ this.weekBeforeData = weekBeforeData;
+ }
+
+ WeekChartsData<BarData> getWeekBeforeData() {
+ return weekBeforeData;
+ }
+ }
+
+ protected ActivityAmounts getActivityAmountsForDay(DBHandler db, Calendar day, GBDevice device) {
+
+ LimitedQueue<Integer, ActivityAmounts> activityAmountCache = null;
+ ActivityAmounts amounts = null;
+
+ Activity activity = getActivity();
+ int key = (int) (day.getTimeInMillis() / 1000) + (mOffsetHours * 3600);
+ if (activity != null) {
+ activityAmountCache = ((ActivityChartsActivity) activity).mActivityAmountCache;
+ amounts = activityAmountCache.lookup(key);
+ }
+
+ if (amounts == null) {
+ ActivityAnalysis analysis = new ActivityAnalysis();
+ amounts = analysis.calculateActivityAmounts(getSamplesOfDay(db, day, mOffsetHours, device));
+ if (activityAmountCache != null) {
+ activityAmountCache.add(key, amounts);
+ }
+ }
+
+ return amounts;
+ }
+
+ private int getRangeDays(){
+ if (GBApplication.getPrefs().getBoolean("charts_range", true)) {
+ return 30;}
+ else{
+ return 7;
+ }
+ }
+
+ public boolean supportsSleepScore() {
+ final GBDevice device = getChartsHost().getDevice();
+ return device.getDeviceCoordinator().supportsSleepScore();
+ }
+
+ abstract String getAverage(float value);
+
+ abstract int getGoal();
+
+ abstract int getOffsetHours();
+
+ abstract float[] getTotalsForActivityAmounts(ActivityAmounts activityAmounts);
+
+ abstract String formatPieValue(long value);
+
+ abstract String[] getPieLabels();
+
+ abstract ValueFormatter getPieValueFormatter();
+
+ abstract ValueFormatter getBarValueFormatter();
+
+ abstract ValueFormatter getYAxisFormatter();
+
+ abstract int[] getColors();
+
+ abstract String getPieDescription(int targetValue);
+
+ protected abstract long calculateBalance(ActivityAmounts amounts);
+
+ protected abstract String getBalanceMessage(long balance, int targetValue);
+
+ protected class WeekChartsData<T extends ChartData<?>> extends DefaultChartsData<T> {
+ private final String balanceMessage;
+ private LineData sleepScoresLineData;
+
+ public WeekChartsData(T data, PreformattedXIndexLabelFormatter xIndexLabelFormatter, String balanceMessage) {
+ super(data, xIndexLabelFormatter);
+ this.balanceMessage = balanceMessage;
+ }
+
+ public WeekChartsData(T data, PreformattedXIndexLabelFormatter xIndexLabelFormatter, String balanceMessage, LineData sleepScores) {
+ super(data, xIndexLabelFormatter);
+ this.balanceMessage = balanceMessage;
+ this.sleepScoresLineData = sleepScores;
+ }
+
+ public String getBalanceMessage() {
+ return balanceMessage;
+ }
+
+ public LineData getSleepScoreData() { return sleepScoresLineData; }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityAnalysis.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityAnalysis.java
new file mode 100644
index 0000000..a8798f1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityAnalysis.java
@@ -0,0 +1,157 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Jos茅 Rebelo, Pavel Elagin, Petr Van臎k, Vebryn
+
+ 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.activities.charts;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmount;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmounts;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+
+public class ActivityAnalysis {
+ public static final Logger LOG = LoggerFactory.getLogger(ActivityAnalysis.class);
+
+ // store raw steps and duration
+ protected HashMap<Integer, Long> stats = new HashMap<Integer, Long>();
+ // max speed determined from samples
+ private int maxSpeed = 0;
+
+ public ActivityAmounts calculateActivityAmounts(List<? extends ActivitySample> samples) {
+ ActivityAmount deepSleep = new ActivityAmount(ActivityKind.DEEP_SLEEP);
+ ActivityAmount lightSleep = new ActivityAmount(ActivityKind.LIGHT_SLEEP);
+ ActivityAmount remSleep = new ActivityAmount(ActivityKind.REM_SLEEP);
+ ActivityAmount awakeSleep = new ActivityAmount(ActivityKind.AWAKE_SLEEP);
+ ActivityAmount notWorn = new ActivityAmount(ActivityKind.NOT_WORN);
+ ActivityAmount activity = new ActivityAmount(ActivityKind.ACTIVITY);
+
+ ActivityAmount previousAmount = null;
+ ActivitySample previousSample = null;
+ for (ActivitySample sample : samples) {
+ ActivityAmount amount;
+ switch (sample.getKind()) {
+ case DEEP_SLEEP:
+ amount = deepSleep;
+ break;
+ case LIGHT_SLEEP:
+ amount = lightSleep;
+ break;
+ case REM_SLEEP:
+ amount = remSleep;
+ break;
+ case AWAKE_SLEEP:
+ amount = awakeSleep;
+ break;
+ case NOT_WORN:
+ amount = notWorn;
+ break;
+ case ACTIVITY:
+ default:
+ amount = activity;
+ break;
+ }
+
+ int steps = sample.getSteps();
+ if (steps > 0) {
+ amount.addSteps(steps);
+ }
+
+ final int distance = sample.getDistanceCm();
+ if (distance >= 0) {
+ amount.addDistance(distance);
+ }
+
+ final int activeCalories = sample.getActiveCalories();
+ if (activeCalories > 0) {
+ amount.addActiveCalories(activeCalories);
+ }
+
+ if (previousSample != null) {
+ long timeDifference = sample.getTimestamp() - previousSample.getTimestamp();
+ if (previousSample.getRawKind() == sample.getRawKind()) {
+ amount.addSeconds(timeDifference);
+ } else {
+ long sharedTimeDifference = (long) (timeDifference / 2.0f);
+ previousAmount.addSeconds(sharedTimeDifference);
+ amount.addSeconds(sharedTimeDifference);
+ }
+
+ // add time
+ if (steps > 0 && sample.getKind() == ActivityKind.ACTIVITY) {
+ if (steps > maxSpeed) {
+ maxSpeed = steps;
+ }
+
+ if (!stats.containsKey(steps)) {
+// LOG.debug("Adding: " + steps);
+ stats.put(steps, timeDifference);
+ } else {
+ long time = stats.get(steps);
+// LOG.debug("Updating: " + steps + " " + timeDifference + time);
+ stats.put(steps, timeDifference + time);
+ }
+ }
+ }
+
+ amount.setStartDate(sample.getTimestamp());
+ amount.setEndDate(sample.getTimestamp());
+
+ previousAmount = amount;
+ previousSample = sample;
+ }
+
+ ActivityAmounts result = new ActivityAmounts();
+ if (deepSleep.getTotalSeconds() > 0) {
+ result.addAmount(deepSleep);
+ }
+ if (lightSleep.getTotalSeconds() > 0) {
+ result.addAmount(lightSleep);
+ }
+ if (remSleep.getTotalSeconds() > 0) {
+ result.addAmount(remSleep);
+ }
+ if (awakeSleep.getTotalSeconds() > 0) {
+ result.addAmount(awakeSleep);
+ }
+ if (activity.getTotalSeconds() > 0) {
+ result.addAmount(activity);
+ }
+ if (notWorn.getTotalSeconds() > 0) {
+ result.addAmount(notWorn);
+ }
+
+ result.calculatePercentages();
+
+ return result;
+ }
+
+ int calculateTotalSteps(List<? extends ActivitySample> samples) {
+ int totalSteps = 0;
+ for (ActivitySample sample : samples) {
+ int steps = sample.getSteps();
+ if (steps > 0) {
+ totalSteps += steps;
+ }
+ }
+ return totalSteps;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java
new file mode 100644
index 0000000..743ae91
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java
@@ -0,0 +1,277 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo, Martin.JM
+
+ 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.activities.charts;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentStatePagerAdapter;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractFragmentPagerAdapter;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmounts;
+import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
+import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class ActivityChartsActivity extends AbstractChartsActivity {
+ LimitedQueue<Integer, ActivityAmounts> mActivityAmountCache = new LimitedQueue<>(60);
+
+ @Override
+ protected AbstractFragmentPagerAdapter createFragmentPagerAdapter(final FragmentManager fragmentManager) {
+ return new SectionsPagerAdapter(fragmentManager);
+ }
+
+ @Override
+ protected int getRecordedDataType() {
+ return RecordedDataTypes.TYPE_ACTIVITY | RecordedDataTypes.TYPE_STRESS;
+ }
+
+ @Override
+ protected boolean supportsRefresh() {
+ final DeviceCoordinator coordinator = getDevice().getDeviceCoordinator();
+ return coordinator.supportsActivityDataFetching();
+ }
+
+ @Override
+ protected boolean allowRefresh() {
+ final DeviceCoordinator coordinator = getDevice().getDeviceCoordinator();
+ return coordinator.allowFetchActivityData(getDevice()) && supportsRefresh();
+ }
+
+ @Override
+ protected List<String> fillChartsTabsList() {
+ return fillChartsTabsList(getDevice(), this);
+ }
+
+ private static List<String> fillChartsTabsList(final GBDevice device, final Context context) {
+ final List<String> tabList;
+ final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
+ final String myTabs = prefs.getString(DeviceSettingsPreferenceConst.PREFS_DEVICE_CHARTS_TABS, null);
+
+ if (myTabs == null) {
+ //make list mutable to be able to remove items later
+ tabList = new ArrayList<>(Arrays.asList(context.getResources().getStringArray(R.array.pref_charts_tabs_items_default)));
+ } else {
+ tabList = new ArrayList<>(Arrays.asList(myTabs.split(",")));
+ }
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ if (!coordinator.supportsActivityTabs()) {
+ tabList.remove("activity");
+ tabList.remove("activitylist");
+ }
+ if (!coordinator.supportsSleepMeasurement()) {
+ tabList.remove("sleep");
+ }
+ if (!coordinator.supportsStressMeasurement()) {
+ tabList.remove("stress");
+ }
+ if (!coordinator.supportsPai()) {
+ tabList.remove("pai");
+ }
+ if (!coordinator.supportsSpo2(device)) {
+ tabList.remove("spo2");
+ }
+ if (!coordinator.supportsStepCounter()) {
+ tabList.remove("stepsweek");
+ }
+ if (!coordinator.supportsSpeedzones()) {
+ tabList.remove("speedzones");
+ }
+ if (!coordinator.supportsRealtimeData()) {
+ tabList.remove("livestats");
+ }
+ if (!coordinator.supportsTemperatureMeasurement()) {
+ tabList.remove("temperature");
+ }
+ if (!coordinator.supportsCyclingData()) {
+ tabList.remove("cycling");
+ }
+ if (!coordinator.supportsWeightMeasurement()) {
+ tabList.remove("weight");
+ }
+ if (!coordinator.supportsHrvMeasurement()) {
+ tabList.remove("hrvstatus");
+ }
+ if (!coordinator.supportsHeartRateMeasurement(device)) {
+ tabList.remove("heartrate");
+ }
+ if (!coordinator.supportsBodyEnergy()) {
+ tabList.remove("bodyenergy");
+ }
+ if (!coordinator.supportsVO2Max()) {
+ tabList.remove("vo2max");
+ }
+ if (!coordinator.supportsActiveCalories()) {
+ tabList.remove("calories");
+ }
+ if (!coordinator.supportsRespiratoryRate()) {
+ tabList.remove("respiratoryrate");
+ }
+ return tabList;
+ }
+
+ public static int getChartsTabIndex(final String tab, final GBDevice device, final Context context) {
+ final List<String> enabledTabsList = fillChartsTabsList(device, context);
+ return enabledTabsList.indexOf(tab);
+ }
+
+ /**
+ * A {@link FragmentStatePagerAdapter} that returns a fragment corresponding to
+ * one of the sections/tabs/pages.
+ */
+ private class SectionsPagerAdapter extends AbstractFragmentPagerAdapter {
+ SectionsPagerAdapter(FragmentManager fm) {
+ super(fm);
+ }
+
+ @NonNull
+ @Override
+ public Fragment getItem(int position) {
+ final DeviceCoordinator coordinator = getDevice().getDeviceCoordinator();
+ // getItem is called to instantiate the fragment for the given page.
+ switch (enabledTabsList.get(position)) {
+ case "activity":
+ return new ActivitySleepChartFragment();
+ case "activitylist":
+ return new ActivityListingChartFragment();
+ case "sleep":
+ return SleepCollectionFragment.newInstance(enabledTabsList.size() == 1);
+ case "heartrate":
+ return new HeartRateDailyFragment();
+ case "hrvstatus":
+ return new HRVStatusFragment();
+ case "bodyenergy":
+ return new BodyEnergyFragment();
+ case "vo2max":
+ return new VO2MaxFragment();
+ case "stress":
+ return new StressChartFragment();
+ case "pai":
+ return new PaiChartFragment();
+ case "stepsweek":
+ return StepsCollectionFragment.newInstance(enabledTabsList.size() == 1);
+ case "speedzones":
+ return new SpeedZonesFragment();
+ case "livestats":
+ return new LiveActivityFragment();
+ case "spo2":
+ return new Spo2ChartFragment();
+ case "temperature":
+ return coordinator.supportsContinuousTemperature()? new TemperatureDailyFragment(): new TemperatureChartFragment();
+ case "cycling":
+ return new CyclingChartFragment();
+ case "weight":
+ return new WeightChartFragment();
+ case "calories":
+ Intent intent = getIntent();
+ String mode = intent.getStringExtra(ActivityChartsActivity.EXTRA_MODE);
+ return CaloriesDailyFragment.newInstance(mode);
+ case "respiratoryrate":
+ return RespiratoryRateCollectionFragment.newInstance(enabledTabsList.size() == 1);
+ }
+
+ return new UnknownFragment();
+ }
+
+ @Override
+ public int getCount() {
+ return enabledTabsList.toArray().length;
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ switch (enabledTabsList.get(position)) {
+ case "activity":
+ return getString(R.string.activity_sleepchart_activity_and_sleep);
+ case "activitylist":
+ return getString(R.string.charts_activity_list);
+ case "sleep":
+ return getString(R.string.sleepchart_your_sleep);
+ case "heartrate":
+ return getString(R.string.menuitem_hr);
+ case "hrvstatus":
+ return getString(R.string.pref_header_hrv_status);
+ case "bodyenergy":
+ return getString(R.string.body_energy);
+ case "vo2max":
+ return getString(R.string.menuitem_vo2_max);
+ case "stress":
+ return getString(R.string.menuitem_stress);
+ case "pai":
+ return getString(getDevice().getDeviceCoordinator().getPaiName());
+ case "stepsweek":
+ return getString(R.string.steps);
+ case "speedzones":
+ return getString(R.string.stats_title);
+ case "livestats":
+ return getString(R.string.liveactivity_live_activity);
+ case "spo2":
+ return getString(R.string.pref_header_spo2);
+ case "temperature":
+ return getString(R.string.menuitem_temperature);
+ case "cycling":
+ return getString(R.string.title_cycling);
+ case "weight":
+ return getString(R.string.menuitem_weight);
+ case "calories":
+ return getString(R.string.calories);
+ case "respiratoryrate":
+ return getString(R.string.respiratoryrate);
+ }
+
+ return String.format(Locale.getDefault(), "Unknown %d", position);
+ }
+ }
+
+ /**
+ * A dummy fragment to avoid a crash when we get a unknown tab position (eg. broken
+ * preference migration).
+ */
+ public static class UnknownFragment extends AbstractGBFragment {
+ @Override
+ public View onCreateView(@NonNull final LayoutInflater inflater,
+ final ViewGroup container,
+ final Bundle savedInstanceState) {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ protected CharSequence getTitle() {
+ return "Unknown";
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingAdapter.java
new file mode 100644
index 0000000..a83753d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingAdapter.java
@@ -0,0 +1,286 @@
+/* Copyright (C) 2020-2024 Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities.charts;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.content.res.AppCompatResources;
+
+import com.github.mikephil.charting.animation.Easing;
+import com.github.mikephil.charting.charts.PieChart;
+import com.github.mikephil.charting.data.PieData;
+import com.github.mikephil.charting.data.PieDataSet;
+import com.github.mikephil.charting.data.PieEntry;
+import com.github.mikephil.charting.utils.MPPointF;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.concurrent.TimeUnit;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.adapter.AbstractActivityListingAdapter;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityListItem;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySession;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FormatUtils;
+
+public class ActivityListingAdapter extends AbstractActivityListingAdapter<ActivitySession> {
+ public static final String CHART_COLOR_START = "#e74c3c";
+ public static final String CHART_COLOR_END = "#2ecc71";
+ protected static final Logger LOG = LoggerFactory.getLogger(ActivityListingAdapter.class);
+ protected final int ANIM_TIME = 250;
+ ActivityUser activityUser = new ActivityUser();
+ int stepsGoal = activityUser.getStepsGoal();
+ int distanceGoalMeters = activityUser.getDistanceGoalMeters();
+ long activeTimeGoalTimeMillis = activityUser.getActiveTimeGoalMinutes() * 60 * 1000L;
+
+ public ActivityListingAdapter(Context context) {
+ super(context);
+ }
+
+ @NonNull
+ @Override
+ public AbstractActivityListingViewHolder<ActivitySession> onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
+ switch (viewType) {
+ case 0: // dashboard
+ return new DashboardViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.activity_list_dashboard_item, parent, false));
+ case 2: // item
+ return new ActivityItemViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.activity_list_item, parent, false));
+ }
+
+ return super.onCreateViewHolder(parent, viewType);
+ }
+
+ private void setChartsData(PieChart pieChart, float value, float target, String label, Context context) {
+ ArrayList<PieEntry> entries = new ArrayList<>();
+ entries.add(new PieEntry(value, AppCompatResources.getDrawable(context, R.drawable.ic_star_gold)));
+
+ Easing.EasingFunction animationEffect = Easing.EaseInOutSine;
+
+ if (value < target) {
+ entries.add(new PieEntry(target - value));
+ }
+
+ pieChart.setCenterText(String.format("%d%%\n%s", (int) (value * 100 / target), label));
+ float colorValue = Math.max(0, Math.min(1, value / target));
+ int chartColor = interpolateColor(Color.parseColor(CHART_COLOR_START), Color.parseColor(CHART_COLOR_END), colorValue);
+
+ PieDataSet dataSet = new PieDataSet(entries, "");
+ dataSet.setDrawIcons(false);
+ dataSet.setIconsOffset(new MPPointF(0, -66));
+
+ if (colorValue == 1) {
+ dataSet.setDrawIcons(true);
+ }
+ dataSet.setSliceSpace(0f);
+ dataSet.setSelectionShift(5f);
+ dataSet.setColors(chartColor, Color.LTGRAY);
+
+ PieData data = new PieData(dataSet);
+ data.setValueTextSize(0f);
+ data.setValueTextColor(Color.WHITE);
+
+ pieChart.setData(data);
+ pieChart.invalidate();
+ pieChart.animateY(ANIM_TIME, animationEffect);
+ }
+
+ private float interpolate(float a, float b, float proportion) {
+ return (a + ((b - a) * proportion));
+ }
+
+ private int interpolateColor(int a, int b, float proportion) {
+ float[] hsva = new float[3];
+ float[] hsvb = new float[3];
+ Color.colorToHSV(a, hsva);
+ Color.colorToHSV(b, hsvb);
+ for (int i = 0; i < 3; i++) {
+ hsvb[i] = interpolate(hsva[i], hsvb[i], proportion);
+ }
+ return Color.HSVToColor(hsvb);
+ }
+
+ public static class ActivityItemViewHolder extends AbstractActivityListingViewHolder<ActivitySession> {
+ final View rootView;
+ final ActivityListItem activityListItem;
+
+ public ActivityItemViewHolder(@NonNull final View itemView) {
+ super(itemView);
+ this.rootView = itemView;
+ this.activityListItem = new ActivityListItem(itemView);
+ }
+
+ @Override
+ public void fill(final int position, final ActivitySession session, final boolean selected) {
+ this.activityListItem.update(
+ session.getStartTime(),
+ session.getEndTime(),
+ session.getActivityKind(),
+ null,
+ session.getActiveSteps(),
+ session.getDistance(),
+ session.getHeartRateAverage(),
+ session.getIntensity(),
+ session.getEndTime().getTime() - session.getStartTime().getTime(),
+ false,
+ null,
+ position % 2 == 1,
+ selected
+ );
+ }
+ }
+
+ public class DashboardViewHolder extends AbstractActivityListingViewHolder<ActivitySession> {
+ final PieChart ActiveStepsChart;
+ final PieChart DistanceChart;
+ final PieChart ActiveTimeChart;
+ final TextView stepLabel;
+ final TextView stepTotalLabel;
+ final TextView distanceLabel;
+ final TextView hrLabel;
+ final TextView intensityLabel;
+ final TextView intensity2Label;
+ final TextView durationLabel;
+ final TextView sessionCountLabel;
+ final LinearLayout durationLayout;
+ final LinearLayout countLayout;
+ final View hrLayout;
+ final LinearLayout stepsLayout;
+ final LinearLayout stepsTotalLayout;
+ final LinearLayout distanceLayout;
+ final View intensityLayout;
+ final View intensity2Layout;
+
+ public DashboardViewHolder(@NonNull final View itemView) {
+ super(itemView);
+
+ ActiveStepsChart = itemView.findViewById(R.id.activity_dashboard_piechart1);
+ DistanceChart = itemView.findViewById(R.id.activity_dashboard_piechart2);
+ ActiveTimeChart = itemView.findViewById(R.id.activity_dashboard_piechart3);
+ stepLabel = itemView.findViewById(R.id.line_layout_step_label);
+ stepTotalLabel = itemView.findViewById(R.id.line_layout_total_step_label);
+ distanceLabel = itemView.findViewById(R.id.line_layout_distance_label);
+ hrLabel = itemView.findViewById(R.id.heartrate_widget_label);
+ intensityLabel = itemView.findViewById(R.id.intensity_widget_label);
+ intensity2Label = itemView.findViewById(R.id.line_layout_intensity2_label);
+ durationLabel = itemView.findViewById(R.id.line_layout_duration_label);
+ sessionCountLabel = itemView.findViewById(R.id.line_layout_count_label);
+ durationLayout = itemView.findViewById(R.id.line_layout_duration);
+ countLayout = itemView.findViewById(R.id.line_layout_count);
+ hrLayout = itemView.findViewById(R.id.heartrate_widget_icon);
+ stepsLayout = itemView.findViewById(R.id.line_layout_step);
+ stepsTotalLayout = itemView.findViewById(R.id.line_layout_total_step);
+ distanceLayout = itemView.findViewById(R.id.line_layout_distance);
+ intensityLayout = itemView.findViewById(R.id.intensity_widget_icon);
+ intensity2Layout = itemView.findViewById(R.id.line_layout_intensity2);
+ }
+
+ @Override
+ public void fill(final int position, final ActivitySession session, final boolean selected) {
+ setUpChart(ActiveStepsChart);
+ int steps = session.getActiveSteps();
+ setChartsData(ActiveStepsChart, steps, stepsGoal, getContext().getString(R.string.activity_list_summary_active_steps), getContext());
+
+ setUpChart(DistanceChart);
+ float distance = session.getDistance();
+ setChartsData(DistanceChart, distance, distanceGoalMeters, getContext().getString(R.string.distance), getContext());
+
+ setUpChart(ActiveTimeChart);
+ long duration = session.getEndTime().getTime() - session.getStartTime().getTime();
+ setChartsData(ActiveTimeChart, duration, activeTimeGoalTimeMillis, getContext().getString(R.string.activity_list_summary_active_time), getContext());
+
+ durationLabel.setText(DateTimeUtils.formatDurationHoursMinutes(duration, TimeUnit.MILLISECONDS));
+ sessionCountLabel.setText(String.valueOf(session.getSessionCount()));
+
+ if (session.getHeartRateAverage() > 0) {
+ hrLabel.setText(String.valueOf(session.getHeartRateAverage()));
+ hrLayout.setVisibility(View.VISIBLE);
+ hrLabel.setVisibility(View.VISIBLE);
+ } else {
+ hrLayout.setVisibility(View.GONE);
+ hrLabel.setVisibility(View.GONE);
+ }
+
+ if (session.getIntensity() > 0) {
+ final DecimalFormat df = new DecimalFormat("###");
+ intensityLabel.setText(df.format(session.getIntensity()));
+ intensity2Label.setText(df.format(session.getIntensity()));
+ intensityLayout.setVisibility(View.VISIBLE);
+ intensity2Layout.setVisibility(View.VISIBLE);
+ intensityLabel.setVisibility(View.VISIBLE);
+ intensity2Label.setVisibility(View.VISIBLE);
+ } else {
+ intensityLayout.setVisibility(View.GONE);
+ intensity2Layout.setVisibility(View.GONE);
+ intensityLabel.setVisibility(View.GONE);
+ intensity2Label.setVisibility(View.GONE);
+ }
+
+ if (session.getDistance() > 0) {
+ distanceLabel.setText(FormatUtils.getFormattedDistanceLabel(session.getDistance()));
+ distanceLayout.setVisibility(View.VISIBLE);
+ } else {
+ distanceLayout.setVisibility(View.GONE);
+ }
+
+ if (session.getActiveSteps() > 0) {
+ stepLabel.setText(String.valueOf(session.getActiveSteps()));
+ stepsLayout.setVisibility(View.VISIBLE);
+ } else {
+ stepsLayout.setVisibility(View.GONE);
+ }
+
+ if (session.getTotalDaySteps() > 0) {
+ stepTotalLabel.setText(String.valueOf(session.getTotalDaySteps()));
+ stepsTotalLayout.setVisibility(View.VISIBLE);
+ countLayout.setVisibility(View.VISIBLE);
+ durationLayout.setVisibility(View.VISIBLE);
+ } else {
+ stepsTotalLayout.setVisibility(View.GONE);
+ countLayout.setVisibility(View.GONE);
+ durationLayout.setVisibility(View.GONE);
+ }
+ }
+
+ private void setUpChart(PieChart DashboardChart) {
+ DashboardChart.setNoDataText("");
+ DashboardChart.getLegend().setEnabled(false);
+ DashboardChart.setDrawHoleEnabled(true);
+ DashboardChart.setHoleColor(Color.WHITE);
+ DashboardChart.getDescription().setText("");
+ DashboardChart.setTransparentCircleColor(Color.WHITE);
+ DashboardChart.setTransparentCircleAlpha(110);
+ DashboardChart.setHoleRadius(70f);
+ DashboardChart.setTransparentCircleRadius(75f);
+ DashboardChart.setDrawCenterText(true);
+ DashboardChart.setRotationEnabled(true);
+ DashboardChart.setHighlightPerTapEnabled(true);
+ DashboardChart.setCenterTextOffset(0, 0);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingChartFragment.java
new file mode 100644
index 0000000..a3a228a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingChartFragment.java
@@ -0,0 +1,244 @@
+/* Copyright (C) 2020-2024 Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities.charts;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.text.format.DateUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.fragment.app.FragmentManager;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+import com.google.android.material.snackbar.Snackbar;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySession;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FormatUtils;
+
+public class ActivityListingChartFragment extends AbstractActivityChartFragment<ActivityListingChartFragment.MyChartsData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(ActivityListingChartFragment.class);
+ int tsDateTo;
+
+ private View rootView;
+ private ActivityListingAdapter stepListAdapter;
+ private TextView stepsDateView;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ rootView = inflater.inflate(R.layout.fragment_steps_list, container, false);
+ getChartsHost().enableSwipeRefresh(false);
+ RecyclerView stepsList = rootView.findViewById(R.id.itemListView);
+ stepListAdapter = new ActivityListingAdapter(getContext());
+ stepsList.setAdapter(stepListAdapter);
+ stepsList.setLayoutManager(new LinearLayoutManager(requireContext()));
+
+ stepListAdapter.setOnItemClickListener(position -> {
+ ActivitySession item = stepListAdapter.getItem(position);
+ if (item.getSessionType() != ActivitySession.SESSION_SUMMARY) {
+ int tsFrom = (int) (item.getStartTime().getTime() / 1000);
+ int tsTo = (int) (item.getEndTime().getTime() / 1000);
+ showDetail(tsFrom, tsTo, item, getChartsHost().getDevice());
+ }
+ });
+
+ stepsDateView = rootView.findViewById(R.id.stepsDateView);
+ FloatingActionButton fab;
+ fab = rootView.findViewById(R.id.fab);
+ fab.setVisibility(View.VISIBLE);
+ fab.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ showDashboard(tsDateTo, getChartsHost().getDevice());
+ }
+ });
+
+ refresh();
+ return rootView;
+ }
+
+
+ @Override
+ public String getTitle() {
+ return "Steps list";
+ }
+
+ @Override
+ protected boolean isSingleDay() {
+ return true;
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (ChartsHost.REFRESH.equals(action)) {
+ // TODO: use LimitLines to visualize smart alarms?
+ refresh();
+ } else {
+ super.onReceive(context, intent);
+ }
+ }
+
+ @Override
+ protected MyChartsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ List<? extends ActivitySample> activitySamples;
+ activitySamples = getSamples(db, device);
+ List<ActivitySession> stepSessions = null;
+ ActivitySession ongoingSession = null;
+ StepAnalysis stepAnalysis = new StepAnalysis();
+ boolean isEmptySummary = false;
+
+ if (activitySamples != null) {
+ stepSessions = stepAnalysis.calculateStepSessions(activitySamples);
+ if (stepSessions.toArray().length == 0) {
+ isEmptySummary = true;
+ }
+ ActivitySession stepSessionsSummary = stepAnalysis.calculateSummary(stepSessions, isEmptySummary);
+ stepSessions.add(0, stepSessionsSummary);
+ ActivitySession emptySession = new ActivitySession();
+ emptySession.setSessionType(ActivitySession.SESSION_EMPTY);
+ stepSessions.add(emptySession); //this is to have an empty item at the end to be able to use FAB without it blocking anything
+ ongoingSession = stepAnalysis.getOngoingSessions(stepSessions);
+ }
+ return new MyChartsData(stepSessions, ongoingSession);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(MyChartsData mcd) {
+ if (mcd == null) {
+ return;
+ }
+ if (mcd.getStepSessions() == null) {
+ return;
+ }
+
+ //noinspection RedundantIfStatement
+ if (mcd.getStepSessions().toArray().length == 0) {
+ getChartsHost().enableSwipeRefresh(true); //enable pull to refresh, might be needed
+ } else {
+ getChartsHost().enableSwipeRefresh(false); //disable pull to refresh as it collides with swipable view
+ }
+
+ Date activityDate = new Date(tsDateTo * 1000L);
+ stepsDateView.setText(DateTimeUtils.formatDate(activityDate, DateUtils.FORMAT_SHOW_WEEKDAY));
+
+ if (GBApplication.getPrefs().getBoolean("charts_show_ongoing_activity", true)) {
+ if (mcd.getOngoingSession() != null && DateUtils.isToday(activityDate.getTime())) {
+ showOngoingActivitySnackbar(mcd.getOngoingSession());
+ }
+ }
+ stepListAdapter.setItems(mcd.getStepSessions(), true);
+ }
+
+ @Override
+ protected void renderCharts() {
+ }
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {
+ }
+
+ @Override
+ protected List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ Calendar day = Calendar.getInstance();
+ day.setTimeInMillis(tsTo * 1000L); //we need today initially, which is the end of the time range
+ day.set(Calendar.HOUR_OF_DAY, 0); //and we set time for the start and end of the same day
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ tsFrom = (int) (day.getTimeInMillis() / 1000);
+ tsTo = tsFrom + 24 * 60 * 60 - 1;
+ tsDateTo = tsTo;
+ return getAllSamples(db, device, tsFrom, tsTo);
+ }
+
+ private void showOngoingActivitySnackbar(ActivitySession ongoingSession) {
+
+ String distanceLabel = FormatUtils.getFormattedDistanceLabel(ongoingSession.getDistance());
+ String stepLabel = String.valueOf(ongoingSession.getActiveSteps());
+ String durationLabel = DateTimeUtils.formatDurationHoursMinutes(ongoingSession.getEndTime().getTime() - ongoingSession.getStartTime().getTime(), TimeUnit.MILLISECONDS);
+ String hrLabel = String.valueOf(ongoingSession.getHeartRateAverage());
+ String activityName = ongoingSession.getActivityKind().getLabel(requireContext());
+ int icon = ongoingSession.getActivityKind().getIcon();
+
+ String text = String.format("%s:\u00A0%s, %s:\u00A0%s, %s:\u00A0%s, %s:\u00A0%s", activityName, durationLabel, getString(R.string.heart_rate), hrLabel, getString(R.string.steps), stepLabel, getString(R.string.distance), distanceLabel);
+ final Snackbar snackbar = Snackbar.make(rootView, text, 1000 * 8);
+
+ View snackbarView = snackbar.getView();
+ snackbarView.setBackgroundColor(requireContext().getResources().getColor(R.color.accent));
+ snackbar.setActionTextColor(Color.WHITE);
+ snackbar.setAction(getString(R.string.dialog_hide).toUpperCase(), new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ snackbar.dismiss();
+ }
+ }
+ );
+ snackbar.show();
+ }
+
+ private void showDashboard(int date, GBDevice device) {
+ FragmentManager fm = requireActivity().getSupportFragmentManager();
+ ActivityListingDashboard listingDashboardFragment = ActivityListingDashboard.newInstance(date, device);
+ listingDashboardFragment.show(fm, "activity_list_total_dashboard");
+ }
+
+ private void showDetail(int tsFrom, int tsTo, ActivitySession item, GBDevice device) {
+ FragmentManager fm = requireActivity().getSupportFragmentManager();
+ ActivityListingDetail listingDetailFragment = ActivityListingDetail.newInstance(tsFrom, tsTo, item, device);
+ listingDetailFragment.show(fm, "activity_list_detail");
+ }
+
+ protected static final class MyChartsData extends ChartsData {
+ private final List<ActivitySession> stepSessions;
+ private final ActivitySession ongoingSession;
+
+ MyChartsData(List<ActivitySession> stepSessions, ActivitySession ongoingSession) {
+ this.stepSessions = stepSessions;
+ this.ongoingSession = ongoingSession;
+ }
+
+ public List<ActivitySession> getStepSessions() {
+ return stepSessions;
+ }
+
+ public ActivitySession getOngoingSession() {
+ return this.ongoingSession;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingDashboard.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingDashboard.java
new file mode 100644
index 0000000..7c4d6ac
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingDashboard.java
@@ -0,0 +1,329 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver, Daniel Dakhno, Petr Van臎k
+
+ 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.activities.charts;
+
+import android.app.DatePickerDialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.DatePicker;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySession;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FormatUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.dialogs.MaterialDialogFragment;
+
+public class ActivityListingDashboard extends MaterialDialogFragment {
+ protected static final Logger LOG = LoggerFactory.getLogger(ActivityListingDashboard.class);
+ GBDevice gbDevice;
+ ActivityListingAdapter stepListAdapter;
+ ActivitySession stepSessionsSummary;
+ private int timeFrom;
+ private int timeTo;
+
+ public ActivityListingDashboard() {
+
+ }
+
+ public static ActivityListingDashboard newInstance(int timestamp, GBDevice device) {
+
+ ActivityListingDashboard frag = new ActivityListingDashboard();
+
+ Bundle args = new Bundle();
+ args.putInt("time", timestamp);
+ args.putParcelable(GBDevice.EXTRA_DEVICE, device);
+ frag.setArguments(args);
+ return frag;
+
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+
+ Bundle savedInstanceState) {
+
+ return inflater.inflate(R.layout.activity_list_total_dashboard, container);
+ }
+
+ @Override
+
+ public void onViewCreated(@NonNull final View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ int time = getArguments().getInt("time", 1);
+ Calendar day = Calendar.getInstance();
+ day.setTimeInMillis(time * 1000L);
+ day.set(Calendar.HOUR_OF_DAY, 23);
+ day.set(Calendar.MINUTE, 59);
+ day.set(Calendar.SECOND, 59);
+ timeTo = (int) (day.getTimeInMillis() / 1000);
+
+
+ gbDevice = getArguments().getParcelable(GBDevice.EXTRA_DEVICE);
+ if (gbDevice == null) {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+ stepListAdapter = new ActivityListingAdapter(getContext());
+
+ final TextView battery_status_date_from_text = getView().findViewById(R.id.battery_status_date_from_text);
+ final TextView battery_status_date_to_text = getView().findViewById(R.id.battery_status_date_to_text);
+ LinearLayout battery_status_date_to_layout = getView().findViewById(R.id.battery_status_date_to_layout);
+ final SeekBar battery_status_time_span_seekbar = getView().findViewById(R.id.battery_status_time_span_seekbar);
+
+ boolean activity_list_debug_extra_time_range_value = GBApplication.getPrefs().getPreferences().getBoolean("activity_list_debug_extra_time_range", false);
+
+ if (!activity_list_debug_extra_time_range_value) {
+ battery_status_time_span_seekbar.setMax(3);
+ }
+ final TextView battery_status_time_span_text = (TextView) getView().findViewById(R.id.battery_status_time_span_text);
+
+ battery_status_time_span_seekbar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
+ String text;
+ switch (i) {
+ case 0:
+ text = getString(R.string.calendar_day);
+ timeFrom = DateTimeUtils.shiftDays(timeTo, -1);
+ break;
+ case 1:
+ text = getString(R.string.calendar_week);
+ timeFrom = DateTimeUtils.shiftDays(timeTo, -7);
+ break;
+ case 2:
+ text = getString(R.string.calendar_two_weeks);
+ timeFrom = DateTimeUtils.shiftDays(timeTo, -14);
+ break;
+ case 3:
+ text = getString(R.string.calendar_month);
+ timeFrom = DateTimeUtils.shiftMonths(timeTo, -1);
+ break;
+ case 4:
+ text = getString(R.string.calendar_three_months);
+ timeFrom = DateTimeUtils.shiftMonths(timeTo, -3);
+ break;
+ case 5:
+ text = getString(R.string.calendar_six_months);
+ timeFrom = DateTimeUtils.shiftMonths(timeTo, -6);
+ break;
+ case 6:
+ text = getString(R.string.calendar_year);
+ timeFrom = DateTimeUtils.shiftMonths(timeTo, -12);
+ break;
+ default:
+ text = getString(R.string.calendar_two_weeks);
+ timeFrom = DateTimeUtils.shiftDays(timeTo, -14);
+
+ }
+
+ battery_status_time_span_text.setText(text);
+ battery_status_date_from_text.setText(DateTimeUtils.formatDate(new Date(timeFrom * 1000L)));
+ battery_status_date_to_text.setText(DateTimeUtils.formatDate(new Date(timeTo * 1000L)));
+ createRefreshTask("Visualizing step sessions", getActivity()).execute();
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+
+ }
+ });
+ battery_status_date_to_layout.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ final Calendar currentDate = Calendar.getInstance();
+ currentDate.setTimeInMillis(timeTo * 1000L);
+
+ new DatePickerDialog(getContext(), new DatePickerDialog.OnDateSetListener() {
+ @Override
+ public void onDateSet(DatePicker view, int year, int monthOfYear, int dayOfMonth) {
+
+ Calendar date = Calendar.getInstance();
+ date.set(year, monthOfYear, dayOfMonth);
+ int time = (int) (date.getTimeInMillis() / 1000);
+ Calendar day = Calendar.getInstance();
+ day.setTimeInMillis(time * 1000L);
+ day.set(Calendar.HOUR_OF_DAY, 23);
+ day.set(Calendar.MINUTE, 59);
+ day.set(Calendar.SECOND, 59);
+ timeTo = (int) (day.getTimeInMillis() / 1000);
+
+ battery_status_date_to_text.setText(DateTimeUtils.formatDate(new Date(timeTo * 1000L)));
+ battery_status_time_span_seekbar.setProgress(0);
+ battery_status_time_span_seekbar.setProgress(1);
+ }
+ }, currentDate.get(Calendar.YEAR), currentDate.get(Calendar.MONTH), currentDate.get(Calendar.DATE)).show();
+ }
+ });
+ battery_status_time_span_seekbar.setProgress(2);
+ }
+
+ protected RefreshTask createRefreshTask(String task, Context context) {
+ return new RefreshTask(task, context);
+ }
+
+ private ActivitySession get_data(GBDevice gbDevice, DBHandler db, int timeFrom, int timeTo) {
+
+ List<ActivitySession> stepSessions;
+ List<? extends ActivitySample> activitySamples = getAllSamples(db, gbDevice, timeFrom, timeTo);
+ StepAnalysis stepAnalysis = new StepAnalysis();
+
+ boolean isEmptySummary = false;
+ if (activitySamples != null) {
+ stepSessions = stepAnalysis.calculateStepSessions(activitySamples);
+ if (stepSessions.toArray().length == 0) {
+ isEmptySummary = true;
+ }
+ stepSessionsSummary = stepAnalysis.calculateSummary(stepSessions, isEmptySummary);
+ }
+ return stepSessionsSummary;
+ }
+
+ SampleProvider<? extends AbstractActivitySample> getProvider(DBHandler db, GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.getSampleProvider(device, db.getDaoSession());
+ }
+
+ protected List<? extends ActivitySample> getAllSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ SampleProvider<? extends ActivitySample> provider = getProvider(db, device);
+ return provider.getAllActivitySamples(tsFrom, tsTo);
+ }
+
+ void indicate_progress(boolean inProgress) {
+ View view = getView();
+ if (view == null) {
+ return;
+ }
+ LinearLayout activity_list_dashboard_results_layout = view.findViewById(R.id.activity_list_dashboard_results_layout);
+ RelativeLayout activity_list_dashboard_loading_layout = view.findViewById(R.id.activity_list_dashboard_loading_layout);
+ if (inProgress) {
+ activity_list_dashboard_results_layout.setVisibility(View.GONE);
+ activity_list_dashboard_loading_layout.setVisibility(View.VISIBLE);
+ } else {
+ activity_list_dashboard_results_layout.setVisibility(View.VISIBLE);
+ activity_list_dashboard_loading_layout.setVisibility(View.GONE);
+ }
+ }
+
+ void populateData(ActivitySession item) {
+ View view = getView();
+ if (view == null) {
+ return;
+ }
+
+ TextView stepLabel = view.findViewById(R.id.line_layout_step_label);
+ TextView stepTotalLabel = view.findViewById(R.id.line_layout_total_step_label);
+ TextView distanceLabel = view.findViewById(R.id.line_layout_distance_label);
+ TextView durationLabel = view.findViewById(R.id.line_layout_duration_label);
+ TextView sessionCountLabel = view.findViewById(R.id.line_layout_count_label);
+ LinearLayout durationLayout = view.findViewById(R.id.line_layout_duration);
+ LinearLayout countLayout = view.findViewById(R.id.line_layout_count);
+ LinearLayout stepsLayout = view.findViewById(R.id.line_layout_step);
+ LinearLayout stepsTotalLayout = view.findViewById(R.id.line_layout_total_step);
+ LinearLayout distanceLayout = view.findViewById(R.id.line_layout_distance);
+
+ final long duration = item.getEndTime().getTime() - item.getStartTime().getTime();
+ durationLabel.setText(DateTimeUtils.formatDurationHoursMinutes(duration, TimeUnit.MILLISECONDS));
+ sessionCountLabel.setText(String.valueOf(item.getSessionCount()));
+
+ if (item.getDistance() > 0) {
+ distanceLabel.setText(FormatUtils.getFormattedDistanceLabel(item.getDistance()));
+ distanceLayout.setVisibility(View.VISIBLE);
+ } else {
+ distanceLayout.setVisibility(View.GONE);
+ }
+
+ if (item.getActiveSteps() > 0) {
+ stepLabel.setText(String.valueOf(item.getActiveSteps()));
+ stepsLayout.setVisibility(View.VISIBLE);
+ } else {
+ stepsLayout.setVisibility(View.GONE);
+ }
+
+ if (item.getTotalDaySteps() > 0) {
+ stepTotalLabel.setText(String.valueOf(item.getTotalDaySteps()));
+ stepsTotalLayout.setVisibility(View.VISIBLE);
+ countLayout.setVisibility(View.VISIBLE);
+ durationLayout.setVisibility(View.VISIBLE);
+ } else {
+ stepsTotalLayout.setVisibility(View.GONE);
+ countLayout.setVisibility(View.GONE);
+ durationLayout.setVisibility(View.GONE);
+ }
+ }
+
+ public class RefreshTask extends DBAccess {
+
+ public RefreshTask(String task, Context context) {
+ super(task, context);
+ }
+
+ @Override
+ protected void doInBackground(DBHandler db) {
+ stepSessionsSummary = get_data(gbDevice, db, timeFrom, timeTo);
+ }
+
+ @Override
+ protected void onPreExecute() {
+ indicate_progress(true);
+ }
+
+ @Override
+ protected void onPostExecute(Object o) {
+ super.onPostExecute(o);
+ FragmentActivity activity = getActivity();
+ if (activity != null && !activity.isFinishing() && !activity.isDestroyed()) {
+ populateData(stepSessionsSummary);
+ indicate_progress(false);
+ } else {
+ LOG.info("Not filling data because activity is not available anymore");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingDetail.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingDetail.java
new file mode 100644
index 0000000..3eaaae0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityListingDetail.java
@@ -0,0 +1,106 @@
+/* Copyright (C) 2021-2024 Petr Van臎k
+
+ 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.activities.charts;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.ActivitySummariesChartFragment;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityListItem;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySession;
+
+public class ActivityListingDetail extends DialogFragment {
+ protected static final Logger LOG = LoggerFactory.getLogger(ActivityListingDetail.class);
+
+ public ActivityListingDetail() {
+
+ }
+
+ public static ActivityListingDetail newInstance(int tsFrom, int tsTo, ActivitySession item, GBDevice device) {
+ ActivityListingDetail frag = new ActivityListingDetail();
+ Bundle args = new Bundle();
+ args.putInt("tsFrom", tsFrom);
+ args.putInt("tsTo", tsTo);
+ args.putSerializable("item", item);
+ args.putParcelable(GBDevice.EXTRA_DEVICE, device);
+ frag.setArguments(args);
+ return frag;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.activity_list_detail, container);
+ }
+
+ @Override
+ public void onViewCreated(final View view, @Nullable Bundle savedInstanceState) {
+
+ super.onViewCreated(view, savedInstanceState);
+
+ int tsFrom = getArguments().getInt("tsFrom");
+ int tsTo = getArguments().getInt("tsTo");
+ ActivitySession item = (ActivitySession) getArguments().getSerializable("item");
+ GBDevice gbDevice;
+ gbDevice = getArguments().getParcelable(GBDevice.EXTRA_DEVICE);
+ if (gbDevice == null) {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+
+ final ActivitySummariesChartFragment activitySummariesChartFragment = new ActivitySummariesChartFragment();
+ getChildFragmentManager()
+ .beginTransaction()
+ .replace(R.id.chartsFragmentHolder, activitySummariesChartFragment)
+ .commit();
+ activitySummariesChartFragment.setDateAndGetData(null, gbDevice, tsFrom, tsTo);
+
+ ActivityListingAdapter stepListAdapter = new ActivityListingAdapter(getContext());
+ View activityItem = view.findViewById(R.id.activityItemHolder);
+ ActivityListItem activityListItem = new ActivityListItem(activityItem);
+ activityListItem.update(
+ item.getStartTime(),
+ item.getEndTime(),
+ item.getActivityKind(),
+ null,
+ item.getActiveSteps(),
+ item.getDistance(),
+ item.getHeartRateAverage(),
+ item.getIntensity(),
+ item.getEndTime().getTime() - item.getStartTime().getTime(),
+ false,
+ null,
+ false,
+ false
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivitySleepChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivitySleepChartFragment.java
new file mode 100644
index 0000000..eb99928
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivitySleepChartFragment.java
@@ -0,0 +1,203 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Dikay900, Jos茅 Rebelo, Pavel Elagin
+
+ 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.activities.charts;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.github.mikephil.charting.animation.Easing;
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.Legend;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.LineData;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+
+
+public class ActivitySleepChartFragment extends AbstractActivityChartFragment<DefaultChartsData<LineData>> {
+ protected static final Logger LOG = LoggerFactory.getLogger(ActivitySleepChartFragment.class);
+
+ private LineChart mChart;
+
+ private int mSmartAlarmFrom = -1;
+ private int mSmartAlarmTo = -1;
+ private int mTimestampFrom = -1;
+ private int mSmartAlarmGoneOff = -1;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_charts, container, false);
+
+ mChart = (LineChart) rootView.findViewById(R.id.activitysleepchart);
+
+ setupChart();
+
+ return rootView;
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.activity_sleepchart_activity_and_sleep);
+ }
+
+ private void setupChart() {
+ mChart.setBackgroundColor(BACKGROUND_COLOR);
+ mChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ configureBarLineChartDefaults(mChart);
+
+
+ XAxis x = mChart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setDrawLimitLinesBehindData(true);
+
+ YAxis y = mChart.getAxisLeft();
+ y.setDrawGridLines(false);
+// y.setDrawLabels(false);
+ // TODO: make fixed max value optional
+ y.setAxisMaximum(1f);
+ y.setAxisMinimum(0);
+ y.setDrawTopYLabelEntry(false);
+ y.setTextColor(CHART_TEXT_COLOR);
+
+// y.setLabelCount(5);
+ y.setEnabled(true);
+
+ YAxis yAxisRight = mChart.getAxisRight();
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setEnabled(supportsHeartrate(getChartsHost().getDevice()));
+ yAxisRight.setDrawLabels(true);
+ yAxisRight.setDrawTopYLabelEntry(true);
+ yAxisRight.setTextColor(CHART_TEXT_COLOR);
+ yAxisRight.setAxisMaximum(HeartRateUtils.getInstance().getMaxHeartRate());
+ yAxisRight.setAxisMinimum(HeartRateUtils.getInstance().getMinHeartRate());
+
+ // refresh immediately instead of use refreshIfVisible(), for perceived performance
+ refresh();
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action.equals(ChartsHost.REFRESH)) {
+ // TODO: use LimitLines to visualize smart alarms?
+ mSmartAlarmFrom = intent.getIntExtra("smartalarm_from", -1);
+ mSmartAlarmTo = intent.getIntExtra("smartalarm_to", -1);
+ mTimestampFrom = intent.getIntExtra("recording_base_timestamp", -1);
+ mSmartAlarmGoneOff = intent.getIntExtra("alarm_gone_off", -1);
+ refresh();
+ } else {
+ super.onReceive(context, intent);
+ }
+ }
+
+ @Override
+ protected DefaultChartsData<LineData> refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ List<? extends ActivitySample> samples = getSamples(db, device);
+ List<? extends ActivitySample> highResSamples = getSamplesHighRes(db, device);
+ if (highResSamples == null)
+ return refresh(device, samples);
+ return refresh(device, samples, highResSamples);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(DefaultChartsData<LineData> dcd) {
+ mChart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ mChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
+ mChart.getXAxis().setValueFormatter(dcd.getXValueFormatter());
+ mChart.setData((LineData) dcd.getData());
+ }
+
+ @Override
+ protected void renderCharts() {
+ mChart.animateX(ANIM_TIME, Easing.EaseInOutQuart);
+// mChart.invalidate();
+ }
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {
+ List<LegendEntry> legendEntries = new ArrayList<>(5);
+
+ LegendEntry activityEntry = new LegendEntry();
+ activityEntry.label = akActivity.label;
+ activityEntry.formColor = akActivity.color;
+ legendEntries.add(activityEntry);
+
+ LegendEntry lightSleepEntry = new LegendEntry();
+ lightSleepEntry.label = akLightSleep.label;
+ lightSleepEntry.formColor = akLightSleep.color;
+ legendEntries.add(lightSleepEntry);
+
+ LegendEntry deepSleepEntry = new LegendEntry();
+ deepSleepEntry.label = akDeepSleep.label;
+ deepSleepEntry.formColor = akDeepSleep.color;
+ legendEntries.add(deepSleepEntry);
+
+ if (supportsRemSleep(getChartsHost().getDevice())) {
+ LegendEntry remSleepEntry = new LegendEntry();
+ remSleepEntry.label = akRemSleep.label;
+ remSleepEntry.formColor = akRemSleep.color;
+ legendEntries.add(remSleepEntry);
+ }
+
+ LegendEntry notWornEntry = new LegendEntry();
+ notWornEntry.label = akNotWorn.label;
+ notWornEntry.formColor = akNotWorn.color;
+ legendEntries.add(notWornEntry);
+
+ if (supportsHeartrate(getChartsHost().getDevice())) {
+ LegendEntry hrEntry = new LegendEntry();
+ hrEntry.label = HEARTRATE_LABEL;
+ hrEntry.formColor = HEARTRATE_COLOR;
+ legendEntries.add(hrEntry);
+ }
+ chart.getLegend().setCustom(legendEntries);
+ chart.getLegend().setWordWrapEnabled(true);
+ chart.getLegend().setHorizontalAlignment(Legend.LegendHorizontalAlignment.CENTER);
+ }
+
+ @Override
+ protected List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ return getAllSamples(db, device, tsFrom, tsTo);
+ }
+
+ @Override
+ protected List<? extends ActivitySample> getSamplesHighRes(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ return getAllSamplesHighRes(db, device, tsFrom, tsTo);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AngledLabelsChartRenderer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AngledLabelsChartRenderer.java
new file mode 100644
index 0000000..6d034b3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AngledLabelsChartRenderer.java
@@ -0,0 +1,42 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Petr Van臎k
+
+ 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.activities.charts;
+
+import android.graphics.Canvas;
+
+import com.github.mikephil.charting.animation.ChartAnimator;
+import com.github.mikephil.charting.interfaces.dataprovider.BarDataProvider;
+import com.github.mikephil.charting.renderer.BarChartRenderer;
+import com.github.mikephil.charting.utils.ViewPortHandler;
+
+public class AngledLabelsChartRenderer extends BarChartRenderer {
+ AngledLabelsChartRenderer(BarDataProvider chart, ChartAnimator animator, ViewPortHandler viewPortHandler) {
+ super(chart, animator, viewPortHandler);
+ }
+
+ @Override
+ public void drawValue(Canvas canvas, String valueText, float x, float y, int color) {
+ mValuePaint.setColor(color);
+ //move position to the center of bar
+ x = x + 8;
+ y = y - 32;
+ canvas.save();
+ canvas.rotate(-90, x, y);
+ canvas.drawText(valueText, x, y, mValuePaint);
+ canvas.restore();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BarChartStackedTimeValueFormatter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BarChartStackedTimeValueFormatter.java
new file mode 100644
index 0000000..3392ba8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BarChartStackedTimeValueFormatter.java
@@ -0,0 +1,55 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import com.github.mikephil.charting.data.BarEntry;
+import com.github.mikephil.charting.formatter.StackedValueFormatter;
+
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public class BarChartStackedTimeValueFormatter extends StackedValueFormatter {
+ private float[] processedValues;
+ private BarEntry lastEntry;
+ private int ignoreLast;
+ private int lastNonZeroIndex;
+ private int index = 0;
+
+ public BarChartStackedTimeValueFormatter(boolean drawWholeStack, String suffix, int decimals, int ignoreLast) {
+ super(drawWholeStack, suffix, decimals);
+ this.ignoreLast = ignoreLast;
+ }
+
+ private int getLastNonZeroIndex(float[] array) {
+ int last = 0;
+ int i = 0;
+ for(float v: array) {
+ last = v == 0 ? last : i;
+ i++;
+ }
+ return last;
+ }
+
+ @Override
+ public String getBarStackedLabel(float value, BarEntry entry) {
+ if (lastEntry != entry) {
+ processedValues = entry.getYVals();
+ lastEntry = entry;
+ lastNonZeroIndex = getLastNonZeroIndex(processedValues);
+ index = 0;
+ }
+
+ if (index == lastNonZeroIndex) {
+ return getFormattedValue(processedValues);
+ }
+
+ index++;
+ return "";
+ }
+
+ String getFormattedValue(float[] values) {
+ float sum = 0;
+ for (int i = 0; i < values.length - ignoreLast; i++) {
+ sum += values[i];
+ }
+ return DateTimeUtils.minutesToHHMM((int) sum);
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyFragment.java
new file mode 100644
index 0000000..796ab77
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyFragment.java
@@ -0,0 +1,305 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.ColorInt;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BodyEnergySample;
+
+
+public class BodyEnergyFragment extends AbstractChartFragment<BodyEnergyFragment.BodyEnergyData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(BodyEnergyFragment.class);
+
+ private TextView mDateView;
+ private ImageView bodyEnergyGauge;
+ private TextView bodyEnergyGained;
+ private TextView bodyEnergyLost;
+ private LineChart bodyEnergyChart;
+
+ protected int CHART_TEXT_COLOR;
+ protected int LEGEND_TEXT_COLOR;
+ protected int TEXT_COLOR;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_body_energy, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ mDateView = rootView.findViewById(R.id.body_energy_date_view);
+ bodyEnergyGauge = rootView.findViewById(R.id.body_energy_gauge);
+ bodyEnergyGained = rootView.findViewById(R.id.body_energy_gained);
+ bodyEnergyLost = rootView.findViewById(R.id.body_energy_lost);
+ bodyEnergyChart = rootView.findViewById(R.id.body_energy_chart);
+ setupBodyEnergyLevelChart();
+ refresh();
+
+
+ return rootView;
+ }
+
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.body_energy);
+ }
+
+ @Override
+ protected void init() {
+ TEXT_COLOR = GBApplication.getTextColor(requireContext());
+ LEGEND_TEXT_COLOR = GBApplication.getTextColor(requireContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+ }
+
+ @Override
+ protected BodyEnergyData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ List<? extends BodyEnergySample> samples = getBodyEnergySamples(db, device, getTSStart(), getTSEnd());
+ return new BodyEnergyData(samples);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(BodyEnergyData bodyEnergyData) {
+ String formattedDate = new SimpleDateFormat("E, MMM dd").format(getEndDate());
+ mDateView.setText(formattedDate);
+
+ List<Entry> lineEntries = new ArrayList<>();
+ final List<ILineDataSet> lineDataSets = new ArrayList<>();
+ final AtomicInteger gainedValue = new AtomicInteger(0);
+ final AtomicInteger drainedValue = new AtomicInteger(0);
+ int newestValue = 0;
+ long referencedTimestamp;
+ if (!bodyEnergyData.samples.isEmpty()) {
+ newestValue = bodyEnergyData.samples.get(bodyEnergyData.samples.size() - 1).getEnergy();
+ referencedTimestamp = bodyEnergyData.samples.get(0).getTimestamp();
+ final AtomicInteger[] lastValue = {new AtomicInteger(0)};
+ bodyEnergyData.samples.forEach((sample) -> {
+ if (sample.getEnergy() < lastValue[0].intValue()) {
+ drainedValue.set(drainedValue.get() + lastValue[0].intValue() - sample.getEnergy());
+ } else if (lastValue[0].intValue() > 0 && sample.getEnergy() > lastValue[0].intValue()) {
+ gainedValue.set(gainedValue.get() + sample.getEnergy() - lastValue[0].intValue());
+ }
+ lastValue[0].set(sample.getEnergy());
+ float x = (float) sample.getTimestamp() / 1000 - (float) referencedTimestamp / 1000;
+ lineEntries.add(new Entry(x, sample.getEnergy()));
+ });
+ }
+
+ final LineDataSet lineDataSet = new LineDataSet(lineEntries, getString(R.string.body_energy_legend_level));
+ lineDataSet.setColor(getResources().getColor(R.color.body_energy_level_color));
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setLineWidth(2f);
+ lineDataSet.setFillAlpha(255);
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setCircleColor(getResources().getColor(R.color.body_energy_level_color));
+ lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
+ lineDataSet.setDrawValues(false);
+ lineDataSet.setMode(LineDataSet.Mode.CUBIC_BEZIER);
+ lineDataSet.setDrawFilled(true);
+ lineDataSet.setFillAlpha(60);
+ lineDataSet.setFillColor(getResources().getColor(R.color.body_energy_level_color ));
+
+ List<LegendEntry> legendEntries = new ArrayList<>(1);
+ LegendEntry activityEntry = new LegendEntry();
+ activityEntry.label = getString(R.string.body_energy_legend_level);
+ activityEntry.formColor = getResources().getColor(R.color.body_energy_level_color);
+ legendEntries.add(activityEntry);
+ bodyEnergyChart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ bodyEnergyChart.getLegend().setCustom(legendEntries);
+
+ lineDataSets.add(lineDataSet);
+ final LineData lineData = new LineData(lineDataSets);
+ bodyEnergyChart.setData(lineData);
+
+ final int width = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 300,
+ GBApplication.getContext().getResources().getDisplayMetrics()
+ );
+
+ bodyEnergyGauge.setImageBitmap(drawGauge(
+ width,
+ width / 15,
+ getResources().getColor(R.color.body_energy_level_color),
+ newestValue,
+ 100
+ ));
+ bodyEnergyGained.setText(String.format("+ %s", gainedValue.intValue()));
+ bodyEnergyLost.setText(String.format("- %s", drainedValue));
+ }
+
+ @Override
+ protected void renderCharts() {
+ bodyEnergyChart.invalidate();
+ }
+
+
+ public List<? extends BodyEnergySample> getBodyEnergySamples(final DBHandler db, final GBDevice device, int tsFrom, int tsTo) {
+ Calendar day = Calendar.getInstance();
+ day.setTimeInMillis(tsTo * 1000L); //we need today initially, which is the end of the time range
+ day.set(Calendar.HOUR_OF_DAY, 0); //and we set time for the start and end of the same day
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ tsFrom = (int) (day.getTimeInMillis() / 1000);
+ tsTo = tsFrom + 24 * 60 * 60 - 1;
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final TimeSampleProvider<? extends BodyEnergySample> sampleProvider = coordinator.getBodyEnergySampleProvider(device, db.getDaoSession());
+ return sampleProvider.getAllSamples(tsFrom * 1000L, tsTo * 1000L);
+ }
+
+ protected void setupLegend(Chart<?> chart) {}
+
+ Bitmap drawGauge(int width, int barWidth, @ColorInt int filledColor, int value, int maxValue) {
+ int height = width;
+ int barMargin = (int) Math.ceil(barWidth / 2f);
+ float filledFactor = (float) value / maxValue;
+
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeCap(Paint.Cap.ROUND);
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(getResources().getColor(R.color.gauge_line_color));
+ canvas.drawArc(
+ barMargin,
+ barMargin,
+ width - barMargin,
+ width - barMargin,
+ 120,
+ 300,
+ false,
+ paint);
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(filledColor);
+ canvas.drawArc(
+ barMargin,
+ barMargin,
+ width - barMargin,
+ height - barMargin,
+ 120,
+ 300 * filledFactor,
+ false,
+ paint
+ );
+
+ Paint textPaint = new Paint();
+ textPaint.setColor(TEXT_COLOR);
+ float textPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, width * 0.06f, requireContext().getResources().getDisplayMetrics());
+ textPaint.setTextSize(textPixels);
+ textPaint.setTextAlign(Paint.Align.CENTER);
+ int yPos = (int) ((float) height / 2 - ((textPaint.descent() + textPaint.ascent()) / 2)) ;
+ canvas.drawText(String.valueOf(value), width / 2f, yPos, textPaint);
+ Paint textLowerPaint = new Paint();
+ textLowerPaint.setColor(TEXT_COLOR);
+ textLowerPaint.setTextAlign(Paint.Align.CENTER);
+ float textLowerPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, width * 0.025f, requireContext().getResources().getDisplayMetrics());
+ textLowerPaint.setTextSize(textLowerPixels);
+ int yPosLowerText = (int) ((float) height / 2 - textPaint.ascent()) ;
+ canvas.drawText(String.valueOf(maxValue), width / 2f, yPosLowerText, textLowerPaint);
+
+ return bitmap;
+ }
+
+ private void setupBodyEnergyLevelChart() {
+ bodyEnergyChart.getDescription().setEnabled(false);
+ bodyEnergyChart.setTouchEnabled(false);
+ bodyEnergyChart.setPinchZoom(false);
+ bodyEnergyChart.setDoubleTapToZoomEnabled(false);
+
+
+ final XAxis xAxisBottom = bodyEnergyChart.getXAxis();
+ xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
+ xAxisBottom.setDrawLabels(true);
+ xAxisBottom.setDrawGridLines(false);
+ xAxisBottom.setEnabled(true);
+ xAxisBottom.setDrawLimitLinesBehindData(true);
+ xAxisBottom.setTextColor(CHART_TEXT_COLOR);
+ xAxisBottom.setAxisMinimum(0f);
+ xAxisBottom.setAxisMaximum(86400f);
+ xAxisBottom.setLabelCount(7, true);
+ xAxisBottom.setValueFormatter(getBodyEnergyChartXValueFormatter());
+
+ final YAxis yAxisLeft = bodyEnergyChart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setAxisMaximum(100);
+ yAxisLeft.setAxisMinimum(0);
+ yAxisLeft.setDrawTopYLabelEntry(true);
+ yAxisLeft.setEnabled(true);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+
+ final YAxis yAxisRight = bodyEnergyChart.getAxisRight();
+ yAxisRight.setEnabled(true);
+ yAxisRight.setDrawLabels(false);
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setDrawAxisLine(true);
+
+ }
+
+ ValueFormatter getBodyEnergyChartXValueFormatter() {
+ return new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ long timestamp = (long) (value * 1000);
+ Date date = new Date ();
+ date.setTime(timestamp);
+ SimpleDateFormat df = new SimpleDateFormat("HH:mm", Locale.getDefault());
+ df.setTimeZone(TimeZone.getTimeZone("UTC"));
+ return df.format(date);
+ }
+ };
+ }
+
+ protected static class BodyEnergyData extends ChartsData {
+ private final List<? extends BodyEnergySample> samples;
+
+ protected BodyEnergyData(List<? extends BodyEnergySample> samples) {
+ this.samples = samples;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesDailyFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesDailyFragment.java
new file mode 100644
index 0000000..3184d40
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesDailyFragment.java
@@ -0,0 +1,253 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+
+import com.github.mikephil.charting.charts.Chart;
+
+import org.apache.commons.lang3.EnumUtils;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.GaugeDrawer;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DefaultRestingMetabolicRateProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
+
+public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFragment.CaloriesData> {
+
+ private ImageView caloriesGauge;
+ private TextView dateView;
+ private TextView caloriesResting;
+ private TextView caloriesActive;
+ private LinearLayout caloriesActiveWrapper;
+ private TextView caloriesActiveGoal;
+ private LinearLayout caloriesActiveGoalWrapper;
+ protected int CALORIES_GOAL;
+ protected int ACTIVE_CALORIES_GOAL;
+ public enum GaugeViewMode {
+ ACTIVE_CALORIES_GOAL,
+ TOTAL_CALORIES_SEGMENT
+ }
+ private GaugeViewMode gaugeViewMode;
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (getArguments() != null) {
+ String mode = getArguments().getString(ActivityChartsActivity.EXTRA_MODE, "");
+ if (EnumUtils.isValidEnum(GaugeViewMode.class, mode)) {
+ gaugeViewMode = GaugeViewMode.valueOf(mode);
+ }
+ }
+ }
+
+ public static CaloriesDailyFragment newInstance(final String mode) {
+ final CaloriesDailyFragment fragment = new CaloriesDailyFragment();
+ final Bundle args = new Bundle();
+ args.putString(ActivityChartsActivity.EXTRA_MODE, mode);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_calories, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ caloriesGauge = rootView.findViewById(R.id.calories_gauge);
+ dateView = rootView.findViewById(R.id.date_view);
+ caloriesResting = rootView.findViewById(R.id.calories_resting);
+ caloriesActive = rootView.findViewById(R.id.calories_active);
+ caloriesActiveWrapper = rootView.findViewById(R.id.calories_active_wrapper);
+ caloriesActiveGoal = rootView.findViewById(R.id.calories_active_goal);
+ caloriesActiveGoalWrapper = rootView.findViewById(R.id.calories_active_goal_wrapper);
+ ActivityUser activityUser = new ActivityUser();
+ ACTIVE_CALORIES_GOAL = activityUser.getCaloriesBurntGoal();
+
+ refresh();
+ if (!supportsActiveCalories()) {
+ caloriesActiveWrapper.setVisibility(View.GONE);
+ caloriesActiveGoalWrapper.setVisibility(View.GONE);
+ }
+
+ if (gaugeViewMode == null) {
+ gaugeViewMode = GaugeViewMode.TOTAL_CALORIES_SEGMENT;
+ }
+
+ if (gaugeViewMode.equals(GaugeViewMode.ACTIVE_CALORIES_GOAL)) {
+ CALORIES_GOAL = ACTIVE_CALORIES_GOAL;
+ }
+
+ return rootView;
+ }
+
+ public boolean supportsActiveCalories() {
+ final GBDevice device = getChartsHost().getDevice();
+ return device.getDeviceCoordinator().supportsActiveCalories();
+ }
+
+ protected RestingMetabolicRateSample getRestingMetabolicRate(DBHandler db, GBDevice device) {
+ TimeSampleProvider<? extends RestingMetabolicRateSample> provider = device.getDeviceCoordinator().getRestingMetabolicRateProvider(device, db.getDaoSession());
+ RestingMetabolicRateSample latestSample = provider.getLatestSample();
+ if (latestSample != null) {
+ return latestSample;
+ }
+ DefaultRestingMetabolicRateProvider defaultProvider = new DefaultRestingMetabolicRateProvider(device, db.getDaoSession());
+ return defaultProvider.getLatestSample();
+ }
+
+ protected List<? extends AbstractActivitySample> getActivitySamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ SampleProvider<? extends ActivitySample> provider = device.getDeviceCoordinator().getSampleProvider(device, db.getDaoSession());
+ return provider.getAllActivitySamples(tsFrom, tsTo);
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.calories);
+ }
+
+ @Override
+ protected void init() {}
+
+ @Override
+ protected CaloriesDailyFragment.CaloriesData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ Calendar calendar = Calendar.getInstance();
+ Calendar day = Calendar.getInstance();
+ day.setTime(chartsHost.getEndDate());
+ day.add(Calendar.DATE, 0);
+ day.set(Calendar.HOUR_OF_DAY, 0);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ day.add(Calendar.HOUR, 0);
+ int startTs = (int) (day.getTimeInMillis() / 1000);
+ int endTs = startTs + 24 * 60 * 60 - 1;
+ Date date = new Date((long) endTs * 1000);
+ String formattedDate = new SimpleDateFormat("E, MMM dd").format(date);
+ dateView.setText(formattedDate);
+ List<? extends ActivitySample> samples = getActivitySamples(db, device, startTs, endTs);
+ RestingMetabolicRateSample metabolicRate = getRestingMetabolicRate(db, device);
+ if (metabolicRate == null) {
+ return new CaloriesData(0, 0, 0);
+ }
+ int totalBurnt;
+ int activeBurnt = 0;
+ boolean sameDay = calendar.get(Calendar.DAY_OF_YEAR) == day.get(Calendar.DAY_OF_YEAR) &&
+ calendar.get(Calendar.YEAR) == day.get(Calendar.YEAR);
+ double passedDayProportion = 1;
+ if (sameDay) {
+ passedDayProportion = (double) (calendar.getTimeInMillis() - day.getTimeInMillis()) / (24L * 60 * 60 * 1000);
+ }
+ int restingBurnt = (int) (metabolicRate.getRestingMetabolicRate() * passedDayProportion);
+
+ for (int i = 0; i <= samples.size() - 1; i++) {
+ ActivitySample sample = samples.get(i);
+ if (sample.getActiveCalories() > 0) {
+ activeBurnt += sample.getActiveCalories();
+ }
+ }
+ // Convert calories to kcal
+ activeBurnt = activeBurnt / 1000;
+ totalBurnt = restingBurnt + activeBurnt;
+
+ return new CaloriesData(totalBurnt, activeBurnt, restingBurnt);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(CaloriesDailyFragment.CaloriesData data) {
+ int restingCalories = data.restingBurnt;
+ int activeCalories = data.activeBurnt;
+ int totalCalories = activeCalories + restingCalories;
+ caloriesActive.setText(String.valueOf(activeCalories));
+ caloriesResting.setText(String.valueOf(restingCalories));
+ caloriesActiveGoal.setText(String.valueOf(ACTIVE_CALORIES_GOAL));
+
+ if (gaugeViewMode.equals(GaugeViewMode.TOTAL_CALORIES_SEGMENT)) {
+ int[] colors = new int[] {
+ ContextCompat.getColor(GBApplication.getContext(), R.color.calories_resting_color),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.calories_color)
+ };
+ float[] segments = new float[] {
+ restingCalories > 0 ? (float) restingCalories / totalCalories : 0,
+ activeCalories > 0 ? (float) activeCalories / totalCalories : 0
+ };
+ final int width = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 300,
+ GBApplication.getContext().getResources().getDisplayMetrics()
+ );
+ caloriesGauge.setImageBitmap(GaugeDrawer.drawCircleGaugeSegmented(
+ width,
+ width / 15,
+ colors,
+ segments,
+ true,
+ String.valueOf(totalCalories),
+ getContext().getString(R.string.total_calories_burnt),
+ getContext()
+ ));
+ } else {
+ int value = 0;
+ if (gaugeViewMode.equals(GaugeViewMode.ACTIVE_CALORIES_GOAL)) {
+ value = activeCalories;
+ }
+ final int width = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 300,
+ GBApplication.getContext().getResources().getDisplayMetrics()
+ );
+ caloriesGauge.setImageBitmap(GaugeDrawer.drawCircleGauge(
+ width,
+ width / 15,
+ getResources().getColor(R.color.calories_color),
+ value,
+ CALORIES_GOAL,
+ getContext()
+ ));
+ }
+ }
+
+ @Override
+ protected void renderCharts() {}
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {}
+
+ protected static class CaloriesData extends ChartsData {
+ public int activeBurnt;
+ public int restingBurnt;
+ public int totalBurnt;
+
+ protected CaloriesData(int totalBurnt, int activeBurnt, int restingBurnt) {
+ this.totalBurnt = totalBurnt;
+ this.activeBurnt = activeBurnt;
+ this.restingBurnt = restingBurnt;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsData.java
new file mode 100644
index 0000000..f9bf5f6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsData.java
@@ -0,0 +1,20 @@
+/* Copyright (C) 2016-2024 Carsten Pfeiffer
+
+ 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.activities.charts;
+
+public abstract class ChartsData {
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsHost.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsHost.java
new file mode 100644
index 0000000..790d38d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsHost.java
@@ -0,0 +1,51 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.activities.charts;
+
+import android.view.ViewGroup;
+
+import java.util.Date;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public interface ChartsHost {
+ String DATE_PREV_DAY = ChartsHost.class.getName().concat(".date_prev_day");
+ String DATE_NEXT_DAY = ChartsHost.class.getName().concat(".date_next_day");
+ String DATE_PREV_WEEK = ChartsHost.class.getName().concat(".date_prev_week");
+ String DATE_NEXT_WEEK = ChartsHost.class.getName().concat(".date_next_week");
+ String DATE_PREV_MONTH = ChartsHost.class.getName().concat(".date_prev_month");
+ String DATE_NEXT_MONTH = ChartsHost.class.getName().concat(".date_next_month");
+
+ String REFRESH = ChartsHost.class.getName().concat(".refresh");
+
+ GBDevice getDevice();
+
+ void setStartDate(Date startDate);
+
+ void setEndDate(Date endDate);
+
+ Date getStartDate();
+
+ Date getEndDate();
+
+ void setDateInfo(String dateInfo);
+
+ ViewGroup getDateBar();
+
+ void enableSwipeRefresh(boolean enable);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsPreferencesActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsPreferencesActivity.java
new file mode 100644
index 0000000..9addbe1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ChartsPreferencesActivity.java
@@ -0,0 +1,131 @@
+/* Copyright (C) 2019-2024 Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities.charts;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.InputType;
+
+import androidx.annotation.NonNull;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+
+import com.mobeta.android.dslv.DragSortListPreference;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AboutUserPreferencesActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs;
+
+public class ChartsPreferencesActivity extends AbstractSettingsActivityV2 {
+ private GBDevice device;
+
+ @Override
+ protected String fragmentTag() {
+ return ChartsPreferencesFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return ChartsPreferencesFragment.newInstance(device);
+ }
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+
+ super.onCreate(savedInstanceState);
+ }
+
+ public static class ChartsPreferencesFragment extends AbstractPreferenceFragment {
+ static final String FRAGMENT_TAG = "CHARTS_PREFERENCES_FRAGMENT";
+
+ private GBDevice device;
+
+ static ChartsPreferencesFragment newInstance(final GBDevice device) {
+ final ChartsPreferencesFragment fragment = new ChartsPreferencesFragment();
+ fragment.setDevice(device);
+ return fragment;
+ }
+
+ private void setDevice(final GBDevice device) {
+ final Bundle args = getArguments() != null ? getArguments() : new Bundle();
+ args.putParcelable("device", device);
+ setArguments(args);
+ }
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ final Bundle arguments = getArguments();
+ if (arguments != null) {
+ this.device = arguments.getParcelable(GBDevice.EXTRA_DEVICE);
+ }
+
+ setPreferencesFromResource(R.xml.charts_preferences, rootKey);
+
+ // If a device was provided, show the charts tabs preference, since that's the only one
+ // that is device-specific for now. We also sync changes to the device-specific preference.
+ //final DragSortListPreference prefChartsTabs = findPreference(DeviceSettingsPreferenceConst.PREFS_DEVICE_CHARTS_TABS);
+ //if (prefChartsTabs != null) {
+ // if (device != null) {
+ // final DevicePrefs devicePrefs = GBApplication.getDevicePrefs(device.getAddress());
+ // final String myTabs = devicePrefs.getString(DeviceSettingsPreferenceConst.PREFS_DEVICE_CHARTS_TABS, null);
+ // if (myTabs != null) {
+ // prefChartsTabs.setValue(myTabs);
+ // }
+ // prefChartsTabs.setOnPreferenceChangeListener((preference, newValue) -> {
+ // devicePrefs.getPreferences().edit()
+ // .putString(DeviceSettingsPreferenceConst.PREFS_DEVICE_CHARTS_TABS, String.valueOf(newValue))
+ // .apply();
+ // return true;
+ // });
+ // } else {
+ // prefChartsTabs.setVisible(false);
+ // }
+ //}
+
+ setInputTypeFor(GBPrefs.CHART_MAX_HEART_RATE, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(GBPrefs.CHART_MIN_HEART_RATE, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor("chart_sleep_lines_limit", InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor("chart_list_min_session_length", InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor("chart_list_max_idle_phase_length", InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor("chart_list_min_steps_per_minute", InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor("chart_list_min_steps_per_minute_for_run", InputType.TYPE_CLASS_NUMBER);
+
+ final Preference aboutUserPref = findPreference("pref_category_activity_personal");
+ if (aboutUserPref != null) {
+ if (device != null) {
+ aboutUserPref.setOnPreferenceClickListener(preference -> {
+ final Intent enableIntent = new Intent(getActivity(), AboutUserPreferencesActivity.class);
+ startActivity(enableIntent);
+ return true;
+ });
+ } else {
+ final Preference aboutUserHeader = findPreference("pref_category_activity_personal_title");
+ if (aboutUserHeader != null) {
+ aboutUserHeader.setVisible(false);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CustomBarChart.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CustomBarChart.java
new file mode 100644
index 0000000..1eb3580
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CustomBarChart.java
@@ -0,0 +1,76 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.activities.charts;
+
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.util.AttributeSet;
+
+import com.github.mikephil.charting.charts.BarChart;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.renderer.BarChartRenderer;
+
+/**
+ * A BarChart with some specific customization, like
+ * <li>allowing to animate a single entry's values without going over 0</li>
+ */
+public class CustomBarChart extends BarChart {
+
+ private Entry entry = null;
+ private SingleEntryValueAnimator singleEntryAnimator;
+
+ public CustomBarChart(Context context) {
+ super(context);
+ }
+
+ public CustomBarChart(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public CustomBarChart(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ public void setSinglAnimationEntry(Entry entry) {
+ this.entry = entry;
+
+ if (entry != null) {
+ // single entry animation mode
+ singleEntryAnimator = new SingleEntryValueAnimator(entry, new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ // ViewCompat.postInvalidateOnAnimation(Chart.this);
+ postInvalidate();
+ }
+ });
+ mAnimator = singleEntryAnimator;
+ mRenderer = new BarChartRenderer(this, singleEntryAnimator, getViewPortHandler());
+ }
+ }
+
+ /**
+ * Call this to set the next value for the Entry to be animated.
+ * Call animateY() when ready to do that.
+ *
+ * @param nextValue
+ */
+ public void setSingleEntryYValue(float nextValue) {
+ if (singleEntryAnimator != null) {
+ singleEntryAnimator.setEntryYValue(nextValue);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CyclingChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CyclingChartFragment.java
new file mode 100644
index 0000000..556f247
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CyclingChartFragment.java
@@ -0,0 +1,295 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.graphics.Color;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+
+import com.github.mikephil.charting.animation.Easing;
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.LimitLine;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import java.text.DateFormat;
+import java.text.DecimalFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class CyclingChartFragment extends AbstractChartFragment<CyclingChartFragment.CyclingChartsData>{
+ private LineChart cyclingHistoryChart;
+
+ private int BACKGROUND_COLOR;
+ private int DESCRIPTION_COLOR;
+ private int CHART_TEXT_COLOR;
+ private int LEGEND_TEXT_COLOR;
+ private int CHART_LINE_COLOR_DISTANCE;
+ private int CHART_LINE_COLOR_SPEED;
+ private final Prefs prefs = GBApplication.getPrefs();
+
+ protected static class CyclingChartsData extends DefaultChartsData<LineData> {
+ public CyclingChartsData(LineData lineData) {
+ super(lineData, new TimeFormatter());
+ }
+ }
+
+ @Override
+ public String getTitle() {
+ return "Cycling data";
+ }
+
+ @Override
+ protected void init() {
+ BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext());
+ LEGEND_TEXT_COLOR = DESCRIPTION_COLOR = GBApplication.getTextColor(requireContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+
+ if (prefs.getBoolean("chart_heartrate_color", false)) {
+ CHART_LINE_COLOR_DISTANCE = ContextCompat.getColor(getContext(), R.color.chart_activity_dark);
+ CHART_LINE_COLOR_SPEED = ContextCompat.getColor(getContext(), R.color.chart_heartrate);
+ } else {
+ CHART_LINE_COLOR_DISTANCE = ContextCompat.getColor(getContext(), R.color.chart_activity_light);
+ CHART_LINE_COLOR_SPEED = ContextCompat.getColor(getContext(), R.color.chart_heartrate_alternative);
+ }
+ }
+
+ @Override
+ protected CyclingChartsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ List<CyclingSample> samples = getSamples(db, device);
+
+ return new CyclingChartsDataBuilder(samples).build();
+ }
+
+ protected class CyclingChartsDataBuilder {
+ private final List<CyclingSample> samples;
+
+ private final List<Entry> lineEntries = new ArrayList<>();
+
+ long averageSum;
+ long averageNumSamples;
+
+ public CyclingChartsDataBuilder(final List<CyclingSample> samples) {
+ this.samples = samples;
+ }
+
+ private void reset() {
+ lineEntries.clear();
+
+ averageSum = 0;
+ averageNumSamples = 0;
+ }
+
+ public CyclingChartsData build() {
+ List<Entry> distanceEntries = new ArrayList<>();
+ List<Entry> speedEntries = new ArrayList<>();
+
+ Float dayStart = 0f;
+
+ if(!samples.isEmpty()){
+ dayStart = samples.get(0).getDistance() / 1000f;
+ }
+
+ int nextIndex = 0;
+ CyclingSample oldSample = null;
+ for (CyclingSample sample : samples) {
+ // add distance in Km
+ distanceEntries.add(new Entry(sample.getTimestamp(), (sample.getDistance() / 1000f) - dayStart));
+
+ if(oldSample != null) {
+ float deltaMeters = sample.getDistance() - oldSample.getDistance();
+ float deltaMillis = sample.getTimestamp() - oldSample.getTimestamp();
+
+ float metersPerMillisecond = deltaMeters / deltaMillis;
+ float kmh = metersPerMillisecond * 3600;
+
+ // Float speed = sample.getSpeed();
+ if(kmh < 6.0) {
+ // speed to slow, cutting down to 0
+ speedEntries.add(new Entry(oldSample.getTimestamp() + 30_000, 0));
+ }
+ speedEntries.add(new Entry(sample.getTimestamp(), kmh));
+ }
+
+ nextIndex++;
+ oldSample = sample;
+ }
+
+ LineDataSet distanceSet = new LineDataSet(distanceEntries, "Cycling");
+ distanceSet.setLineWidth(2.2f);
+ distanceSet.setColor(CHART_LINE_COLOR_DISTANCE);
+ distanceSet.setDrawCircles(false);
+ distanceSet.setDrawCircleHole(false);
+ distanceSet.setDrawValues(true);
+ distanceSet.setValueTextSize(10f);
+ distanceSet.setValueTextColor(CHART_TEXT_COLOR);
+ distanceSet.setHighlightEnabled(false);
+ distanceSet.setValueFormatter(new CyclingDistanceFormatter(CyclingChartFragment.this, dayStart));
+ distanceSet.setAxisDependency(cyclingHistoryChart.getAxisLeft().getAxisDependency());
+ LineData lineData = new LineData(distanceSet);
+
+ LineDataSet speedSet = new LineDataSet(speedEntries, "Speed");
+ speedSet.setLineWidth(2.2f);
+ speedSet.setColor(CHART_LINE_COLOR_SPEED);
+ speedSet.setDrawCircles(false);
+ speedSet.setDrawCircleHole(false);
+ speedSet.setDrawValues(true);
+ speedSet.setValueTextSize(10f);
+ speedSet.setValueTextColor(CHART_TEXT_COLOR);
+ speedSet.setHighlightEnabled(true);
+ speedSet.setValueFormatter(new CyclingSpeedFormatter(CyclingChartFragment.this));
+ speedSet.setAxisDependency(cyclingHistoryChart.getAxisRight().getAxisDependency());
+
+ lineData.addDataSet(speedSet);
+
+ return new CyclingChartsData(lineData);
+ }
+ }
+
+ @Override
+ protected void renderCharts() {
+ cyclingHistoryChart.animateX(ANIM_TIME, Easing.EaseInOutQuart);
+ }
+
+ @Override
+ protected void setupLegend(final Chart<?> chart) {
+ final List<LegendEntry> legendEntries = new ArrayList<>(2);
+
+ final LegendEntry distanceEntry = new LegendEntry();
+ distanceEntry.label = getString(R.string.activity_list_summary_distance);
+ distanceEntry.formColor = CHART_LINE_COLOR_DISTANCE;
+ legendEntries.add(distanceEntry);
+
+ final LegendEntry speedEntry = new LegendEntry();
+ speedEntry.label = getString(R.string.Speed);
+ speedEntry.formColor = CHART_LINE_COLOR_SPEED;
+ legendEntries.add(speedEntry);
+
+ chart.getLegend().setCustom(legendEntries);
+ chart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(CyclingChartsData cyclingData) {
+ cyclingHistoryChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
+ cyclingHistoryChart.getXAxis().setValueFormatter(cyclingData.getXValueFormatter());
+ cyclingHistoryChart.getXAxis().setAvoidFirstLastClipping(true);
+
+ cyclingHistoryChart.setData(cyclingData.getData());
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_cycling, container, false);
+
+ cyclingHistoryChart = rootView.findViewById(R.id.chart_cycling_history);
+
+ cyclingHistoryChart.setBackgroundColor(BACKGROUND_COLOR);
+ cyclingHistoryChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ configureBarLineChartDefaults(cyclingHistoryChart);
+
+ final XAxis x = cyclingHistoryChart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(true);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setDrawLimitLinesBehindData(true);
+
+ final YAxis yAxisLeft = cyclingHistoryChart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setTextColor(CHART_LINE_COLOR_DISTANCE);
+ yAxisLeft.setEnabled(true);
+ yAxisLeft.setGridColor(CHART_LINE_COLOR_DISTANCE);
+
+ final YAxis yAxisRight = cyclingHistoryChart.getAxisRight();
+ yAxisRight.setDrawGridLines(true);
+ yAxisRight.setTextColor(CHART_LINE_COLOR_SPEED);
+ yAxisRight.setEnabled(true);
+ yAxisRight.setGridColor(CHART_LINE_COLOR_SPEED);
+
+ refresh();
+
+ return rootView;
+ }
+
+ private List<CyclingSample> getSamples(final DBHandler db, final GBDevice device) {
+ final int tsStart = getTSStart();
+ final int tsEnd = getTSEnd();
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final TimeSampleProvider<CyclingSample> sampleProvider = coordinator.getCyclingSampleProvider(device, db.getDaoSession());
+ return sampleProvider.getAllSamples(tsStart * 1000L, tsEnd * 1000L);
+ }
+
+
+
+ protected static class CyclingDistanceFormatter extends ValueFormatter {
+ // private final DecimalFormat formatter = new DecimalFormat("0.00 km");
+ Float dayStartDistance;
+ CyclingChartFragment fragment;
+
+ public CyclingDistanceFormatter(CyclingChartFragment fragment, Float dayStartDistance) {
+ this.dayStartDistance = dayStartDistance;
+ this.fragment = fragment;
+ }
+
+ @Override
+ public String getPointLabel(Entry entry) {
+ return fragment.getString(R.string.chart_cycling_point_label_distance, entry.getY(), entry.getY() + dayStartDistance);
+ }
+ }
+
+ protected static class CyclingSpeedFormatter extends ValueFormatter {
+ CyclingChartFragment fragment;
+
+ public CyclingSpeedFormatter(CyclingChartFragment fragment) {
+ this.fragment = fragment;
+ }
+
+ @Override
+ public String getPointLabel(Entry entry) {
+ return fragment.getString(R.string.chart_cycling_point_label_speed, entry.getY());
+ }
+ }
+
+ protected static class TimeFormatter extends ValueFormatter {
+ DateFormat annotationDateFormat = SimpleDateFormat.getTimeInstance(DateFormat.SHORT);
+ Calendar cal = GregorianCalendar.getInstance();
+
+ public TimeFormatter() {
+ }
+
+ @Override
+ public String getFormattedValue(float value) {
+ cal.clear();
+ cal.setTimeInMillis((long)(value));
+ Date date = cal.getTime();
+ return annotationDateFormat.format(date);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DaySleepChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DaySleepChartFragment.java
new file mode 100644
index 0000000..2d3a7a1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DaySleepChartFragment.java
@@ -0,0 +1,616 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Dikay900, Jos茅 Rebelo, ozkanpakdil, Pavel Elagin, Petr Van臎k, Q-er, a0z
+
+ 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.activities.charts;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Color;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.method.ScrollingMovementMethod;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+
+import com.github.mikephil.charting.animation.Easing;
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.*;
+import com.github.mikephil.charting.data.LineData;
+
+import org.apache.commons.lang3.tuple.Triple;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.DecimalFormat;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.SleepAnalysis.SleepSession;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.GaugeDrawer;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.SleepScoreSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+
+public class DaySleepChartFragment extends AbstractActivityChartFragment<DaySleepChartFragment.MyChartsData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(ActivitySleepChartFragment.class);
+
+ private LineChart mActivityChart;
+ private ImageView sleepStagesGauge;
+ private TextView mSleepchartInfo;
+ private TextView remSleepTimeText;
+ private LinearLayout remSleepTimeTextWrapper;
+ private TextView awakeSleepTimeText;
+ private LinearLayout awakeSleepTimeTextWrapper;
+ private TextView deepSleepTimeText;
+ private TextView lightSleepTimeText;
+ private TextView lowestHrText;
+ private TextView highestHrText;
+ private TextView movementIntensityText;
+ private LinearLayout movementIntensityTextWrapper;
+ private LinearLayout dummyTile;
+ private TextView sleepDateText;
+ private int heartRateMin = 0;
+ private int heartRateMax = 0;
+ private float intensityTotal = 0;
+
+
+
+ private int mSmartAlarmFrom = -1;
+ private int mSmartAlarmTo = -1;
+ private int mTimestampFrom = -1;
+ private int mSmartAlarmGoneOff = -1;
+ Prefs prefs = GBApplication.getPrefs();
+ private boolean CHARTS_SLEEP_RANGE_24H = prefs.getBoolean("chart_sleep_range_24h", false);
+ private boolean SHOW_CHARTS_AVERAGE = prefs.getBoolean("charts_show_average", true);
+ private int sleepLinesLimit = prefs.getInt("chart_sleep_lines_limit", 6);
+
+ @Override
+ protected boolean isSingleDay() {
+ return true;
+ }
+
+ @Override
+ protected MyChartsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ List<? extends ActivitySample> samples;
+ if (CHARTS_SLEEP_RANGE_24H) {
+ samples = getSamples(db, device);
+ } else {
+ samples = getSamplesofSleep(db, device);
+ }
+ List<? extends SleepScoreSample> sleepScoreSamples = new ArrayList<>();
+ if (supportsSleepScore()) {
+ sleepScoreSamples = getSleepScoreSamples(db, device, getTSStart(), getTSEnd());
+ }
+ MySleepChartsData mySleepChartsData = refreshSleepAmounts(device, samples, sleepScoreSamples);
+
+ if (!CHARTS_SLEEP_RANGE_24H) {
+ if (mySleepChartsData.sleepSessions.size() > 0) {
+ long tstart = mySleepChartsData.sleepSessions.get(0).getSleepStart().getTime() / 1000;
+ long tend = mySleepChartsData.sleepSessions.get(mySleepChartsData.sleepSessions.size() - 1).getSleepEnd().getTime() / 1000;
+
+ for (Iterator<ActivitySample> iterator = (Iterator<ActivitySample>) samples.iterator(); iterator.hasNext(); ) {
+ ActivitySample sample = iterator.next();
+ if (sample.getTimestamp() < tstart || sample.getTimestamp() > tend) {
+ iterator.remove();
+ }
+ }
+ }
+ }
+ DefaultChartsData<LineData> chartsData = refresh(device, samples);
+ Triple<Float, Integer, Integer> hrData = calculateHrData(samples);
+ Triple<Float, Float, Float> intensityData = calculateIntensityData(samples);
+ return new MyChartsData(mySleepChartsData, chartsData, hrData.getLeft(), hrData.getMiddle(), hrData.getRight(), intensityData.getLeft(), intensityData.getMiddle(), intensityData.getRight());
+ }
+
+
+
+ private MySleepChartsData refreshSleepAmounts(GBDevice mGBDevice, List<? extends ActivitySample> samples, List<? extends SleepScoreSample> sleepScoreSamples) {
+ SleepAnalysis sleepAnalysis = new SleepAnalysis();
+ List<SleepSession> sleepSessions = sleepAnalysis.calculateSleepSessions(samples);
+
+ final long lightSleepDuration = calculateLightSleepDuration(sleepSessions);
+ final long deepSleepDuration = calculateDeepSleepDuration(sleepSessions);
+ final long remSleepDuration = calculateRemSleepDuration(sleepSessions);
+ final long awakeSleepDuration = calculateAwakeSleepDuration(sleepSessions);
+ final long totalSeconds = lightSleepDuration + deepSleepDuration + remSleepDuration;
+
+ int sleepScore = 0;
+ if (!sleepScoreSamples.isEmpty()) {
+ sleepScore = sleepScoreSamples.get(sleepScoreSamples.size() - 1).getSleepScore();
+ }
+
+ return new MySleepChartsData(sleepSessions, totalSeconds, awakeSleepDuration, remSleepDuration, deepSleepDuration, lightSleepDuration, sleepScore);
+ }
+
+ private long calculateLightSleepDuration(List<SleepSession> sleepSessions) {
+ long result = 0;
+ for (SleepSession sleepSession : sleepSessions) {
+ result += sleepSession.getLightSleepDuration();
+ }
+ return result;
+ }
+
+ private long calculateDeepSleepDuration(List<SleepSession> sleepSessions) {
+ long result = 0;
+ for (SleepSession sleepSession : sleepSessions) {
+ result += sleepSession.getDeepSleepDuration();
+ }
+ return result;
+ }
+
+ private long calculateRemSleepDuration(List<SleepSession> sleepSessions) {
+ long result = 0;
+ for (SleepSession sleepSession : sleepSessions) {
+ result += sleepSession.getRemSleepDuration();
+ }
+ return result;
+ }
+
+ private long calculateAwakeSleepDuration(List<SleepSession> sleepSessions) {
+ long result = 0;
+ for (SleepSession sleepSession : sleepSessions) {
+ result += sleepSession.getAwakeSleepDuration();
+ }
+ return result;
+ }
+
+ protected void sleepStagesGaugeUpdate(MySleepChartsData pieData) {
+ int[] colors = new int[] {
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_light_sleep_light),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_deep_sleep_light),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_rem_sleep_light),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_awake_sleep_light),
+ };
+ long total = pieData.getTotalSleep() + pieData.getTotalAwake();
+ float[] segments = new float[] {
+ pieData.getTotalLight() > 0 ? (float) pieData.getTotalLight() / total : 0,
+ pieData.getTotalDeep() > 0 ? (float) pieData.getTotalDeep() / total : 0,
+ pieData.getTotalRem() > 0 ? (float) pieData.getTotalRem() / total : 0,
+ pieData.getTotalAwake() > 0 ? (float) pieData.getTotalAwake() / total : 0,
+ };
+ final int width = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 300,
+ GBApplication.getContext().getResources().getDisplayMetrics()
+ );
+ String lowerText = "";
+ if (supportsSleepScore()) {
+ lowerText = GBApplication.getContext().getString(R.string.sleep_score_value, pieData.getSleepScore());
+ }
+ sleepStagesGauge.setImageBitmap(GaugeDrawer.drawCircleGaugeSegmented(
+ width,
+ width / 15,
+ colors,
+ segments,
+ true,
+ String.valueOf(timeStringFormat(pieData.getTotalSleep())),
+ lowerText,
+ getContext()
+ ));
+ }
+
+ private String timeStringFormat(long seconds) {
+ return DateTimeUtils.formatDurationHoursMinutes(seconds, TimeUnit.SECONDS);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(MyChartsData mcd) {
+ MySleepChartsData pieData = mcd.getPieData();
+
+ Date date = new Date((long) this.getTSEnd() * 1000);
+ String formattedDate = new SimpleDateFormat("E, MMM dd").format(date);
+ sleepDateText.setText(formattedDate);
+
+ sleepStagesGaugeUpdate(pieData);
+
+ if (!pieData.sleepSessions.isEmpty()) {
+ awakeSleepTimeText.setText(timeStringFormat(pieData.getTotalAwake()));
+ remSleepTimeText.setText(timeStringFormat(pieData.getTotalRem()));
+ deepSleepTimeText.setText(timeStringFormat(pieData.getTotalDeep()));
+ lightSleepTimeText.setText(timeStringFormat(pieData.getTotalLight()));
+ } else {
+ awakeSleepTimeText.setText("-");
+ remSleepTimeText.setText("-");
+ deepSleepTimeText.setText("-");
+ lightSleepTimeText.setText("-");
+ }
+ if (!supportsRemSleep(getChartsHost().getDevice())) {
+ remSleepTimeTextWrapper.setVisibility(View.GONE);
+ }
+ if (!supportsAwakeSleep(getChartsHost().getDevice())) {
+ awakeSleepTimeTextWrapper.setVisibility(View.GONE);
+ }
+ mSleepchartInfo.setText(buildYouSleptText(pieData));
+ mSleepchartInfo.setMovementMethod(new ScrollingMovementMethod());
+ mActivityChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
+ mActivityChart.getXAxis().setValueFormatter(mcd.getChartsData().getXValueFormatter());
+ mActivityChart.getAxisLeft().setDrawLabels(false);
+
+ mActivityChart.setData(mcd.getChartsData().getData());
+ heartRateMin = mcd.getHeartRateAxisMin();
+ heartRateMax = mcd.getHeartRateAxisMax();
+ intensityTotal = mcd.getIntensityTotal();
+ lowestHrText.setText(String.valueOf(heartRateMin != 0 ? heartRateMin : "-"));
+ highestHrText.setText(String.valueOf(heartRateMax != 0 ? heartRateMax : "-"));
+ movementIntensityText.setText(intensityTotal > 0 ? new DecimalFormat("###.#").format(intensityTotal) : "-");
+ movementIntensityTextWrapper.setVisibility(intensityTotal > 0 ? View.VISIBLE : View.GONE);
+ dummyTile.setVisibility(intensityTotal > 0 ? View.VISIBLE : View.GONE);
+
+ if (!CHARTS_SLEEP_RANGE_24H
+ && supportsHeartrate(getChartsHost().getDevice())
+ && SHOW_CHARTS_AVERAGE) {
+ if (mcd.getHeartRateAxisMax() != 0 || mcd.getHeartRateAxisMin() != 0) {
+ mActivityChart.getAxisRight().setAxisMaximum(mcd.getHeartRateAxisMax() + (mcd.getHeartRateAxisMin() / 2f));
+ mActivityChart.getAxisRight().setAxisMinimum(mcd.getHeartRateAxisMin() / 2f);
+ }
+ LimitLine hrAverage_line = new LimitLine(mcd.getHeartRateAverage());
+ hrAverage_line.setLineColor(Color.RED);
+ hrAverage_line.setLineWidth(1.5f);
+ hrAverage_line.enableDashedLine(15f, 10f, 0f);
+ mActivityChart.getAxisRight().removeAllLimitLines();
+ mActivityChart.getAxisRight().addLimitLine(hrAverage_line);
+ }
+ }
+
+ private Triple<Float, Integer, Integer> calculateHrData(List<? extends ActivitySample> samples) {
+ if (samples.toArray().length < 1) {
+ return Triple.of(0f, 0, 0);
+ }
+
+ List<Integer> heartRateValues = new ArrayList<>();
+ HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance();
+ for (ActivitySample sample : samples) {
+ if (sample.getKind() == ActivityKind.LIGHT_SLEEP || sample.getKind() == ActivityKind.DEEP_SLEEP) {
+ int heartRate = sample.getHeartRate();
+ if (heartRateUtilsInstance.isValidHeartRateValue(heartRate)) {
+ heartRateValues.add(heartRate);
+ }
+ }
+ }
+ if (heartRateValues.toArray().length < 1) {
+ return Triple.of(0f, 0, 0);
+ }
+
+ int min = Collections.min(heartRateValues);
+ int max = Collections.max(heartRateValues);
+ int count = heartRateValues.toArray().length;
+ float sum = calculateSumOfInts(heartRateValues);
+ float average = sum / count;
+ return Triple.of(average, min, max);
+ }
+
+ private float calculateIntensitySum(List<Float> samples) {
+ float result = 0;
+ for (Float sample : samples) {
+ result += sample;
+ }
+ return result;
+ }
+
+ private float calculateSumOfInts(List<Integer> samples) {
+ float result = 0;
+ for (Integer sample : samples) {
+ result += sample;
+ }
+ return result;
+ }
+
+ private Triple<Float, Float, Float> calculateIntensityData(List<? extends ActivitySample> samples) {
+ if (samples.toArray().length < 1) {
+ return Triple.of(0f, 0f, 0f);
+ }
+
+ List<Float> allIntensities = new ArrayList<>();
+
+ for (ActivitySample s : samples) {
+ if (s.getKind() == ActivityKind.LIGHT_SLEEP || s.getKind() == ActivityKind.DEEP_SLEEP) {
+ float intensity = s.getIntensity();
+ allIntensities.add(intensity);
+ }
+ }
+ if (allIntensities.toArray().length < 1) {
+ return Triple.of(0f, 0f, 0f);
+ }
+
+ Float min = Collections.min(allIntensities);
+ Float max = Collections.max(allIntensities);
+ Float sum = calculateIntensitySum(allIntensities);
+
+ return Triple.of(sum, min, max);
+ }
+
+ private String buildYouSleptText(MySleepChartsData pieData) {
+ final StringBuilder result = new StringBuilder();
+ if (!pieData.getSleepSessions().isEmpty()) {
+ for (SleepSession sleepSession : pieData.getSleepSessions()) {
+ if (result.length() > 0) {
+ result.append(" | ");
+ }
+ String from = DateTimeUtils.timeToString(sleepSession.getSleepStart());
+ String to = DateTimeUtils.timeToString(sleepSession.getSleepEnd());
+ result.append(String.format("%s - %s", from, to));
+ }
+ }
+ return result.toString();
+ }
+
+ public boolean supportsSleepScore() {
+ final GBDevice device = getChartsHost().getDevice();
+ return device.getDeviceCoordinator().supportsSleepScore();
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.sleepchart_your_sleep);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_sleepchart, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ mActivityChart = rootView.findViewById(R.id.sleepchart);
+ sleepStagesGauge = rootView.findViewById(R.id.sleep_stages_gauge);
+ mSleepchartInfo = rootView.findViewById(R.id.sleepchart_info);
+ remSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_rem_time);
+ remSleepTimeTextWrapper = rootView.findViewById(R.id.sleep_chart_legend_rem_time_wrapper);
+ awakeSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_awake_time);
+ awakeSleepTimeTextWrapper = rootView.findViewById(R.id.sleep_chart_legend_awake_time_wrapper);
+ deepSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_deep_time);
+ lightSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_light_time);
+ lowestHrText = rootView.findViewById(R.id.sleep_hr_lowest);
+ highestHrText = rootView.findViewById(R.id.sleep_hr_highest);
+ movementIntensityText = rootView.findViewById(R.id.sleep_movement_intensity);
+ movementIntensityTextWrapper = rootView.findViewById(R.id.sleep_chart_legend_movement_intensity_wrapper);
+ dummyTile = rootView.findViewById(R.id.sleep_chart_legend_dummy_tile);
+ sleepDateText = rootView.findViewById(R.id.sleep_date);
+
+ mSleepchartInfo.setMaxLines(sleepLinesLimit);
+
+ setupActivityChart();
+
+ // refresh immediately instead of use refreshIfVisible(), for perceived performance
+ refresh();
+
+ return rootView;
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action.equals(ChartsHost.REFRESH)) {
+ // TODO: use LimitLines to visualize smart alarms?
+ mSmartAlarmFrom = intent.getIntExtra("smartalarm_from", -1);
+ mSmartAlarmTo = intent.getIntExtra("smartalarm_to", -1);
+ mTimestampFrom = intent.getIntExtra("recording_base_timestamp", -1);
+ mSmartAlarmGoneOff = intent.getIntExtra("alarm_gone_off", -1);
+ refresh();
+ } else {
+ super.onReceive(context, intent);
+ }
+ }
+
+ private void setupActivityChart() {
+ mActivityChart.setBackgroundColor(BACKGROUND_COLOR);
+ mActivityChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ configureBarLineChartDefaults(mActivityChart);
+
+ XAxis x = mActivityChart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setDrawLimitLinesBehindData(true);
+
+ YAxis y = mActivityChart.getAxisLeft();
+ y.setDrawGridLines(false);
+ y.setAxisMaximum(1f);
+ y.setAxisMinimum(0);
+ y.setDrawTopYLabelEntry(false);
+ y.setTextColor(CHART_TEXT_COLOR);
+ y.setEnabled(true);
+
+ YAxis yAxisRight = mActivityChart.getAxisRight();
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setEnabled(supportsHeartrate(getChartsHost().getDevice()));
+ yAxisRight.setDrawLabels(true);
+ yAxisRight.setDrawTopYLabelEntry(true);
+ yAxisRight.setTextColor(CHART_TEXT_COLOR);
+ yAxisRight.setAxisMaximum(HeartRateUtils.getInstance().getMaxHeartRate());
+ yAxisRight.setAxisMinimum(HeartRateUtils.getInstance().getMinHeartRate());
+ }
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {
+ List<LegendEntry> legendEntries = new ArrayList<>(4);
+ LegendEntry lightSleepEntry = new LegendEntry();
+ lightSleepEntry.label = getActivity().getString(R.string.sleep_colored_stats_light);
+ lightSleepEntry.formColor = akLightSleep.color;
+ legendEntries.add(lightSleepEntry);
+
+ LegendEntry deepSleepEntry = new LegendEntry();
+ deepSleepEntry.label = getActivity().getString(R.string.sleep_colored_stats_deep);
+ deepSleepEntry.formColor = akDeepSleep.color;
+ legendEntries.add(deepSleepEntry);
+
+ if (supportsRemSleep(getChartsHost().getDevice())) {
+ LegendEntry remSleepEntry = new LegendEntry();
+ remSleepEntry.label = getActivity().getString(R.string.sleep_colored_stats_rem);
+ remSleepEntry.formColor = akRemSleep.color;
+ legendEntries.add(remSleepEntry);
+ }
+
+ if (supportsAwakeSleep(getChartsHost().getDevice())) {
+ LegendEntry awakeSleepEntry = new LegendEntry();
+ awakeSleepEntry.label = getActivity().getString(R.string.abstract_chart_fragment_kind_awake_sleep);
+ awakeSleepEntry.formColor = akAwakeSleep.color;
+ legendEntries.add(awakeSleepEntry);
+ }
+
+ if (supportsHeartrate(getChartsHost().getDevice())) {
+ LegendEntry hrEntry = new LegendEntry();
+ hrEntry.label = HEARTRATE_LABEL;
+ hrEntry.formColor = HEARTRATE_COLOR;
+ legendEntries.add(hrEntry);
+ if (!CHARTS_SLEEP_RANGE_24H && SHOW_CHARTS_AVERAGE) {
+ LegendEntry hrAverageEntry = new LegendEntry();
+ hrAverageEntry.label = HEARTRATE_AVERAGE_LABEL;
+ hrAverageEntry.formColor = Color.RED;
+ legendEntries.add(hrAverageEntry);
+ }
+ }
+ chart.getLegend().setCustom(legendEntries);
+ chart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ }
+
+ @Override
+ protected List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ // Temporary fix for totally wrong sleep amounts.
+ return super.getAllSamples(db, device, tsFrom, tsTo);
+ }
+
+ protected List<SleepScoreSample> getSleepScoreSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ TimeSampleProvider<? extends SleepScoreSample> provider = device.getDeviceCoordinator().getSleepScoreProvider(device, db.getDaoSession());
+ return (List<SleepScoreSample>) provider.getAllSamples(tsFrom * 1000L, tsTo * 1000L);
+ }
+
+ @Override
+ protected void renderCharts() {
+ mActivityChart.animateX(ANIM_TIME, Easing.EaseInOutQuart);
+ }
+
+ private static class MySleepChartsData extends ChartsData {
+ private long totalSleep;
+ private long totalAwake;
+ private long totalRem;
+ private long totalDeep;
+ private long totalLight;
+ private int sleepScore;
+ private final List<SleepSession> sleepSessions;
+
+ public MySleepChartsData(List<SleepSession> sleepSessions, long totalSleep, long totalAwake, long totalRem, long totalDeep, long totalLight, int sleepScore) {
+ this.sleepSessions = sleepSessions;
+ this.totalAwake = totalAwake;
+ this.totalSleep = totalSleep;
+ this.totalRem = totalRem;
+ this.totalDeep = totalDeep;
+ this.totalLight = totalLight;
+ this.sleepScore = sleepScore;
+ }
+
+ public long getTotalSleep() {
+ return totalSleep;
+ }
+
+ public long getTotalAwake() {
+ return totalAwake;
+ }
+
+ public long getTotalRem() {
+ return totalRem;
+ }
+
+ public long getTotalDeep() {
+ return totalDeep;
+ }
+
+ public long getTotalLight() {
+ return totalLight;
+ }
+
+ public int getSleepScore() {return sleepScore;}
+
+ public List<SleepSession> getSleepSessions() {
+ return sleepSessions;
+ }
+ }
+
+ protected static class MyChartsData extends ChartsData {
+ private final DefaultChartsData<LineData> chartsData;
+ private final MySleepChartsData pieData;
+ private final float heartRateAverage;
+ private int heartRateAxisMax;
+ private int heartRateAxisMin;
+ private float intensityAxisMax;
+ private float intensityAxisMin;
+ private float intensityTotal;
+
+ public MyChartsData(MySleepChartsData pieData, DefaultChartsData<LineData> chartsData, float heartRateAverage, int heartRateAxisMin, int heartRateAxisMax, float intensityTotal, float intensityAxisMin, float intensityAxisMax) {
+ this.pieData = pieData;
+ this.chartsData = chartsData;
+ this.heartRateAverage = heartRateAverage;
+ this.heartRateAxisMax = heartRateAxisMax;
+ this.heartRateAxisMin = heartRateAxisMin;
+ this.intensityTotal = intensityTotal;
+ this.intensityAxisMin = intensityAxisMin;
+ this.intensityAxisMax = intensityAxisMax;
+ }
+
+ public MySleepChartsData getPieData() {
+ return pieData;
+ }
+
+ public DefaultChartsData<LineData> getChartsData() {
+ return chartsData;
+ }
+
+ public float getHeartRateAverage() {
+ return heartRateAverage;
+ }
+
+ public int getHeartRateAxisMax() {
+ return heartRateAxisMax;
+ }
+
+ public int getHeartRateAxisMin() { return heartRateAxisMin; }
+
+ public float getIntensityAxisMax() {
+ return intensityAxisMax;
+ }
+
+ public float getIntensityAxisMin() {
+ return intensityAxisMin;
+ }
+
+ public float getIntensityTotal() { return intensityTotal;}
+
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsData.java
new file mode 100644
index 0000000..e09601a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsData.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import com.github.mikephil.charting.data.ChartData;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+
+public class DefaultChartsData<T extends ChartData<?>> extends ChartsData {
+ private final T data;
+ private final ValueFormatter xValueFormatter;
+
+ public DefaultChartsData(final T data, final ValueFormatter xValueFormatter) {
+ this.xValueFormatter = xValueFormatter;
+ this.data = data;
+ }
+
+ public ValueFormatter getXValueFormatter() {
+ return xValueFormatter;
+ }
+
+ public T getData() {
+ return data;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HRVStatusFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HRVStatusFragment.java
new file mode 100644
index 0000000..d1b2cd8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HRVStatusFragment.java
@@ -0,0 +1,415 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Daniele Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import static java.util.stream.Collectors.toCollection;
+
+import android.os.Build;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardHrvWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.GaugeDrawer;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+
+public class HRVStatusFragment extends AbstractChartFragment<HRVStatusFragment.HRVStatusWeeklyData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(HRVStatusFragment.class);
+ protected final int TOTAL_DAYS = 7;
+
+ protected GaugeDrawer gaugeDrawer;
+ private ImageView mHRVStatusGauge;
+ private LineChart mWeeklyHRVStatusChart;
+ private TextView mHRVStatusSevenDaysAvg;
+ private TextView mHRVStatusSevenDaysAvgStatus; // Balanced, Unbalanced, Low
+ private TextView mHRVStatusLastNight;
+ private TextView mHRVStatusLastNight5MinHighest;
+ private TextView mHRVStatusDayAvg;
+ private TextView mHRVStatusBaseline;
+ private TextView mDateView;
+ private TextView mHRVGaugeValue;
+ private TextView mHRVGaugeStatus;
+ protected int CHART_TEXT_COLOR;
+ protected int LEGEND_TEXT_COLOR;
+ protected int TEXT_COLOR;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_hrv_status, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ mWeeklyHRVStatusChart = rootView.findViewById(R.id.hrv_weekly_line_chart);
+ mHRVStatusLastNight = rootView.findViewById(R.id.hrv_status_last_night);
+ mHRVStatusSevenDaysAvg = rootView.findViewById(R.id.hrv_status_seven_days_avg);
+ mHRVStatusSevenDaysAvgStatus = rootView.findViewById(R.id.hrv_status_seven_days_avg_rate);
+ mHRVStatusLastNight5MinHighest = rootView.findViewById(R.id.hrv_status_last_night_highest_5);
+ mHRVStatusDayAvg = rootView.findViewById(R.id.hrv_status_day_avg);
+ mHRVStatusBaseline = rootView.findViewById(R.id.hrv_status_baseline);
+ mDateView = rootView.findViewById(R.id.hrv_status_date_view);
+ mHRVStatusGauge = rootView.findViewById(R.id.hrv_status_gauge_bar);
+ mHRVGaugeValue = rootView.findViewById(R.id.hrv_gauge_value);
+ mHRVGaugeStatus = rootView.findViewById(R.id.hrv_gauge_status);
+
+ gaugeDrawer = new GaugeDrawer();
+ setupLineChart();
+ refresh();
+
+ return rootView;
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.pref_header_hrv_status);
+ }
+
+ @Override
+ protected void init() {
+ TEXT_COLOR = GBApplication.getTextColor(requireContext());
+ LEGEND_TEXT_COLOR = GBApplication.getTextColor(requireContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+ }
+
+ @Override
+ protected HRVStatusWeeklyData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ Calendar day = Calendar.getInstance();
+ day.setTime(getEndDate());
+
+ List<HRVStatusDayData> weeklyData = getWeeklyData(db, day, device);
+ return new HRVStatusWeeklyData(weeklyData);
+ }
+
+ @Override
+ protected void renderCharts() {
+ mWeeklyHRVStatusChart.invalidate();
+ }
+
+ protected LineDataSet createDataSet(final List<Entry> values) {
+ final LineDataSet lineDataSet = new LineDataSet(values, getString(R.string.hrv_status_day_avg));
+ lineDataSet.setColor(getResources().getColor(R.color.hrv_status_char_line_color));
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setLineWidth(2f);
+ lineDataSet.setFillAlpha(255);
+ lineDataSet.setCircleRadius(5f);
+ lineDataSet.setDrawCircles(true);
+ lineDataSet.setDrawCircleHole(true);
+ lineDataSet.setCircleColor(getResources().getColor(R.color.hrv_status_char_line_color));
+ lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
+ lineDataSet.setDrawValues(true);
+ lineDataSet.setValueTextSize(10f);
+ lineDataSet.setValueTextColor(CHART_TEXT_COLOR);
+ lineDataSet.setValueFormatter(new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ return String.format(Locale.ROOT, "%d", (int) value);
+ }
+ });
+ return lineDataSet;
+ }
+
+ @Override
+ protected void updateChartsnUIThread(HRVStatusWeeklyData weeklyData) {
+ String formattedDate = new SimpleDateFormat("E, MMM dd").format(getEndDate());
+ mDateView.setText(formattedDate);
+
+ mWeeklyHRVStatusChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
+ List<Entry> lineEntries = new ArrayList<>();
+ final List<ILineDataSet> lineDataSets = new ArrayList<>();
+ weeklyData.getDaysData().forEach((HRVStatusDayData day) -> {
+ if (day.status.getNum() > 0) {
+ lineEntries.add(new Entry(day.i, day.dayAvg));
+ } else {
+ if (!lineEntries.isEmpty()) {
+ List<Entry> clone = new ArrayList<>(lineEntries.size());
+ clone.addAll(lineEntries);
+ lineDataSets.add(createDataSet(clone));
+ lineEntries.clear();
+ }
+ }
+ });
+ if (!lineEntries.isEmpty()) {
+ lineDataSets.add(createDataSet(lineEntries));
+ }
+
+ List<LegendEntry> legendEntries = new ArrayList<>(1);
+ LegendEntry activityEntry = new LegendEntry();
+ activityEntry.label = getString(R.string.hrv_status_day_avg_legend);
+ activityEntry.formColor = getResources().getColor(R.color.hrv_status_char_line_color);
+ legendEntries.add(activityEntry);
+ mWeeklyHRVStatusChart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ mWeeklyHRVStatusChart.getLegend().setCustom(legendEntries);
+
+ final LineData lineData = new LineData(lineDataSets);
+ mWeeklyHRVStatusChart.setData(lineData);
+
+ final XAxis x = mWeeklyHRVStatusChart.getXAxis();
+ x.setValueFormatter(getHRVStatusChartDayValueFormatter(weeklyData));
+
+ HRVStatusDayData today = weeklyData.getCurrentDay();
+ mHRVStatusSevenDaysAvg.setText(today.weeklyAvg > 0 ? getString(R.string.hrv_status_unit, today.weeklyAvg) : "-");
+ mHRVStatusLastNight.setText(today.lastNight > 0 ? getString(R.string.hrv_status_unit, today.lastNight) : "-");
+ mHRVStatusLastNight5MinHighest.setText(today.lastNight5MinHigh > 0 ? getString(R.string.hrv_status_unit, today.lastNight5MinHigh) : "-");
+ mHRVStatusDayAvg.setText(today.dayAvg > 0 ? getString(R.string.hrv_status_unit, today.dayAvg) : "-");
+ mHRVStatusBaseline.setText(today.baseLineBalancedLower > 0 && today.baseLineBalancedUpper > 0 ? getString(R.string.hrv_status_baseline, today.baseLineBalancedLower, today.baseLineBalancedUpper) : "-");
+ switch (today.status) {
+ case NONE:
+ mHRVStatusSevenDaysAvgStatus.setText("-");
+ mHRVStatusSevenDaysAvgStatus.setTextColor(TEXT_COLOR);
+ mHRVGaugeStatus.setText("");
+ mHRVGaugeStatus.setTextColor(TEXT_COLOR);
+ break;
+ case POOR:
+ mHRVStatusSevenDaysAvgStatus.setText(getString(R.string.hrv_status_poor));
+ mHRVStatusSevenDaysAvgStatus.setTextColor(getResources().getColor(R.color.hrv_status_poor));
+ mHRVGaugeStatus.setText(getString(R.string.hrv_status_poor));
+ mHRVGaugeStatus.setTextColor(getResources().getColor(R.color.hrv_status_poor));
+ break;
+ case LOW:
+ mHRVStatusSevenDaysAvgStatus.setText(getString(R.string.hrv_status_low));
+ mHRVStatusSevenDaysAvgStatus.setTextColor(getResources().getColor(R.color.hrv_status_low));
+ mHRVGaugeStatus.setText(getString(R.string.hrv_status_low));
+ mHRVGaugeStatus.setTextColor(getResources().getColor(R.color.hrv_status_low));
+ break;
+ case UNBALANCED:
+ mHRVStatusSevenDaysAvgStatus.setText(getString(R.string.hrv_status_unbalanced));
+ mHRVStatusSevenDaysAvgStatus.setTextColor(getResources().getColor(R.color.hrv_status_unbalanced));
+ mHRVGaugeStatus.setText(getString(R.string.hrv_status_unbalanced));
+ mHRVGaugeStatus.setTextColor(getResources().getColor(R.color.hrv_status_unbalanced));
+ break;
+ case BALANCED:
+ mHRVStatusSevenDaysAvgStatus.setText(getString(R.string.hrv_status_balanced));
+ mHRVStatusSevenDaysAvgStatus.setTextColor(getResources().getColor(R.color.hrv_status_balanced));
+ mHRVGaugeStatus.setText(getString(R.string.hrv_status_balanced));
+ mHRVGaugeStatus.setTextColor(getResources().getColor(R.color.hrv_status_balanced));
+ break;
+ }
+ float value = DashboardHrvWidget.calculateGaugeValue(today.weeklyAvg, today.baseLineLowUpper, today.baseLineBalancedLower, today.baseLineBalancedUpper);
+ final String valueText = value > 0 ? getString(R.string.hrv_status_unit, today.weeklyAvg) : getString(R.string.stats_empty_value);
+ mHRVGaugeValue.setText(valueText);
+ gaugeDrawer.drawSegmentedGauge(mHRVStatusGauge, DashboardHrvWidget.getColors(), DashboardHrvWidget.getSegments(), value, false, true);
+ }
+
+ private List<HRVStatusDayData> getWeeklyData(DBHandler db, Calendar day, GBDevice device) {
+ day = DateTimeUtils.dayStart(day);
+ day.add(Calendar.DATE, -TOTAL_DAYS + 1);
+
+ List<HRVStatusDayData> weeklyData = new ArrayList<>();
+ for (int counter = 0; counter < TOTAL_DAYS; counter++) {
+ int startTs = (int) (day.getTimeInMillis() / 1000);
+ int endTs = startTs + 24 * 60 * 60 - 1;
+ Optional<? extends HrvSummarySample> latestSummarySample = getSamples(db, device, startTs, endTs)
+ .stream()
+ .max(Comparator.comparingLong(HrvSummarySample::getTimestamp));
+ List<? extends HrvValueSample> valueSamples = getHrvValueSamples(db, device, startTs, endTs);
+
+ int avgHRV = (int) valueSamples.stream().mapToInt(HrvValueSample::getValue).average().orElse(0);
+ if (latestSummarySample.isPresent()) {
+ final HrvSummarySample sample = latestSummarySample.get();
+ Calendar finalDay = (Calendar) day.clone();
+ weeklyData.add(new HRVStatusDayData(
+ finalDay,
+ counter,
+ sample.getTimestamp(),
+ avgHRV,
+ sample.getWeeklyAverage() != null ? sample.getWeeklyAverage() : 0,
+ sample.getLastNightAverage() != null ? sample.getLastNightAverage() : 0,
+ sample.getLastNight5MinHigh() != null ? sample.getLastNight5MinHigh() : 0,
+ sample.getBaselineLowUpper() != null ? sample.getBaselineLowUpper() : 0,
+ sample.getBaselineBalancedLower() != null ? sample.getBaselineBalancedLower() : 0,
+ sample.getBaselineBalancedUpper() != null ? sample.getBaselineBalancedUpper() : 0,
+ sample.getStatus() != null ? sample.getStatus() : HrvSummarySample.Status.NONE
+ ));
+ } else {
+ HRVStatusDayData d = new HRVStatusDayData(
+ (Calendar) day.clone(),
+ counter,
+ 0,
+ avgHRV,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ HrvSummarySample.Status.NONE
+ );
+ weeklyData.add(d);
+ }
+
+ day.add(Calendar.DATE, 1);
+ }
+ return weeklyData;
+ }
+
+ private List<? extends HrvSummarySample> getSamples(final DBHandler db, final GBDevice device, int tsFrom, int tsTo) {
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final TimeSampleProvider<? extends HrvSummarySample> sampleProvider = coordinator.getHrvSummarySampleProvider(device, db.getDaoSession());
+ return sampleProvider.getAllSamples(tsFrom * 1000L, tsTo * 1000L);
+ }
+
+ public List<? extends HrvValueSample> getHrvValueSamples(final DBHandler db, final GBDevice device, int tsFrom, int tsTo) {
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final TimeSampleProvider<? extends HrvValueSample> sampleProvider = coordinator.getHrvValueSampleProvider(device, db.getDaoSession());
+ return sampleProvider.getAllSamples(tsFrom * 1000L, tsTo * 1000L);
+ }
+
+ private void setupLineChart() {
+ mWeeklyHRVStatusChart.getDescription().setEnabled(false);
+ mWeeklyHRVStatusChart.setTouchEnabled(false);
+ mWeeklyHRVStatusChart.setPinchZoom(false);
+ mWeeklyHRVStatusChart.setDoubleTapToZoomEnabled(false);
+
+
+ final XAxis xAxisBottom = mWeeklyHRVStatusChart.getXAxis();
+ xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
+ xAxisBottom.setDrawLabels(true);
+ xAxisBottom.setDrawGridLines(false);
+ xAxisBottom.setEnabled(true);
+ xAxisBottom.setDrawLimitLinesBehindData(true);
+ xAxisBottom.setAxisMaximum(6 + 0.5f);
+ xAxisBottom.setAxisMinimum(0 - 0.5f);
+ xAxisBottom.setTextColor(CHART_TEXT_COLOR);
+
+ final YAxis yAxisLeft = mWeeklyHRVStatusChart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setAxisMaximum(120);
+ yAxisLeft.setAxisMinimum(0);
+ yAxisLeft.setDrawTopYLabelEntry(false);
+ yAxisLeft.setEnabled(true);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+
+ final YAxis yAxisRight = mWeeklyHRVStatusChart.getAxisRight();
+ yAxisRight.setEnabled(true);
+ yAxisRight.setDrawLabels(false);
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setDrawAxisLine(true);
+ }
+
+ ValueFormatter getHRVStatusChartDayValueFormatter(HRVStatusWeeklyData weeklyData) {
+ return new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ return formatHRVStatusChartValue((long) value, weeklyData);
+ }
+ };
+ }
+
+ protected String formatHRVStatusChartValue(long value, HRVStatusWeeklyData weeklyData) {
+ HRVStatusDayData day = weeklyData.getDay((int) value);
+
+ SimpleDateFormat formatLetterDay = new SimpleDateFormat("EEE", Locale.getDefault());
+ return formatLetterDay.format(new Date(day.day.getTimeInMillis()));
+ }
+
+ protected void setupLegend(Chart<?> chart) {}
+
+ protected static class HRVStatusWeeklyData extends ChartsData {
+ private final List<HRVStatusDayData> data;
+
+ public HRVStatusWeeklyData(final List<HRVStatusDayData> chartsData) {
+ this.data = chartsData;
+ }
+
+ public HRVStatusDayData getDay(int i) {
+ return this.data.get(i);
+ }
+
+ public HRVStatusDayData getCurrentDay() {
+ return this.data.get(this.data.size() - 1);
+ }
+
+ public List<HRVStatusDayData> getDaysData() {
+ return data;
+ }
+ }
+
+ protected static class HRVStatusDayData {
+ public Integer i;
+ public long timestamp;
+ public Integer weeklyAvg;
+ public Integer lastNight;
+ public Integer lastNight5MinHigh;
+ public Integer dayAvg;
+ public Integer baseLineBalancedLower;
+ public Integer baseLineBalancedUpper;
+ public Integer baseLineLowUpper;
+ public HrvSummarySample.Status status;
+ public Calendar day;
+
+ public HRVStatusDayData(Calendar day,
+ int i, long timestamp,
+ Integer dayAvg,
+ Integer weeklyAvg,
+ Integer lastNight,
+ Integer lastNight5MinHigh,
+ Integer baseLineLowUpper,
+ Integer baseLineBalancedLower,
+ Integer baseLineBalancedUpper,
+ HrvSummarySample.Status status) {
+ this.lastNight = lastNight;
+ this.weeklyAvg = weeklyAvg;
+ this.lastNight5MinHigh = lastNight5MinHigh;
+ this.i = i;
+ this.timestamp = timestamp;
+ this.status = status;
+ this.day = day;
+ this.dayAvg = dayAvg;
+ this.baseLineLowUpper = baseLineLowUpper;
+ this.baseLineBalancedLower = baseLineBalancedLower;
+ this.baseLineBalancedUpper = baseLineBalancedUpper;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HeartRateDailyFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HeartRateDailyFragment.java
new file mode 100644
index 0000000..10e19c9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HeartRateDailyFragment.java
@@ -0,0 +1,322 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.graphics.Color;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.LimitLine;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.util.Accumulator;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class HeartRateDailyFragment extends AbstractChartFragment<HeartRateDailyFragment.HeartRateData> {
+
+ protected int HEARTRATE_COLOR;
+ protected int CHART_TEXT_COLOR;
+ protected int BACKGROUND_COLOR;
+ protected int DESCRIPTION_COLOR;
+ protected int LEGEND_TEXT_COLOR;
+
+ private TextView mDateView;
+ private TextView hrResting;
+ private TextView hrAverage;
+ private TextView hrMinimum;
+ private TextView hrMaximum;
+ private LineChart hrLineChart;
+
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_heart_rate, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ mDateView = rootView.findViewById(R.id.hr_date_view);
+ hrLineChart = rootView.findViewById(R.id.heart_rate_line_chart);
+ hrResting = rootView.findViewById(R.id.hr_resting);
+ hrAverage = rootView.findViewById(R.id.hr_average);
+ hrMinimum = rootView.findViewById(R.id.hr_minimum);
+ hrMaximum = rootView.findViewById(R.id.hr_maximum);
+ final LinearLayout heartRateRestingWrapper = rootView.findViewById(R.id.hr_resting_wrapper);
+
+ setupChart();
+ refresh();
+ setupLegend(hrLineChart);
+
+ if (!supportsHeartRateRestingMeasurement()) {
+ heartRateRestingWrapper.setVisibility(View.GONE);
+ }
+
+ return rootView;
+ }
+
+ public boolean supportsHeartRateRestingMeasurement() {
+ final GBDevice device = getChartsHost().getDevice();
+ return device.getDeviceCoordinator().supportsHeartRateRestingMeasurement(device);
+ }
+
+ protected List<? extends AbstractActivitySample> getActivitySamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ SampleProvider<? extends ActivitySample> provider = device.getDeviceCoordinator().getSampleProvider(device, db.getDaoSession());
+ return provider.getAllActivitySamplesHighRes(tsFrom, tsTo);
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.heart_rate);
+ }
+
+ @Override
+ protected void init() {
+ Prefs prefs = GBApplication.getPrefs();
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext());
+ DESCRIPTION_COLOR = LEGEND_TEXT_COLOR = GBApplication.getTextColor(getContext());
+ if (prefs.getBoolean("chart_heartrate_color", false)) {
+ HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_alternative);
+ }else{
+ HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate);
+ }
+ }
+
+ @Override
+ protected HeartRateDailyFragment.HeartRateData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ Calendar day = Calendar.getInstance();
+ day.setTime(chartsHost.getEndDate());
+ day.add(Calendar.DATE, 0);
+ day.set(Calendar.HOUR_OF_DAY, 0);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ day.add(Calendar.HOUR, 0);
+ int startTs = (int) (day.getTimeInMillis() / 1000);
+ int endTs = startTs + 24 * 60 * 60 - 1;
+
+ List<? extends ActivitySample> samples = getActivitySamples(db, device, startTs, endTs);
+
+ int restingHeartRate = -1;
+ if (supportsHeartRateRestingMeasurement()) {
+ restingHeartRate = device.getDeviceCoordinator()
+ .getHeartRateRestingSampleProvider(device, db.getDaoSession())
+ .getAllSamples(startTs * 1000L, endTs * 1000L)
+ .stream()
+ .max(Comparator.comparingLong(HeartRateSample::getTimestamp))
+ .map(HeartRateSample::getHeartRate)
+ .orElse(-1);
+ }
+
+ return new HeartRateData(samples, restingHeartRate);
+ }
+
+ @Override
+ protected void renderCharts() {
+ hrLineChart.invalidate();
+ }
+
+ private void setupChart() {
+ hrLineChart.setBackgroundColor(BACKGROUND_COLOR);
+ hrLineChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ hrLineChart.getDescription().setEnabled(false);
+
+
+ XAxis x = hrLineChart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setDrawLimitLinesBehindData(true);
+ x.setPosition(XAxis.XAxisPosition.BOTTOM);
+ x.setAxisMinimum(0f);
+ x.setAxisMaximum(86400f);
+
+ YAxis y = hrLineChart.getAxisLeft();
+ y.setDrawGridLines(false);
+ y.setDrawTopYLabelEntry(true);
+ y.setTextColor(CHART_TEXT_COLOR);
+ y.setEnabled(true);
+ y.setAxisMaximum(HeartRateUtils.getInstance().getMaxHeartRate());
+ y.setAxisMinimum(HeartRateUtils.getInstance().getMinHeartRate());
+
+ YAxis yAxisRight = hrLineChart.getAxisRight();
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setDrawLabels(true);
+ yAxisRight.setDrawTopYLabelEntry(true);
+ yAxisRight.setTextColor(CHART_TEXT_COLOR);
+ yAxisRight.setAxisMaximum(HeartRateUtils.getInstance().getMaxHeartRate());
+ yAxisRight.setAxisMinimum(HeartRateUtils.getInstance().getMinHeartRate());
+
+ refresh();
+ }
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {
+ List<LegendEntry> legendEntries = new ArrayList<>(1);
+ LegendEntry hrEntry = new LegendEntry();
+ hrEntry.label = getTitle();
+ hrEntry.formColor = HEARTRATE_COLOR;
+ legendEntries.add(hrEntry);
+
+ if (GBApplication.getPrefs().getBoolean("charts_show_average", true)) {
+ LegendEntry hrAverageEntry = new LegendEntry();
+ hrAverageEntry.label = getString(R.string.hr_average);
+ hrAverageEntry.formColor = Color.RED;
+ legendEntries.add(hrAverageEntry);
+ }
+
+ //if (supportsHeartRateRestingMeasurement()) {
+ // LegendEntry hrRestingEntry = new LegendEntry();
+ // hrRestingEntry.label = getString(R.string.hr_resting);
+ // hrRestingEntry.formColor = Color.GRAY;
+ // legendEntries.add(hrRestingEntry);
+ //}
+
+ chart.getLegend().setCustom(legendEntries);
+ chart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ chart.getLegend().setWordWrapEnabled(true);
+ }
+
+ protected LineDataSet createHeartRateDataSet(final List<Entry> values) {
+ LineDataSet dataSet = new LineDataSet(values, "Heart Rate");
+ dataSet.setLineWidth(1.5f);
+ dataSet.setMode(LineDataSet.Mode.HORIZONTAL_BEZIER);
+ dataSet.setCubicIntensity(0.1f);
+ dataSet.setDrawCircles(false);
+ dataSet.setDrawValues(true);
+ dataSet.setAxisDependency(YAxis.AxisDependency.RIGHT);
+ dataSet.setColor(HEARTRATE_COLOR);
+ dataSet.setValueTextColor(CHART_TEXT_COLOR);
+ return dataSet;
+ }
+
+ @Override
+ protected void updateChartsnUIThread(HeartRateDailyFragment.HeartRateData data) {
+ Calendar day = Calendar.getInstance();
+ day.setTime(getEndDate());
+ day.add(Calendar.DATE, 0);
+ day.set(Calendar.HOUR_OF_DAY, 0);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ day.add(Calendar.HOUR, 0);
+ int startTs = (int) (day.getTimeInMillis() / 1000);
+ int endTs = startTs + 24 * 60 * 60 - 1;
+ Date date = new Date((long) endTs * 1000);
+ String formattedDate = new SimpleDateFormat("E, MMM dd").format(date);
+ mDateView.setText(formattedDate);
+
+ HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance();
+ final TimestampTranslation tsTranslation = new TimestampTranslation();
+ final List<Entry> lineEntries = new ArrayList<>();
+ List<? extends ActivitySample> samples = data.samples;
+ final Accumulator accumulator = new Accumulator();
+
+ final List<ILineDataSet> lineDataSets = new ArrayList<>();
+ int lastTsShorten = 0;
+ for (int i =0; i < samples.size(); i++) {
+ final ActivitySample sample = samples.get(i);
+ final int tsShorten = tsTranslation.shorten(sample.getTimestamp());
+ if (!heartRateUtilsInstance.isValidHeartRateValue(sample.getHeartRate())) {
+ continue;
+ }
+ if (lastTsShorten == 0 || (tsShorten - lastTsShorten) <= 60 * HeartRateUtils.MAX_HR_MEASUREMENTS_GAP_MINUTES) {
+ lineEntries.add(new Entry(tsShorten, sample.getHeartRate()));
+ } else {
+ if (!lineEntries.isEmpty()) {
+ List<Entry> clone = new ArrayList<>(lineEntries.size());
+ clone.addAll(lineEntries);
+ lineDataSets.add(createHeartRateDataSet(clone));
+ lineEntries.clear();
+ }
+ }
+ lastTsShorten = tsShorten;
+ lineEntries.add(new Entry(tsShorten, sample.getHeartRate()));
+ accumulator.add(sample.getHeartRate());
+ }
+
+ if (!lineEntries.isEmpty()) {
+ lineDataSets.add(createHeartRateDataSet(lineEntries));
+ }
+
+ final int average = accumulator.getCount() > 0 ? (int) Math.round(accumulator.getAverage()) : -1;
+ final int minimum = accumulator.getCount() > 0 ? (int) Math.round(accumulator.getMin()) : -1;
+ final int maximum = accumulator.getCount() > 0 ? (int) Math.round(accumulator.getMax()) : -1;
+
+ hrAverage.setText(average > 0 ? getString(R.string.bpm_value_unit, average) : "-");
+ hrMinimum.setText(minimum > 0 ? getString(R.string.bpm_value_unit, minimum) : "-");
+ hrMaximum.setText(maximum > 0 ? getString(R.string.bpm_value_unit, maximum) : "-");
+ hrResting.setText(data.restingHeartRate > 0 ? getString(R.string.bpm_value_unit, data.restingHeartRate) : "-");
+
+ if (minimum > 0) {
+ hrLineChart.getAxisLeft().setAxisMinimum(Math.max(minimum - 30, 0));
+ hrLineChart.getAxisRight().setAxisMinimum(Math.max(minimum - 30, 0));
+ }
+ if (maximum > 0) {
+ hrLineChart.getAxisLeft().setAxisMaximum(maximum + 30);
+ hrLineChart.getAxisRight().setAxisMaximum(maximum + 30);
+ }
+
+ hrLineChart.getXAxis().setValueFormatter(new SampleXLabelFormatter(tsTranslation, "HH:mm"));
+ hrLineChart.setData(new LineData(lineDataSets));
+
+ hrLineChart.getAxisLeft().removeAllLimitLines();
+
+ if (average > 0 && GBApplication.getPrefs().getBoolean("charts_show_average", true)) {
+ final LimitLine averageLine = new LimitLine(average);
+ averageLine.setLineWidth(1.5f);
+ averageLine.enableDashedLine(15f, 10f, 0f);
+ averageLine.setLineColor(Color.RED);
+ hrLineChart.getAxisLeft().addLimitLine(averageLine);
+ }
+
+ //if (data.restingHeartRate > 0) {
+ // final LimitLine restingLine = new LimitLine(data.restingHeartRate);
+ // restingLine.setLineWidth(1.5f);
+ // restingLine.enableDashedLine(15f, 10f, 0f);
+ // restingLine.setLineColor(Color.GRAY);
+ // hrLineChart.getAxisLeft().addLimitLine(restingLine);
+ //}
+ }
+
+ protected static class HeartRateData extends ChartsData {
+ public List<? extends ActivitySample> samples;
+ public int restingHeartRate;
+
+ protected HeartRateData(List<? extends ActivitySample> samples, int restingHeartRate) {
+ this.samples = samples;
+ this.restingHeartRate = restingHeartRate;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java
new file mode 100644
index 0000000..a807249
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java
@@ -0,0 +1,547 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Cre3per,
+ Daniele Gobbetti, Dikay900, Jos茅 Rebelo, Pavel Elagin
+
+ 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.activities.charts;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.graphics.Paint;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.github.mikephil.charting.charts.BarLineChartBase;
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.components.LimitLine;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.BarData;
+import com.github.mikephil.charting.data.BarDataSet;
+import com.github.mikephil.charting.data.BarEntry;
+import com.github.mikephil.charting.data.ChartData;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.utils.Utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class LiveActivityFragment extends AbstractActivityChartFragment<ChartsData> {
+ private static final Logger LOG = LoggerFactory.getLogger(LiveActivityFragment.class);
+ private static final int MAX_STEPS_PER_MINUTE = 300;
+ private static final int MIN_STEPS_PER_MINUTE = 60;
+ private static final int RESET_COUNT = 10; // reset the max steps per minute value every 10s
+
+ private BarEntry totalStepsEntry;
+ private BarEntry stepsPerMinuteEntry;
+ private BarDataSet mStepsPerMinuteData;
+ private BarDataSet mTotalStepsData;
+ private LineDataSet mHistorySet;
+ private BarLineChartBase mStepsPerMinuteHistoryChart;
+ private CustomBarChart mStepsPerMinuteCurrentChart;
+ private CustomBarChart mTotalStepsChart;
+ private TextView mMaxHeartRateView;
+
+ private final Steps mSteps = new Steps();
+ private ScheduledExecutorService pulseScheduler;
+ private int maxStepsResetCounter;
+ private LineDataSet mHeartRateSet;
+ private int mHeartRate;
+ private int mMaxHeartRate = 0;
+ private TimestampTranslation tsTranslation;
+
+ private class Steps {
+ private int steps;
+ private int lastTimestamp;
+ private int currentStepsPerMinute;
+ private int maxStepsPerMinute;
+ private int lastStepsPerMinute;
+
+ public int getStepsPerMinute(boolean reset) {
+ lastStepsPerMinute = currentStepsPerMinute;
+ int result = currentStepsPerMinute;
+ if (reset) {
+ currentStepsPerMinute = 0;
+ }
+ return result;
+ }
+
+ public int getTotalSteps() {
+ return steps;
+ }
+
+ public int getMaxStepsPerMinute() {
+ return maxStepsPerMinute;
+ }
+
+ public void updateCurrentSteps(int stepsDelta, int timestamp) {
+ try {
+ if (steps == 0) {
+ steps += stepsDelta;
+ lastTimestamp = timestamp;
+ return;
+ }
+
+ int timeDelta = timestamp - lastTimestamp;
+ currentStepsPerMinute = calculateStepsPerMinute(stepsDelta, timeDelta);
+ if (currentStepsPerMinute > maxStepsPerMinute) {
+ maxStepsPerMinute = currentStepsPerMinute;
+ maxStepsResetCounter = 0;
+ }
+ steps += stepsDelta;
+ lastTimestamp = timestamp;
+ } catch (Exception ex) {
+ GB.toast(LiveActivityFragment.this.getContext(), ex.getMessage(), Toast.LENGTH_SHORT, GB.ERROR, ex);
+ }
+ }
+
+ private int calculateStepsPerMinute(int stepsDelta, int seconds) {
+ if (stepsDelta == 0) {
+ return 0; // not walking or not enough data per mills?
+ }
+ if (seconds <= 0) {
+ throw new IllegalArgumentException("delta in seconds is <= 0 -- time change?");
+ }
+
+ int oneMinute = 60;
+ float factor = oneMinute / seconds;
+ int result = (int) (stepsDelta * factor);
+ if (result > MAX_STEPS_PER_MINUTE) {
+ // ignore, return previous value instead
+ result = lastStepsPerMinute;
+ }
+ return result;
+ }
+ }
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ switch (action) {
+ case DeviceService.ACTION_REALTIME_SAMPLES: {
+ ActivitySample sample = (ActivitySample) intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE);
+ addSample(sample);
+ break;
+ }
+ }
+ }
+ };
+
+ private void addSample(ActivitySample sample) {
+ int heartRate = sample.getHeartRate();
+ int timestamp = tsTranslation.shorten(sample.getTimestamp());
+ if (HeartRateUtils.getInstance().isValidHeartRateValue(heartRate)) {
+ setCurrentHeartRate(heartRate, timestamp);
+ }
+ int steps = sample.getSteps();
+ if (steps > 0) {
+ addEntries(steps, timestamp);
+ }
+ }
+
+ private int translateTimestampFrom(Intent intent) {
+ return translateTimestamp(intent.getLongExtra(DeviceService.EXTRA_TIMESTAMP, System.currentTimeMillis()));
+ }
+
+ private int translateTimestamp(long tsMillis) {
+ int timestamp = (int) (tsMillis / 1000); // translate to seconds
+ return tsTranslation.shorten(timestamp); // and shorten
+ }
+
+ private void setCurrentHeartRate(int heartRate, int timestamp) {
+ addHistoryDataSet(true);
+ mHeartRate = heartRate;
+ if (mMaxHeartRate < mHeartRate) {
+ mMaxHeartRate = mHeartRate;
+ }
+ mMaxHeartRateView.setText(getContext().getString(R.string.live_activity_max_heart_rate, heartRate, mMaxHeartRate));
+ }
+
+ private int getCurrentHeartRate() {
+ int result = mHeartRate;
+ mHeartRate = -1;
+ return result;
+ }
+
+ private void addEntries(int steps, int timestamp) {
+ mSteps.updateCurrentSteps(steps, timestamp);
+ if (++maxStepsResetCounter > RESET_COUNT) {
+ maxStepsResetCounter = 0;
+ mSteps.maxStepsPerMinute = 0;
+ }
+ // Or: count down the steps until goal reached? And then flash GOAL REACHED -> Set stretch goal
+ LOG.info("Steps: " + steps + ", total: " + mSteps.getTotalSteps() + ", current: " + mSteps.getStepsPerMinute(false));
+
+// addEntries();
+ }
+
+ private void addEntries(int timestamp) {
+ mTotalStepsChart.setSingleEntryYValue(mSteps.getTotalSteps());
+ YAxis stepsPerMinuteCurrentYAxis = mStepsPerMinuteCurrentChart.getAxisLeft();
+ int maxStepsPerMinute = mSteps.getMaxStepsPerMinute();
+// int extraRoom = maxStepsPerMinute/5;
+// buggy in MPAndroidChart? Disable.
+// stepsPerMinuteCurrentYAxis.setAxisMaxValue(Math.max(MIN_STEPS_PER_MINUTE, maxStepsPerMinute + extraRoom));
+ LimitLine target = new LimitLine(maxStepsPerMinute);
+ stepsPerMinuteCurrentYAxis.removeAllLimitLines();
+ stepsPerMinuteCurrentYAxis.addLimitLine(target);
+
+ int stepsPerMinute = mSteps.getStepsPerMinute(true);
+ mStepsPerMinuteCurrentChart.setSingleEntryYValue(stepsPerMinute);
+
+ if (!addHistoryDataSet(false)) {
+ return;
+ }
+
+ ChartData data = mStepsPerMinuteHistoryChart.getData();
+ if (stepsPerMinute < 0) {
+ stepsPerMinute = 0;
+ }
+ mHistorySet.addEntry(new Entry(timestamp, stepsPerMinute));
+ int hr = getCurrentHeartRate();
+ if (hr > HeartRateUtils.getInstance().getMinHeartRate()) {
+ mHeartRateSet.addEntry(new Entry(timestamp, hr));
+ }
+ }
+
+ private boolean addHistoryDataSet(boolean force) {
+ if (mStepsPerMinuteHistoryChart.getData() == null) {
+ // ignore the first default value to keep the "no-data-description" visible
+ if (force || mSteps.getTotalSteps() > 0) {
+ LineData data = new LineData();
+ data.addDataSet(mHistorySet);
+ data.addDataSet(mHeartRateSet);
+ mStepsPerMinuteHistoryChart.setData(data);
+ return true;
+ }
+ return false;
+ }
+ return true;
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(DeviceService.ACTION_REALTIME_SAMPLES);
+ tsTranslation = new TimestampTranslation();
+
+ View rootView = inflater.inflate(R.layout.fragment_live_activity, container, false);
+
+ mStepsPerMinuteCurrentChart = rootView.findViewById(R.id.livechart_steps_per_minute_current);
+ mTotalStepsChart = rootView.findViewById(R.id.livechart_steps_total);
+ mStepsPerMinuteHistoryChart = rootView.findViewById(R.id.livechart_steps_per_minute_history);
+
+ totalStepsEntry = new BarEntry(1, 0);
+ stepsPerMinuteEntry = new BarEntry(1, 0);
+
+ mStepsPerMinuteData = setupCurrentChart(mStepsPerMinuteCurrentChart, stepsPerMinuteEntry, getString(R.string.live_activity_current_steps_per_minute));
+ mStepsPerMinuteData.setDrawValues(true);
+ mStepsPerMinuteData.setValueTextColor(DESCRIPTION_COLOR);
+ mTotalStepsData = setupTotalStepsChart(mTotalStepsChart, totalStepsEntry, getString(R.string.live_activity_total_steps));
+ mTotalStepsData.setDrawValues(true);
+ mTotalStepsData.setValueTextColor(DESCRIPTION_COLOR);
+ setupHistoryChart(mStepsPerMinuteHistoryChart);
+ mMaxHeartRateView = rootView.findViewById(R.id.livechart_max_heart_rate);
+
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(mReceiver, filterLocal);
+
+ return rootView;
+ }
+
+ @Override
+ public void onPause() {
+ enableRealtimeTracking(false);
+ super.onPause();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ enableRealtimeTracking(true);
+ }
+
+ private ScheduledExecutorService startActivityPulse() {
+ ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
+ service.scheduleAtFixedRate(new Runnable() {
+ @Override
+ public void run() {
+ FragmentActivity activity = LiveActivityFragment.this.getActivity();
+ if (activity != null && !activity.isFinishing() && !activity.isDestroyed()) {
+ activity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ pulse();
+ }
+ });
+ }
+ }
+ }, 0, getPulseIntervalMillis(), TimeUnit.MILLISECONDS);
+ return service;
+ }
+
+ private void stopActivityPulse() {
+ if (pulseScheduler != null) {
+ pulseScheduler.shutdownNow();
+ pulseScheduler = null;
+ }
+ }
+
+ /**
+ * Called in the UI thread.
+ */
+ private void pulse() {
+ addEntries(translateTimestamp(System.currentTimeMillis()));
+
+ LineData historyData = (LineData) mStepsPerMinuteHistoryChart.getData();
+ if (historyData == null) {
+ return;
+ }
+
+ historyData.notifyDataChanged();
+ mTotalStepsData.notifyDataSetChanged();
+ mStepsPerMinuteData.notifyDataSetChanged();
+ mStepsPerMinuteHistoryChart.notifyDataSetChanged();
+
+ renderCharts();
+
+ // have to enable it again and again to keep it measuring
+ GBApplication.deviceService(getChartsHost().getDevice()).onEnableRealtimeHeartRateMeasurement(true);
+ }
+
+ private int getPulseIntervalMillis() {
+ return 1000;
+ }
+
+ @Override
+ protected void onMadeVisibleInActivity() {
+ super.onMadeVisibleInActivity();
+ enableRealtimeTracking(true);
+ }
+
+ private void enableRealtimeTracking(boolean enable) {
+ if (enable && pulseScheduler != null) {
+ // already running
+ return;
+ }
+
+ try {
+ GBApplication.deviceService(getChartsHost().getDevice()).onEnableRealtimeSteps(enable);
+ GBApplication.deviceService(getChartsHost().getDevice()).onEnableRealtimeHeartRateMeasurement(enable);
+ } catch (IllegalStateException e) {
+ LOG.error("IllegalStateException catched, setting realtime tracking globally to {}", enable);
+ GBApplication.deviceService().onEnableRealtimeSteps(enable);
+ GBApplication.deviceService().onEnableRealtimeHeartRateMeasurement(enable);
+ }
+
+ if (enable) {
+ if (getActivity() != null) {
+ getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ }
+ pulseScheduler = startActivityPulse();
+ } else {
+ stopActivityPulse();
+ if (getActivity() != null) {
+ getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ }
+ }
+ }
+
+ @Override
+ public void onMadeInvisibleInActivity() {
+ enableRealtimeTracking(false);
+ super.onMadeInvisibleInActivity();
+ }
+
+ @Override
+ public void onDestroyView() {
+ onMadeInvisibleInActivity();
+ LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(mReceiver);
+ super.onDestroyView();
+ }
+
+ private BarDataSet setupCurrentChart(CustomBarChart chart, BarEntry entry, String title) {
+ mStepsPerMinuteCurrentChart.getAxisLeft().setAxisMaximum(MAX_STEPS_PER_MINUTE);
+ return setupCommonChart(chart, entry, title);
+ }
+
+ private BarDataSet setupCommonChart(CustomBarChart chart, BarEntry entry, String title) {
+ chart.setSinglAnimationEntry(entry);
+
+// chart.getXAxis().setPosition(XAxis.XAxisPosition.TOP);
+ chart.getXAxis().setDrawLabels(false);
+ chart.getXAxis().setEnabled(false);
+ chart.getXAxis().setTextColor(CHART_TEXT_COLOR);
+ chart.getAxisLeft().setTextColor(CHART_TEXT_COLOR);
+
+ chart.setBackgroundColor(BACKGROUND_COLOR);
+ chart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ chart.getDescription().setText(title);
+// chart.setNoDataTextDescription("");
+ chart.setNoDataText("");
+ chart.getAxisRight().setEnabled(false);
+
+ List<BarEntry> entries = new ArrayList<>();
+ List<Integer> colors = new ArrayList<>();
+
+ entries.add(entry);
+ colors.add(akActivity.color);
+ colors.add(akActivity.color);
+ colors.add(akActivity.color);
+// //we don't want labels
+// xLabels.add("");
+// xLabels.add("");
+// xLabels.add("");
+
+ BarDataSet set = new BarDataSet(entries, "");
+ set.setDrawValues(false);
+ set.setColors(colors);
+ BarData data = new BarData(set);
+// data.setGroupSpace(0);
+ chart.setData(data);
+
+ chart.getLegend().setEnabled(false);
+
+ return set;
+ }
+
+ private BarDataSet setupTotalStepsChart(CustomBarChart chart, BarEntry entry, String label) {
+ mTotalStepsChart.getAxisLeft().addLimitLine(new LimitLine(GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_STEPS_GOAL, ActivityUser.defaultUserStepsGoal), "ss")); // TODO: use daily goal - already reached steps
+ mTotalStepsChart.getAxisLeft().setAxisMinimum(0);
+ mTotalStepsChart.setAutoScaleMinMaxEnabled(true);
+ return setupCommonChart(chart, entry, label); // at the moment, these look the same
+ }
+
+ private void setupHistoryChart(BarLineChartBase chart) {
+ configureBarLineChartDefaults(chart);
+
+ chart.setTouchEnabled(false); // no zooming or anything, because it's updated all the time
+ chart.setBackgroundColor(BACKGROUND_COLOR);
+ chart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ chart.getDescription().setText(getString(R.string.live_activity_steps_per_minute_history));
+ chart.setNoDataText(getString(R.string.live_activity_start_your_activity));
+ chart.getLegend().setEnabled(false);
+ Paint infoPaint = chart.getPaint(Chart.PAINT_INFO);
+ infoPaint.setTextSize(Utils.convertDpToPixel(20f));
+ infoPaint.setFakeBoldText(true);
+ chart.setPaint(infoPaint, Chart.PAINT_INFO);
+
+ XAxis x = chart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setValueFormatter(new SampleXLabelFormatter(tsTranslation, "HH:mm"));
+ x.setDrawLimitLinesBehindData(true);
+
+ YAxis y = chart.getAxisLeft();
+ y.setDrawGridLines(false);
+ y.setDrawTopYLabelEntry(false);
+ y.setTextColor(CHART_TEXT_COLOR);
+ y.setEnabled(true);
+ y.setAxisMinimum(0);
+
+ YAxis yAxisRight = chart.getAxisRight();
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setEnabled(true);
+ yAxisRight.setDrawLabels(true);
+ yAxisRight.setDrawTopYLabelEntry(false);
+ yAxisRight.setTextColor(CHART_TEXT_COLOR);
+
+ mHistorySet = new LineDataSet(new ArrayList<Entry>(), getString(R.string.live_activity_steps_history));
+ mHistorySet.setAxisDependency(YAxis.AxisDependency.LEFT);
+ mHistorySet.setColor(akActivity.color);
+ mHistorySet.setDrawCircles(false);
+ mHistorySet.setMode(LineDataSet.Mode.CUBIC_BEZIER);
+ mHistorySet.setDrawFilled(true);
+ mHistorySet.setDrawValues(false);
+
+ mHeartRateSet = createHeartrateSet(new ArrayList<Entry>(), getString(R.string.live_activity_heart_rate));
+ mHeartRateSet.setDrawValues(false);
+ }
+
+ @Override
+ public String getTitle() {
+ return getContext().getString(R.string.liveactivity_live_activity);
+ }
+
+ @Override
+ protected void showDateBar(boolean show) {
+ // never show the data bar
+ super.showDateBar(false);
+ }
+
+ @Override
+ protected void refresh() {
+ // do nothing, we don't have any db interaction
+ }
+
+ @Override
+ protected ChartsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ protected void updateChartsnUIThread(ChartsData chartsData) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ protected void renderCharts() {
+ mStepsPerMinuteCurrentChart.animateY(150);
+ mTotalStepsChart.animateY(150);
+ mStepsPerMinuteHistoryChart.invalidate();
+ }
+
+ @Override
+ protected List<ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ throw new UnsupportedOperationException("no db access supported for live activity");
+ }
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {
+ // no legend
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/NonSwipeableViewPager.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/NonSwipeableViewPager.java
new file mode 100644
index 0000000..6aebcbc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/NonSwipeableViewPager.java
@@ -0,0 +1,53 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+
+import androidx.viewpager.widget.ViewPager;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+
+public class NonSwipeableViewPager extends ViewPager {
+ private boolean allowSwipe = true;
+
+ public NonSwipeableViewPager(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public void setAllowSwipe(final boolean allowSwipe) {
+ this.allowSwipe = allowSwipe;
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(final MotionEvent ev) {
+ if (allowSwipe) {
+ return super.onInterceptTouchEvent(ev);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onTouchEvent(final MotionEvent ev) {
+ if (allowSwipe) {
+ return super.onTouchEvent(ev);
+ }
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/PaiChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/PaiChartFragment.java
new file mode 100644
index 0000000..b1a75e4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/PaiChartFragment.java
@@ -0,0 +1,477 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.graphics.Color;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+
+import com.github.mikephil.charting.charts.BarChart;
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.PieChart;
+import com.github.mikephil.charting.components.Legend;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.BarData;
+import com.github.mikephil.charting.data.BarDataSet;
+import com.github.mikephil.charting.data.BarEntry;
+import com.github.mikephil.charting.data.ChartData;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.GaugeDrawer;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public class PaiChartFragment extends AbstractChartFragment<PaiChartFragment.PaiChartsData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(PaiChartFragment.class);
+
+ protected final int TOTAL_DAYS = getRangeDays();
+
+ protected Locale mLocale;
+
+ protected ImageView mGoalMinutesGauge;
+ protected BarChart mWeekChart;
+ protected TextView mDateView;
+ protected TextView mLineToday;
+ protected TextView mLineTotal;
+ protected TextView mLineLowInc;
+ protected TextView mLineLowTime;
+ protected TextView mLineModerateInc;
+ protected TextView mLineModerateTime;
+ protected TextView mLineHighInc;
+ protected TextView mLineHighTime;
+ protected LinearLayout mTileLow;
+ protected LinearLayout mTileModerate;
+ protected LinearLayout mTileHigh;
+
+ protected int BACKGROUND_COLOR;
+ protected int DESCRIPTION_COLOR;
+ protected int CHART_TEXT_COLOR;
+ protected int LEGEND_TEXT_COLOR;
+
+ protected int PAI_TOTAL_COLOR;
+ protected int PAI_DAY_COLOR;
+
+ @Override
+ protected void init() {
+ BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext());
+ LEGEND_TEXT_COLOR = DESCRIPTION_COLOR = GBApplication.getTextColor(requireContext());
+ CHART_TEXT_COLOR = ContextCompat.getColor(requireContext(), R.color.secondarytext);
+
+ PAI_TOTAL_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_pai_weekly);
+ PAI_DAY_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_pai_today);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater,
+ final ViewGroup container,
+ final Bundle savedInstanceState) {
+ mLocale = getResources().getConfiguration().locale;
+
+ final View rootView = inflater.inflate(R.layout.fragment_pai_chart, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ mGoalMinutesGauge = rootView.findViewById(R.id.goal_minutes_gauge);
+ mWeekChart = rootView.findViewById(R.id.pai_chart_week);
+ mDateView = rootView.findViewById(R.id.pai_date_view);
+ mLineToday = rootView.findViewById(R.id.pai_line_today);
+ mLineTotal = rootView.findViewById(R.id.pai_line_total);
+ mLineLowInc = rootView.findViewById(R.id.pai_line_low_inc);
+ mLineLowTime = rootView.findViewById(R.id.pai_line_low_time);
+ mLineModerateInc = rootView.findViewById(R.id.pai_line_moderate_inc);
+ mLineModerateTime = rootView.findViewById(R.id.pai_line_moderate_time);
+ mLineHighInc = rootView.findViewById(R.id.pai_line_high_inc);
+ mLineHighTime = rootView.findViewById(R.id.pai_line_high_time);
+ mTileLow = rootView.findViewById(R.id.pai_tile_low);
+ mTileModerate = rootView.findViewById(R.id.pai_tile_moderate);
+ mTileHigh = rootView.findViewById(R.id.pai_tile_high);
+
+ if (!getChartsHost().getDevice().getDeviceCoordinator().supportsPaiTime()) {
+ mLineLowTime.setVisibility(View.GONE);
+ mLineModerateTime.setVisibility(View.GONE);
+ mLineHighTime.setVisibility(View.GONE);
+ }
+
+ if (!getChartsHost().getDevice().getDeviceCoordinator().supportsPaiLow()) {
+ mTileLow.setVisibility(View.GONE);
+ }
+
+ setupWeekChart();
+
+ // refresh immediately instead of use refreshIfVisible(), for perceived performance
+ refresh();
+
+ return rootView;
+ }
+
+ private void setupWeekChart() {
+ mWeekChart.setBackgroundColor(BACKGROUND_COLOR);
+ mWeekChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ mWeekChart.getDescription().setText("");
+ mWeekChart.setFitBars(true);
+
+ configureBarLineChartDefaults(mWeekChart);
+
+ final XAxis x = mWeekChart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setDrawLimitLinesBehindData(true);
+ x.setPosition(XAxis.XAxisPosition.BOTTOM);
+
+ final YAxis y = mWeekChart.getAxisLeft();
+ y.setDrawGridLines(true);
+ y.setDrawTopYLabelEntry(true);
+ y.setTextColor(CHART_TEXT_COLOR);
+ y.setDrawZeroLine(true);
+ y.setSpaceBottom(0);
+ y.setAxisMinimum(0);
+ y.setAxisMaximum(getPaiTarget());
+ y.setValueFormatter(getRoundFormatter());
+ y.setEnabled(true);
+
+ final YAxis yAxisRight = mWeekChart.getAxisRight();
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setEnabled(false);
+ yAxisRight.setDrawLabels(false);
+ yAxisRight.setDrawTopYLabelEntry(false);
+ yAxisRight.setTextColor(CHART_TEXT_COLOR);
+ }
+
+ private int getRangeDays() {
+ if (GBApplication.getPrefs().getBoolean("charts_range", true)) {
+ return 30;
+ } else {
+ return 7;
+ }
+ }
+
+ private int getPaiTarget() {
+ return getChartsHost().getDevice().getDeviceCoordinator().getPaiTarget();
+ }
+
+ @Override
+ public String getTitle() {
+ if (GBApplication.getPrefs().getBoolean("charts_range", true)) {
+ return getString(R.string.pai_chart_per_month);
+ } else {
+ return getString(R.string.pai_chart_per_week);
+ }
+ }
+
+ @Override
+ protected PaiChartsData refreshInBackground(final ChartsHost chartsHost, final DBHandler db, final GBDevice device) {
+ final Calendar day = Calendar.getInstance();
+ day.setTime(chartsHost.getEndDate());
+ //NB: we could have omitted the day, but this way we can move things to the past easily
+ final DayData dayData = refreshDayData(db, day, device);
+ final WeekChartsData<BarData> weekBeforeData = refreshWeekBeforeData(db, mWeekChart, day, device);
+
+ return new PaiChartsData(dayData, weekBeforeData);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(final PaiChartsData pcd) {
+ setupLegend(mWeekChart);
+ int[] colors = new int[] {
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_pai_weekly),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_pai_today)
+ };
+ final int width = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 300,
+ GBApplication.getContext().getResources().getDisplayMetrics()
+ );
+ mGoalMinutesGauge.setImageBitmap(GaugeDrawer.drawCircleGaugeSegmented(
+ width,
+ width / 15,
+ colors,
+ pcd.getDayData().getGaugeSegments(),
+ false,
+ String.valueOf(pcd.getDayData().getTotal()),
+ String.valueOf(getPaiTarget()),
+ getContext()
+ ));
+
+ // set custom renderer for 30days bar charts
+ if (GBApplication.getPrefs().getBoolean("charts_range", true)) {
+ mWeekChart.setRenderer(new AngledLabelsChartRenderer(mWeekChart, mWeekChart.getAnimator(), mWeekChart.getViewPortHandler()));
+ }
+
+ mWeekChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
+ mWeekChart.setData(pcd.getWeekBeforeData().getData());
+ mWeekChart.getXAxis().setValueFormatter(pcd.getWeekBeforeData().getXValueFormatter());
+
+ mDateView.setText(DateTimeUtils.formatDate(pcd.getDayData().day.getTime()));
+ mLineToday.setText(requireContext().getString(R.string.pai_plus_num, pcd.getDayData().today));
+ mLineTotal.setText(String.valueOf(pcd.getDayData().total));
+ mLineLowInc.setText(String.valueOf(pcd.getDayData().paiLow));
+ mLineLowTime.setText(requireContext().getString(R.string.num_min, pcd.getDayData().minutesLow));
+ mLineModerateInc.setText(String.valueOf(pcd.getDayData().paiModerate));
+ mLineModerateTime.setText(requireContext().getString(R.string.num_min, pcd.getDayData().minutesModerate));
+ mLineHighInc.setText(String.valueOf(pcd.getDayData().paiHigh));
+ mLineHighTime.setText(requireContext().getString(R.string.num_min, pcd.getDayData().minutesHigh));
+ }
+
+ @Override
+ protected void renderCharts() {
+ mWeekChart.invalidate();
+ }
+
+ protected String getWeeksChartsLabel(final Calendar day) {
+ if (GBApplication.getPrefs().getBoolean("charts_range", true)) {
+ // month, show day date
+ return String.valueOf(day.get(Calendar.DAY_OF_MONTH));
+ } else {
+ // week, show short day name
+ return day.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, mLocale);
+ }
+ }
+
+ protected WeekChartsData<BarData> refreshWeekBeforeData(final DBHandler db,
+ final BarChart barChart,
+ Calendar day,
+ final GBDevice device) {
+ day = (Calendar) day.clone(); // do not modify the caller's argument
+ day.add(Calendar.DATE, -TOTAL_DAYS + 1);
+
+ List<BarEntry> entries = new ArrayList<>();
+ final ArrayList<String> labels = new ArrayList<>();
+
+ int maxPai = -1;
+
+ for (int counter = 0; counter < TOTAL_DAYS; counter++) {
+ final Optional<? extends PaiSample> sampleOpt = getSamplePaiForDay(db, device, day);
+
+ if (sampleOpt.isPresent()) {
+ final PaiSample sample = sampleOpt.get();
+ final int paiToday = Math.round(sample.getPaiToday());
+ final int paiTotal = Math.round(sample.getPaiTotal());
+
+ if (paiTotal > maxPai) {
+ maxPai = paiTotal;
+ }
+
+ final float[] paiBar = new float[]{
+ paiTotal - paiToday,
+ paiToday
+ };
+
+ entries.add(new BarEntry(counter, paiBar));
+ } else {
+ entries.add(new BarEntry(counter, new float[]{0.0f, 0.0f}));
+ }
+ labels.add(getWeeksChartsLabel(day));
+ day.add(Calendar.DATE, 1);
+ }
+
+ BarDataSet set = new BarDataSet(entries, "");
+ set.setColors(PAI_TOTAL_COLOR, PAI_DAY_COLOR);
+ set.setValueFormatter(getRoundFormatter());
+
+ BarData barData = new BarData(set);
+ barData.setValueTextColor(Color.GRAY); //prevent tearing other graph elements with the black text. Another approach would be to hide the values cmpletely with data.setDrawValues(false);
+ barData.setValueTextSize(10f);
+
+ barChart.getAxisLeft().setAxisMaximum(Math.max(maxPai, getPaiTarget()));
+
+ return new WeekChartsData(barData, new PreformattedXIndexLabelFormatter(labels));
+ }
+
+ protected DayData refreshDayData(final DBHandler db,
+ final Calendar day,
+ final GBDevice device) {
+ final Optional<? extends PaiSample> sampleOpt = getSamplePaiForDay(db, device, day);
+
+ final int today;
+ final int total;
+ final int paiLow;
+ final int paiModerate;
+ final int paiHigh;
+ final int minutesLow;
+ final int minutesModerate;
+ final int minutesHigh;
+
+ if (sampleOpt.isPresent()) {
+ final PaiSample sample = sampleOpt.get();
+ today = Math.round(sample.getPaiToday());
+ total = Math.round(sample.getPaiTotal());
+ paiLow = Math.round(sample.getPaiLow());
+ paiModerate = Math.round(sample.getPaiModerate());
+ paiHigh = Math.round(sample.getPaiHigh());
+ minutesLow = sample.getTimeLow();
+ minutesModerate = sample.getTimeModerate();
+ minutesHigh = sample.getTimeHigh();
+ } else {
+ today = 0;
+ total = 0;
+ paiLow = 0;
+ paiModerate = 0;
+ paiHigh = 0;
+ minutesLow = 0;
+ minutesModerate = 0;
+ minutesHigh = 0;
+ }
+
+ final int maxPai = Math.max(getPaiTarget(), total);
+
+ float[] segments = new float[] {
+ (float) (total - today) / maxPai,
+ (float) today / maxPai
+ };
+
+ return new DayData(day, segments, today, total, paiLow, paiModerate, paiHigh, minutesLow, minutesModerate, minutesHigh);
+ }
+
+ protected ValueFormatter getRoundFormatter() {
+ return new ValueFormatter() {
+ @Override
+ public String getFormattedValue(final float value) {
+ return String.valueOf(Math.round(value));
+ }
+ };
+ }
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {
+ List<LegendEntry> legendEntries = new ArrayList<>(2);
+
+ LegendEntry lightSleepEntry = new LegendEntry();
+ lightSleepEntry.label = requireContext().getString(R.string.pai_total);
+ lightSleepEntry.formColor = PAI_TOTAL_COLOR;
+ legendEntries.add(lightSleepEntry);
+
+ LegendEntry deepSleepEntry = new LegendEntry();
+ deepSleepEntry.label = requireContext().getString(R.string.pai_day);
+ deepSleepEntry.formColor = PAI_DAY_COLOR;
+ legendEntries.add(deepSleepEntry);
+
+ chart.getLegend().setCustom(legendEntries);
+ chart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ chart.getLegend().setWordWrapEnabled(true);
+ chart.getLegend().setHorizontalAlignment(Legend.LegendHorizontalAlignment.CENTER);
+ }
+
+ private Optional<? extends PaiSample> getSamplePaiForDay(final DBHandler db, final GBDevice device, final Calendar day) {
+ final Date dayStart = DateTimeUtils.dayStart(day.getTime());
+ final Date dayEnd = DateTimeUtils.dayEnd(day.getTime());
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final TimeSampleProvider<? extends PaiSample> sampleProvider = coordinator.getPaiSampleProvider(device, db.getDaoSession());
+ final List<? extends PaiSample> daySamples = sampleProvider.getAllSamples(dayStart.getTime(), dayEnd.getTime());
+ return Optional.ofNullable(daySamples.isEmpty() ? null : daySamples.get(daySamples.size() - 1));
+ }
+
+ protected static class DayData {
+ private final Calendar day;
+ private final float[] gaugeSegments;
+ private final int today;
+ private final int total;
+ private final int paiLow;
+ private final int paiModerate;
+ private final int paiHigh;
+ private final int minutesLow;
+ private final int minutesModerate;
+ private final int minutesHigh;
+
+ DayData(final Calendar day,
+ float[] gaugeSegments,
+ final int today,
+ final int total,
+ final int paiLow,
+ final int paiModerate,
+ final int paiHigh,
+ final int minutesLow,
+ final int minutesModerate,
+ final int minutesHigh) {
+ this.day = day;
+ this.gaugeSegments = gaugeSegments;
+ this.today = today;
+ this.total = total;
+ this.paiLow = paiLow;
+ this.paiModerate = paiModerate;
+ this.paiHigh = paiHigh;
+ this.minutesLow = minutesLow;
+ this.minutesModerate = minutesModerate;
+ this.minutesHigh = minutesHigh;
+ }
+
+ public int getTotal() {
+ return total;
+ }
+
+ public float[] getGaugeSegments() {
+ return gaugeSegments;
+ }
+ }
+
+ protected static class WeekChartsData<T extends ChartData<?>> extends DefaultChartsData<T> {
+ public WeekChartsData(final T data,
+ final PreformattedXIndexLabelFormatter xIndexLabelFormatter) {
+ super(data, xIndexLabelFormatter);
+ }
+ }
+
+ protected static class PaiChartsData extends ChartsData {
+ private final WeekChartsData<BarData> weekBeforeData;
+ private final DayData dayData;
+
+ PaiChartsData(final DayData dayData, final WeekChartsData<BarData> weekBeforeData) {
+ this.dayData = dayData;
+ this.weekBeforeData = weekBeforeData;
+ }
+
+ public DayData getDayData() {
+ return dayData;
+ }
+
+ public WeekChartsData<BarData> getWeekBeforeData() {
+ return weekBeforeData;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/PreformattedXIndexLabelFormatter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/PreformattedXIndexLabelFormatter.java
new file mode 100644
index 0000000..2cde827
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/PreformattedXIndexLabelFormatter.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import com.github.mikephil.charting.formatter.ValueFormatter;
+
+import java.util.ArrayList;
+
+class PreformattedXIndexLabelFormatter extends ValueFormatter {
+ private final ArrayList<String> xLabels;
+
+ public PreformattedXIndexLabelFormatter(ArrayList<String> xLabels) {
+ this.xLabels = xLabels;
+ }
+
+ @Override
+ public String getFormattedValue(float value) {
+ int index = (int) value;
+ if (xLabels == null || index >= xLabels.size()) {
+ return String.valueOf(value);
+ }
+ return xLabels.get(index);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateCollectionFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateCollectionFragment.java
new file mode 100644
index 0000000..1097a3c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateCollectionFragment.java
@@ -0,0 +1,29 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.os.Bundle;
+
+import androidx.fragment.app.FragmentManager;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
+import nodomain.freeyourgadget.gadgetbridge.adapter.NestedFragmentAdapter;
+import nodomain.freeyourgadget.gadgetbridge.adapter.RespiratoryRateFragmentAdapter;
+
+public class RespiratoryRateCollectionFragment extends AbstractCollectionFragment {
+ public RespiratoryRateCollectionFragment() {
+
+ }
+
+ public static RespiratoryRateCollectionFragment newInstance(final boolean allowSwipe) {
+ final RespiratoryRateCollectionFragment fragment = new RespiratoryRateCollectionFragment();
+ final Bundle args = new Bundle();
+ args.putBoolean(ARG_ALLOW_SWIPE, allowSwipe);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public NestedFragmentAdapter getNestedFragmentAdapter(AbstractGBFragment fragment, FragmentManager childFragmentManager) {
+ return new RespiratoryRateFragmentAdapter(this, getChildFragmentManager());
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateDailyFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateDailyFragment.java
new file mode 100644
index 0000000..db7600a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateDailyFragment.java
@@ -0,0 +1,208 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.os.Build;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractRespiratoryRateSample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class RespiratoryRateDailyFragment extends RespiratoryRateFragment<RespiratoryRateFragment.RespiratoryRateDay> {
+ protected static final Logger LOG = LoggerFactory.getLogger(BodyEnergyFragment.class);
+
+ private TextView mDateView;
+ private TextView sleepAvg;
+ private TextView awakeAvg;
+ private TextView lowest;
+ private TextView highest;
+ private LineChart respiratoryRateChart;
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_respiratory_rate, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ mDateView = rootView.findViewById(R.id.rr_date_view);
+ awakeAvg = rootView.findViewById(R.id.awake_avg);
+ sleepAvg = rootView.findViewById(R.id.sleep_avg);
+ lowest = rootView.findViewById(R.id.day_lowest);
+ highest = rootView.findViewById(R.id.day_highest);
+ respiratoryRateChart = rootView.findViewById(R.id.respiratory_rate_line_chart);
+ setupRespiratoryRateChart();
+ refresh();
+
+ return rootView;
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.respiratoryrate);
+ }
+
+ @Override
+ protected RespiratoryRateDay refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ Calendar day = Calendar.getInstance();
+ day.setTime(chartsHost.getEndDate());
+ String formattedDate = new SimpleDateFormat("E, MMM dd").format(chartsHost.getEndDate());
+ mDateView.setText(formattedDate);
+ List<RespiratoryRateDay> stepsDayList = getMyRespiratoryRateDaysData(db, day, device);
+ final RespiratoryRateDay RespiratoryRateDay;
+ if (stepsDayList.isEmpty()) {
+ LOG.error("Failed to get RespiratoryRateDay for {}", day);
+ List<? extends AbstractRespiratoryRateSample> s = new ArrayList<>();
+ RespiratoryRateDay = new RespiratoryRateDay(day, new ArrayList<>(), new ArrayList<>(), true);
+ } else {
+ RespiratoryRateDay = stepsDayList.get(0);
+ }
+ return RespiratoryRateDay;
+ }
+
+ @Override
+ protected void updateChartsnUIThread(RespiratoryRateFragment.RespiratoryRateDay respiratoryRateDay) {
+ final String emptyValue = requireContext().getString(R.string.stats_empty_value);
+ awakeAvg.setText(respiratoryRateDay.awakeRateAvg > 0 ? String.valueOf(respiratoryRateDay.awakeRateAvg) : emptyValue);
+ sleepAvg.setText(respiratoryRateDay.sleepRateAvg > 0 ? String.valueOf(respiratoryRateDay.sleepRateAvg) : emptyValue);
+ lowest.setText(respiratoryRateDay.rateLowest > 0 ? String.valueOf(respiratoryRateDay.rateLowest) : emptyValue);
+ highest.setText(respiratoryRateDay.rateHighest > 0 ? String.valueOf(respiratoryRateDay.rateHighest) : emptyValue);
+
+ // Chart
+ final List<LegendEntry> legendEntries = new ArrayList<>(1);
+ final LegendEntry respiratoryRateEntry = new LegendEntry();
+ respiratoryRateEntry.label = getString(R.string.respiratoryrate);
+ respiratoryRateEntry.formColor = getResources().getColor(R.color.respiratory_rate_color);
+ legendEntries.add(respiratoryRateEntry);
+ respiratoryRateChart.getLegend().setTextColor(TEXT_COLOR);
+ respiratoryRateChart.getLegend().setCustom(legendEntries);
+
+ final List<ILineDataSet> lineDataSets = new ArrayList<>();
+ List<Entry> lineEntries = new ArrayList<>();
+ final TimestampTranslation tsTranslation = new TimestampTranslation();
+ int lastTsShorten = 0;
+ for (final AbstractRespiratoryRateSample sample : respiratoryRateDay.respiratoryRateSamples) {
+ int ts = (int) (sample.getTimestamp() / 1000L);
+ int tsShorten = tsTranslation.shorten(ts);
+ if (lastTsShorten == 0 || (tsShorten - lastTsShorten) <= 300) {
+ lineEntries.add(new Entry(tsShorten, (int) sample.getRespiratoryRate()));
+ } else {
+ if (!lineEntries.isEmpty()) {
+ List<Entry> clone = new ArrayList<>(lineEntries.size());
+ clone.addAll(lineEntries);
+ lineDataSets.add(createDataSet(clone));
+ lineEntries.clear();
+ }
+ }
+ lastTsShorten = tsShorten;
+ lineEntries.add(new Entry(tsShorten, (int) sample.getRespiratoryRate()));
+ }
+
+ if (!lineEntries.isEmpty()) {
+ lineDataSets.add(createDataSet(lineEntries));
+ }
+
+ respiratoryRateChart.getXAxis().setValueFormatter(new SampleXLabelFormatter(tsTranslation, "HH:mm"));
+ if (respiratoryRateDay.rateLowest > 0 && respiratoryRateDay.rateHighest > 0) {
+ final YAxis yAxisLeft = respiratoryRateChart.getAxisLeft();
+ yAxisLeft.setAxisMaximum(Math.max(respiratoryRateDay.rateHighest + 3, 20));
+ }
+
+ final LineDataSet lineDataSet = new LineDataSet(lineEntries, getString(R.string.respiratoryrate));
+ lineDataSet.setColor(getResources().getColor(R.color.respiratory_rate_color));
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setLineWidth(2f);
+ lineDataSet.setFillAlpha(255);
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setCircleColor(getResources().getColor(R.color.respiratory_rate_color));
+ lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
+ lineDataSet.setDrawValues(false);
+ lineDataSet.setMode(LineDataSet.Mode.CUBIC_BEZIER);
+
+ lineDataSets.add(lineDataSet);
+ final LineData lineData = new LineData(lineDataSets);
+ respiratoryRateChart.setData(lineData);
+ }
+
+ protected LineDataSet createDataSet(final List<Entry> values) {
+ final LineDataSet lineDataSet = new LineDataSet(values, getString(R.string.respiratoryrate));
+ lineDataSet.setColor(getResources().getColor(R.color.respiratory_rate_color));
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setLineWidth(2f);
+ lineDataSet.setFillAlpha(255);
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setCircleColor(getResources().getColor(R.color.respiratory_rate_color));
+ lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
+ lineDataSet.setDrawValues(false);
+ lineDataSet.setMode(LineDataSet.Mode.CUBIC_BEZIER);
+
+ return lineDataSet;
+ }
+
+ @Override
+ protected void renderCharts() {
+ respiratoryRateChart.invalidate();
+ }
+
+ protected void setupLegend(Chart<?> chart) {}
+
+ private void setupRespiratoryRateChart() {
+ respiratoryRateChart.getDescription().setEnabled(false);
+ respiratoryRateChart.setDoubleTapToZoomEnabled(false);
+
+ final XAxis xAxisBottom = respiratoryRateChart.getXAxis();
+ xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
+ xAxisBottom.setDrawLabels(true);
+ xAxisBottom.setDrawGridLines(false);
+ xAxisBottom.setEnabled(true);
+ xAxisBottom.setDrawLimitLinesBehindData(true);
+ xAxisBottom.setTextColor(CHART_TEXT_COLOR);
+ xAxisBottom.setAxisMinimum(0f);
+ xAxisBottom.setAxisMaximum(86400f);
+ xAxisBottom.setLabelCount(7, true);
+
+ final YAxis yAxisLeft = respiratoryRateChart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setAxisMinimum(0);
+ yAxisLeft.setAxisMaximum(20);
+ yAxisLeft.setDrawTopYLabelEntry(true);
+ yAxisLeft.setEnabled(true);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+
+ final YAxis yAxisRight = respiratoryRateChart.getAxisRight();
+ yAxisRight.setEnabled(true);
+ yAxisRight.setDrawLabels(false);
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setDrawAxisLine(true);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateFragment.java
new file mode 100644
index 0000000..fb7fc55
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRateFragment.java
@@ -0,0 +1,155 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractRespiratoryRateSample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
+
+abstract class RespiratoryRateFragment<T extends ChartsData> extends AbstractChartFragment<T> {
+ protected static final Logger LOG = LoggerFactory.getLogger(RespiratoryRateFragment.class);
+
+ protected int CHART_TEXT_COLOR;
+ protected int TEXT_COLOR;
+ protected int LEGEND_TEXT_COLOR;
+
+ protected int BACKGROUND_COLOR;
+ protected int DESCRIPTION_COLOR;
+ protected int TOTAL_DAYS = 1;
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.respiratoryrate);
+ }
+
+ @Override
+ protected void init() {
+ TEXT_COLOR = GBApplication.getTextColor(requireContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+ BACKGROUND_COLOR = GBApplication.getBackgroundColor(getContext());
+ LEGEND_TEXT_COLOR = DESCRIPTION_COLOR = GBApplication.getTextColor(getContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext());
+ }
+
+ protected List<RespiratoryRateFragment.RespiratoryRateDay> getMyRespiratoryRateDaysData(DBHandler db, Calendar day, GBDevice device) {
+ day = (Calendar) day.clone(); // do not modify the caller's argument
+ day.add(Calendar.DATE, -TOTAL_DAYS + 1);
+
+ final boolean supportsDayRespiratoryRate = device.getDeviceCoordinator().supportsDayRespiratoryRate();
+
+ List<RespiratoryRateDay> daysData = new ArrayList<>();;
+ for (int counter = 0; counter < TOTAL_DAYS; counter++) {
+ int startTs;
+ int endTs;
+ day = (Calendar) day.clone(); // do not modify the caller's argument
+ day.set(Calendar.HOUR_OF_DAY, 0);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ day.add(Calendar.HOUR, 0);
+ startTs = (int) (day.getTimeInMillis() / 1000);
+ endTs = startTs + 24 * 60 * 60 - 1;
+ List<? extends ActivitySample> activitySamples = getAllActivitySamples(db, device, startTs, endTs);
+ SleepAnalysis sleepAnalysis = new SleepAnalysis();
+ List<SleepAnalysis.SleepSession> sleepSessions = sleepAnalysis.calculateSleepSessions(activitySamples);
+ List<? extends AbstractRespiratoryRateSample> samples = getRespiratoryRateSamples(db, device, startTs, endTs);
+ Calendar d = (Calendar) day.clone();
+ daysData.add(new RespiratoryRateDay(d, samples, sleepSessions, supportsDayRespiratoryRate));
+ day.add(Calendar.DATE, 1);
+ }
+ return daysData;
+ }
+
+ protected List<? extends AbstractRespiratoryRateSample> getSamplesOfDay(DBHandler db, GBDevice device, int startTs, int endTs) {
+ return getRespiratoryRateSamples(db, device, startTs, endTs);
+ }
+
+ protected List<AbstractRespiratoryRateSample> getRespiratoryRateSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ TimeSampleProvider<? extends RespiratoryRateSample> provider = device.getDeviceCoordinator().getRespiratoryRateSampleProvider(device, db.getDaoSession());
+ return (List<AbstractRespiratoryRateSample>) provider.getAllSamples(tsFrom * 1000L, tsTo * 1000L);
+ }
+
+ protected List<? extends ActivitySample> getAllActivitySamples(DBHandler db, GBDevice device, int startTs, int endTs) {
+ SampleProvider<? extends ActivitySample> provider = device.getDeviceCoordinator().getSampleProvider(device, db.getDaoSession());
+ return provider.getAllActivitySamples(startTs, endTs);
+ }
+
+ protected static class RespiratoryRateDay extends ChartsData {
+ public int awakeRateAvg;
+ public int sleepRateAvg;
+ public int rateLowest;
+ public int rateHighest;
+ public Calendar day;
+ List<? extends AbstractRespiratoryRateSample> respiratoryRateSamples;
+ List<SleepAnalysis.SleepSession> sleepSessions;
+
+ protected RespiratoryRateDay(Calendar day,
+ List<? extends AbstractRespiratoryRateSample> respiratoryRateSamples,
+ List<SleepAnalysis.SleepSession> sleepSessions,
+ boolean supportsDayRespiratoryRate) {
+ this.day = day;
+ this.respiratoryRateSamples = respiratoryRateSamples;
+ this.sleepSessions = sleepSessions;
+ float awakeRateTotal = 0;
+ int awakeCounter = 0;
+ float sleepRateTotal = 0;
+ int sleepCounter = 0;
+ float lowest = 0;
+ float highest = 0;
+ if (!this.respiratoryRateSamples.isEmpty()) {
+ for (AbstractRespiratoryRateSample sample : this.respiratoryRateSamples) {
+ if (isSleepSample(sample, supportsDayRespiratoryRate)) {
+ sleepRateTotal += sample.getRespiratoryRate();
+ sleepCounter++;
+ } else {
+ awakeRateTotal += sample.getRespiratoryRate();
+ awakeCounter++;
+ }
+ if (sample.getRespiratoryRate() > highest) {
+ highest = sample.getRespiratoryRate();
+ }
+ if (sample.getRespiratoryRate() < lowest || lowest == 0) {
+ lowest = sample.getRespiratoryRate();
+ }
+ }
+ }
+ if (awakeRateTotal > 0) {
+ this.awakeRateAvg = Math.round(awakeRateTotal / awakeCounter);
+ }
+ if (sleepRateTotal > 0) {
+ this.sleepRateAvg = Math.round(sleepRateTotal / sleepCounter);
+ }
+ this.rateLowest = (int) lowest;
+ this.rateHighest = (int) highest;
+ }
+
+ private boolean isSleepSample(AbstractRespiratoryRateSample sample, boolean supportsDayRespiratoryRate) {
+ if (!supportsDayRespiratoryRate) {
+ return true;
+ }
+
+ if (this.sleepSessions.isEmpty()) {
+ return true;
+ }
+
+ for (SleepAnalysis.SleepSession session : this.sleepSessions) {
+ if (sample.getTimestamp() >= session.getSleepStart().getTime() && sample.getTimestamp() <= session.getSleepEnd().getTime()) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRatePeriodFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRatePeriodFragment.java
new file mode 100644
index 0000000..80b9cf3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/RespiratoryRatePeriodFragment.java
@@ -0,0 +1,239 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.graphics.Color;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import org.apache.commons.lang3.time.DateUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class RespiratoryRatePeriodFragment extends RespiratoryRateFragment<RespiratoryRatePeriodFragment.RespiratoryRateData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(BodyEnergyFragment.class);
+
+ private TextView mDateView;
+ private TextView sleepAvg;
+ private TextView awakeAvg;
+ private LineChart respiratoryRateChart;
+
+ public static RespiratoryRatePeriodFragment newInstance (int totalDays) {
+ RespiratoryRatePeriodFragment fragmentFirst = new RespiratoryRatePeriodFragment();
+ Bundle args = new Bundle();
+ args.putInt("totalDays", totalDays);
+ fragmentFirst.setArguments(args);
+ return fragmentFirst;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ TOTAL_DAYS = getArguments() != null ? getArguments().getInt("totalDays") : 0;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_respiratory_rate_period, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ mDateView = rootView.findViewById(R.id.rr_date_view);
+ sleepAvg = rootView.findViewById(R.id.sleep_avg);
+ awakeAvg = rootView.findViewById(R.id.awake_avg);
+ respiratoryRateChart = rootView.findViewById(R.id.respiratory_rate_line_chart);
+ setupRespiratoryRateChart();
+ refresh();
+
+ return rootView;
+ }
+
+ protected void setupRespiratoryRateChart() {
+ respiratoryRateChart.getDescription().setEnabled(false);
+ if (TOTAL_DAYS <= 7) {
+ respiratoryRateChart.setTouchEnabled(false);
+ respiratoryRateChart.setPinchZoom(false);
+ }
+
+ respiratoryRateChart.getDescription().setEnabled(false);
+ respiratoryRateChart.setDoubleTapToZoomEnabled(false);
+
+ final XAxis xAxisBottom = respiratoryRateChart.getXAxis();
+ xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
+ xAxisBottom.setDrawLabels(true);
+ xAxisBottom.setDrawGridLines(false);
+ xAxisBottom.setEnabled(true);
+ xAxisBottom.setDrawLimitLinesBehindData(true);
+ xAxisBottom.setTextColor(CHART_TEXT_COLOR);
+ xAxisBottom.setLabelCount(7, true);
+ xAxisBottom.setAxisMinimum(0);
+ xAxisBottom.setAxisMaximum(TOTAL_DAYS - 1);
+
+ final YAxis yAxisLeft = respiratoryRateChart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setAxisMinimum(0);
+ yAxisLeft.setAxisMaximum(20);
+ yAxisLeft.setDrawTopYLabelEntry(true);
+ yAxisLeft.setEnabled(true);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+
+ final YAxis yAxisRight = respiratoryRateChart.getAxisRight();
+ yAxisRight.setEnabled(true);
+ yAxisRight.setDrawLabels(false);
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setDrawAxisLine(true);
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.respiratoryrate);
+ }
+
+ @Override
+ protected RespiratoryRateData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ Calendar day = Calendar.getInstance();
+ Date to = new Date((long) this.getTSEnd() * 1000);
+ Date from = DateUtils.addDays(to,-(TOTAL_DAYS - 1));
+ String toFormattedDate = new SimpleDateFormat("E, MMM dd").format(to);
+ String fromFormattedDate = new SimpleDateFormat("E, MMM dd").format(from);
+ mDateView.setText(fromFormattedDate + " - " + toFormattedDate);
+ day.setTime(to);
+ List<RespiratoryRateDay> respiratoryRateDaysData = getMyRespiratoryRateDaysData(db, day, device);
+ return new RespiratoryRateData(respiratoryRateDaysData);
+ }
+
+ protected LineDataSet createDataSet(final List<Entry> values, String label, int color) {
+ final LineDataSet lineDataSet = new LineDataSet(values, label);
+ lineDataSet.setColor(getResources().getColor(color));
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setLineWidth(2f);
+ lineDataSet.setFillAlpha(255);
+ lineDataSet.setCircleRadius(5f);
+ lineDataSet.setDrawCircles(true);
+ lineDataSet.setDrawCircleHole(true);
+ lineDataSet.setCircleColor(getResources().getColor(color));
+ lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
+ lineDataSet.setDrawValues(false);
+ return lineDataSet;
+ }
+
+ @Override
+ protected void updateChartsnUIThread(RespiratoryRateData respiratoryRateData) {
+ respiratoryRateChart.setData(null);
+ final String emptyValue = requireContext().getString(R.string.stats_empty_value);
+ sleepAvg.setText(respiratoryRateData.sleepRateAvg > 0 ? String.valueOf(respiratoryRateData.sleepRateAvg) : emptyValue);
+ awakeAvg.setText(respiratoryRateData.awakeRateAvg > 0 ? String.valueOf(respiratoryRateData.awakeRateAvg) : emptyValue);
+
+ List<Entry> lineAwakeRateAvgEntries = new ArrayList<>();
+ List<Entry> lineSleepRateEntries = new ArrayList<>();
+ for (int i = 0; i < TOTAL_DAYS; i++) {
+ RespiratoryRateDay day = respiratoryRateData.days.get(i);
+ if (day.awakeRateAvg > 0) {
+ lineAwakeRateAvgEntries.add(new Entry(i, day.awakeRateAvg));
+ }
+ if (day.sleepRateAvg > 0) {
+ lineSleepRateEntries.add(new Entry(i, day.sleepRateAvg));
+ }
+ }
+
+ LineDataSet awakeDataSet = createDataSet(lineAwakeRateAvgEntries, getString(R.string.sleep_colored_stats_awake_avg), R.color.respiratory_rate_color);
+ LineDataSet sleepDataSet = createDataSet(lineSleepRateEntries, getString(R.string.sleep_avg), R.color.chart_light_sleep_light);
+
+ final List<ILineDataSet> lineDataSets = new ArrayList<>();
+ lineDataSets.add(awakeDataSet);
+ lineDataSets.add(sleepDataSet);
+
+ List<LegendEntry> legendEntries = new ArrayList<>(1);
+ LegendEntry awakeEntry = new LegendEntry();
+ awakeEntry.label = getString(R.string.sleep_colored_stats_awake_avg);
+ awakeEntry.formColor = getResources().getColor(R.color.respiratory_rate_color);
+ LegendEntry sleepEntry = new LegendEntry();
+ sleepEntry.label = getString(R.string.sleep_avg);
+ sleepEntry.formColor = getResources().getColor(R.color.chart_light_sleep_light);
+ legendEntries.add(awakeEntry);
+ legendEntries.add(sleepEntry);
+ respiratoryRateChart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ respiratoryRateChart.getLegend().setCustom(legendEntries);
+ final LineData lineData = new LineData(lineDataSets);
+ respiratoryRateChart.setData(lineData);
+ final XAxis x = respiratoryRateChart.getXAxis();
+ x.setValueFormatter(getRespiratoryRateChartDayValueFormatter(respiratoryRateData));
+ }
+
+ ValueFormatter getRespiratoryRateChartDayValueFormatter(RespiratoryRateData RespiratoryRateData) {
+ return new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ RespiratoryRateFragment.RespiratoryRateDay day = RespiratoryRateData.days.get((int) value);
+ String pattern = TOTAL_DAYS > 7 ? "dd" : "EEE";
+ SimpleDateFormat formatLetterDay = new SimpleDateFormat(pattern, Locale.getDefault());
+ return formatLetterDay.format(new Date(day.day.getTimeInMillis()));
+ }
+ };
+ }
+
+ @Override
+ protected void renderCharts() {
+ respiratoryRateChart.invalidate();
+ }
+
+ protected void setupLegend(Chart<?> chart) {}
+
+ protected static class RespiratoryRateData extends ChartsData {
+ List<RespiratoryRateDay> days;
+ int awakeRateAvg;
+ int sleepRateAvg;
+
+ protected RespiratoryRateData(List<RespiratoryRateDay> days) {
+ this.days = days;
+ int awakeTotal = 0;
+ int sleepTotal = 0;
+ int awakeCounter = 0;
+ int sleepCounter = 0;
+ for(RespiratoryRateDay day: days) {
+ if (day.awakeRateAvg > 0) {
+ awakeTotal += day.awakeRateAvg;
+ awakeCounter++;
+ }
+ if (day.sleepRateAvg > 0) {
+ sleepTotal += day.sleepRateAvg;
+ sleepCounter++;
+ }
+ }
+ if (awakeTotal > 0) {
+ this.awakeRateAvg = Math.round((float) awakeTotal / awakeCounter);
+ }
+ if (sleepTotal > 0) {
+ this.sleepRateAvg = Math.round((float) sleepTotal / sleepCounter);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SampleXLabelFormatter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SampleXLabelFormatter.java
new file mode 100644
index 0000000..bb5fdf3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SampleXLabelFormatter.java
@@ -0,0 +1,53 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.annotation.SuppressLint;
+
+import com.github.mikephil.charting.formatter.ValueFormatter;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+
+public class SampleXLabelFormatter extends ValueFormatter {
+ private final TimestampTranslation tsTranslation;
+ @SuppressLint("SimpleDateFormat")
+ private final SimpleDateFormat annotationDateFormat;
+ // SimpleDateFormat dateFormat = new SimpleDateFormat("dd.MM.yyyy HH:mm");
+ private final Calendar cal = GregorianCalendar.getInstance();
+
+ public SampleXLabelFormatter(final TimestampTranslation tsTranslation, String simpleDateFormatPattern) {
+ this.tsTranslation = tsTranslation;
+ this.annotationDateFormat = new SimpleDateFormat(simpleDateFormatPattern);
+ }
+
+ // TODO: this does not work. Cannot use precomputed labels
+ @Override
+ public String getFormattedValue(final float value) {
+ cal.clear();
+ final int ts = (int) value;
+ cal.setTimeInMillis(tsTranslation.toOriginalValue(ts) * 1000L);
+ final Date date = cal.getTime();
+ return annotationDateFormat.format(date);
+ }
+
+ public TimestampTranslation getTsTranslation() {
+ return tsTranslation;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ShowDurationDialog.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ShowDurationDialog.java
new file mode 100644
index 0000000..4614388
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ShowDurationDialog.java
@@ -0,0 +1,51 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.widget.TextView;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class ShowDurationDialog extends Dialog {
+ private final String mDuration;
+ private TextView durationLabel;
+
+ ShowDurationDialog(final String duration, final Context context) {
+ super(context);
+ mDuration = duration;
+ }
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_charts_durationdialog);
+
+ durationLabel = findViewById(R.id.charts_duration_label);
+ setDuration(mDuration);
+ }
+
+ public void setDuration(final String duration) {
+ if (mDuration != null) {
+ durationLabel.setText(duration);
+ } else {
+ durationLabel.setText("");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SingleEntryValueAnimator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SingleEntryValueAnimator.java
new file mode 100644
index 0000000..e099e2d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SingleEntryValueAnimator.java
@@ -0,0 +1,70 @@
+/* Copyright (C) 2015-2024 Carsten Pfeiffer
+
+ 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.activities.charts;
+
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+
+import com.github.mikephil.charting.animation.ChartAnimator;
+import com.github.mikephil.charting.data.Entry;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SingleEntryValueAnimator extends ChartAnimator {
+ private static final Logger LOG = LoggerFactory.getLogger(SingleEntryValueAnimator.class);
+
+ private final Entry entry;
+ private final ValueAnimator.AnimatorUpdateListener listener;
+ private float previousValue;
+
+ public SingleEntryValueAnimator(Entry singleEntry, ValueAnimator.AnimatorUpdateListener listener) {
+ super(listener);
+ this.listener = listener;
+ entry = singleEntry;
+ }
+
+ public void setEntryYValue(float value) {
+ this.previousValue = entry.getY();
+ entry.setY(value);
+ }
+
+ @Override
+ public void animateY(int durationMillis) {
+ // we start with the previous value and animate the change to the
+ // next value.
+ // as our animation values are not used as absolute values, but as factors,
+ // we have to calculate the proper factors in advance. The entry already has
+ // the new value, so we create a factor to calculate the old value from the
+ // new value.
+
+ float startAnim;
+ float endAnim = 1f;
+ if (entry.getY() == 0f) {
+ startAnim = 0f;
+ } else {
+ startAnim = previousValue / entry.getY();
+ }
+
+// LOG.debug("anim factors: " + startAnim + ", " + endAnim);
+
+ ObjectAnimator animatorY = ObjectAnimator.ofFloat(this, "phaseY", startAnim, endAnim);
+ animatorY.setDuration(durationMillis);
+ animatorY.addUpdateListener(listener);
+ animatorY.start();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysis.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysis.java
new file mode 100644
index 0000000..0c53344
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepAnalysis.java
@@ -0,0 +1,153 @@
+/* Copyright (C) 2019-2024 Jos茅 Rebelo, Petr Van臎k, Q-er
+
+ 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.activities.charts;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+
+public class SleepAnalysis {
+
+ public static final long MIN_SESSION_LENGTH = 5 * 60;
+ public static final long MAX_WAKE_PHASE_LENGTH = 2 * 60 * 60;
+
+ public List<SleepSession> calculateSleepSessions(List<? extends ActivitySample> samples) {
+ List<SleepSession> result = new ArrayList<>();
+
+ ActivitySample previousSample = null;
+ Date sleepStart = null;
+ Date sleepEnd = null;
+ long lightSleepDuration = 0;
+ long deepSleepDuration = 0;
+ long remSleepDuration = 0;
+ long awakeSleepDuration = 0;
+ long durationSinceLastSleep = 0;
+
+ for (ActivitySample sample : samples) {
+ if (isSleep(sample)) {
+ if (sleepStart == null)
+ sleepStart = getDateFromSample(sample);
+ sleepEnd = getDateFromSample(sample);
+
+ durationSinceLastSleep = 0;
+ } else {
+ //exclude "not worn" times from sleep sessions as this makes a discrepancy with the charts
+ if (lightSleepDuration + deepSleepDuration + remSleepDuration + awakeSleepDuration > MIN_SESSION_LENGTH)
+ result.add(new SleepSession(sleepStart, sleepEnd, lightSleepDuration, deepSleepDuration, remSleepDuration, awakeSleepDuration));
+ sleepStart = null;
+ sleepEnd = null;
+ lightSleepDuration = 0;
+ deepSleepDuration = 0;
+ remSleepDuration = 0;
+ awakeSleepDuration = 0;
+ }
+
+ if (previousSample != null) {
+ long durationSinceLastSample = sample.getTimestamp() - previousSample.getTimestamp();
+ if (sample.getKind() == ActivityKind.LIGHT_SLEEP) {
+ lightSleepDuration += durationSinceLastSample;
+ } else if (sample.getKind() == ActivityKind.DEEP_SLEEP) {
+ deepSleepDuration += durationSinceLastSample;
+ } else if (sample.getKind() == ActivityKind.REM_SLEEP) {
+ remSleepDuration += durationSinceLastSample;
+ } else if (sample.getKind() == ActivityKind.AWAKE_SLEEP) {
+ awakeSleepDuration += durationSinceLastSample;
+ } else {
+ durationSinceLastSleep += durationSinceLastSample;
+ if (sleepStart != null && durationSinceLastSleep > MAX_WAKE_PHASE_LENGTH) {
+ if (lightSleepDuration + deepSleepDuration + remSleepDuration + awakeSleepDuration > MIN_SESSION_LENGTH)
+ result.add(new SleepSession(sleepStart, sleepEnd, lightSleepDuration, deepSleepDuration, remSleepDuration, awakeSleepDuration));
+ sleepStart = null;
+ sleepEnd = null;
+ lightSleepDuration = 0;
+ deepSleepDuration = 0;
+ remSleepDuration = 0;
+ awakeSleepDuration = 0;
+ }
+ }
+ }
+
+ previousSample = sample;
+ }
+ if (lightSleepDuration + deepSleepDuration + remSleepDuration + awakeSleepDuration > MIN_SESSION_LENGTH) {
+ result.add(new SleepSession(sleepStart, sleepEnd, lightSleepDuration, deepSleepDuration, remSleepDuration, awakeSleepDuration));
+ }
+ return result;
+ }
+
+ private boolean isSleep(ActivitySample sample) {
+ return sample.getKind() == ActivityKind.DEEP_SLEEP ||
+ sample.getKind() == ActivityKind.LIGHT_SLEEP ||
+ sample.getKind() == ActivityKind.REM_SLEEP ||
+ sample.getKind() == ActivityKind.AWAKE_SLEEP;
+ }
+
+ private Date getDateFromSample(ActivitySample sample) {
+ return new Date(sample.getTimestamp() * 1000L);
+ }
+
+
+ public static class SleepSession {
+ private final Date sleepStart;
+ private final Date sleepEnd;
+ private final long lightSleepDuration;
+ private final long deepSleepDuration;
+ private final long remSleepDuration;
+ private final long awakeSleepDuration;
+
+ private SleepSession(Date sleepStart,
+ Date sleepEnd,
+ long lightSleepDuration,
+ long deepSleepDuration,
+ long remSleepDuration,
+ long awakeSleepDuration) {
+ this.sleepStart = sleepStart;
+ this.sleepEnd = sleepEnd;
+ this.lightSleepDuration = lightSleepDuration;
+ this.deepSleepDuration = deepSleepDuration;
+ this.remSleepDuration = remSleepDuration;
+ this.awakeSleepDuration = awakeSleepDuration;
+ }
+
+ public Date getSleepStart() {
+ return sleepStart;
+ }
+
+ public Date getSleepEnd() {
+ return sleepEnd;
+ }
+
+ public long getLightSleepDuration() {
+ return lightSleepDuration;
+ }
+
+ public long getDeepSleepDuration() {
+ return deepSleepDuration;
+ }
+
+ public long getRemSleepDuration() {
+ return remSleepDuration;
+ }
+
+ public long getAwakeSleepDuration() {
+ return awakeSleepDuration;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepCollectionFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepCollectionFragment.java
new file mode 100644
index 0000000..5377f5d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepCollectionFragment.java
@@ -0,0 +1,44 @@
+/* Copyright (C) 2024 a0z, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.os.Bundle;
+
+import androidx.fragment.app.FragmentManager;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
+import nodomain.freeyourgadget.gadgetbridge.adapter.NestedFragmentAdapter;
+import nodomain.freeyourgadget.gadgetbridge.adapter.SleepFragmentAdapter;
+
+public class SleepCollectionFragment extends AbstractCollectionFragment {
+ public SleepCollectionFragment() {
+
+ }
+
+ public static SleepCollectionFragment newInstance(final boolean allowSwipe) {
+ final SleepCollectionFragment fragment = new SleepCollectionFragment();
+ final Bundle args = new Bundle();
+ args.putBoolean(ARG_ALLOW_SWIPE, allowSwipe);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public NestedFragmentAdapter getNestedFragmentAdapter(AbstractGBFragment fragment, FragmentManager childFragmentManager) {
+ return new SleepFragmentAdapter(this, getChildFragmentManager());
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SpeedZonesFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SpeedZonesFragment.java
new file mode 100644
index 0000000..74986cb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SpeedZonesFragment.java
@@ -0,0 +1,174 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Daniele Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.HorizontalBarChart;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.BarData;
+import com.github.mikephil.charting.data.BarDataSet;
+import com.github.mikephil.charting.data.BarEntry;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+
+
+public class SpeedZonesFragment extends AbstractActivityChartFragment<ChartsData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(SpeedZonesFragment.class);
+
+ private HorizontalBarChart mStatsChart;
+
+ @Override
+ protected ChartsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ List<? extends ActivitySample> samples = getSamples(db, device);
+
+ MySpeedZonesData mySpeedZonesData = refreshStats(samples);
+
+ return new MyChartsData(mySpeedZonesData);
+ }
+
+ private MySpeedZonesData refreshStats(List<? extends ActivitySample> samples) {
+ ActivityAnalysis analysis = new ActivityAnalysis();
+ analysis.calculateActivityAmounts(samples);
+ BarData data = new BarData();
+ data.setValueTextColor(CHART_TEXT_COLOR);
+ List<BarEntry> entries = new ArrayList<>();
+
+ ActivityUser user = new ActivityUser();
+ /*double distanceFactorCm;
+ if (user.getGender() == user.GENDER_MALE){
+ distanceFactorCm = user.getHeightCm() * user.GENDER_MALE_DISTANCE_FACTOR / 1000;
+ } else {
+ distanceFactorCm = user.getHeightCm() * user.GENDER_FEMALE_DISTANCE_FACTOR / 1000;
+ }*/
+
+ for (Map.Entry<Integer, Long> entry : analysis.stats.entrySet()) {
+ entries.add(new BarEntry(entry.getKey(), entry.getValue() / 60));
+ }
+
+ BarDataSet set = new BarDataSet(entries, "");
+ set.setValueTextColor(CHART_TEXT_COLOR);
+ set.setColors(getColorFor(ActivityKind.ACTIVITY));
+ //set.setDrawValues(false);
+ //data.setBarWidth(0.1f);
+ data.addDataSet(set);
+
+ return new MySpeedZonesData(data);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(ChartsData chartsData) {
+ MyChartsData mcd = (MyChartsData) chartsData;
+ mStatsChart.setData(mcd.getChartsData().getBarData());
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.stats_title);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_statschart, container, false);
+
+ mStatsChart = (HorizontalBarChart) rootView.findViewById(R.id.statschart);
+ setupStatsChart();
+
+ // refresh immediately instead of use refreshIfVisible(), for perceived performance
+ refresh();
+
+ return rootView;
+ }
+
+ private void setupStatsChart() {
+ mStatsChart.setBackgroundColor(BACKGROUND_COLOR);
+ mStatsChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ mStatsChart.setNoDataText("");
+ mStatsChart.getLegend().setEnabled(false);
+ mStatsChart.setTouchEnabled(false);
+ mStatsChart.getDescription().setText("");
+
+ XAxis right = mStatsChart.getXAxis(); //believe it or not, the X axis is vertical for HorizontalBarChart
+ right.setTextColor(CHART_TEXT_COLOR);
+
+ YAxis bottom = mStatsChart.getAxisRight();
+ bottom.setTextColor(CHART_TEXT_COLOR);
+ bottom.setGranularity(1f);
+
+ YAxis top = mStatsChart.getAxisLeft();
+ top.setTextColor(CHART_TEXT_COLOR);
+ top.setGranularity(1f);
+ }
+
+ @Override
+ protected List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ return super.getAllSamples(db, device, tsFrom, tsTo);
+ }
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {
+ // no legend here, it is all about the steps here
+ chart.getLegend().setEnabled(false);
+ }
+
+ @Override
+ protected void renderCharts() {
+ mStatsChart.invalidate();
+ }
+
+ private static class MySpeedZonesData extends ChartsData {
+ private final BarData barData;
+
+ MySpeedZonesData(BarData barData) {
+ this.barData = barData;
+ }
+
+ BarData getBarData() {
+ return barData;
+ }
+ }
+
+ private static class MyChartsData extends ChartsData {
+ private final MySpeedZonesData chartsData;
+
+ MyChartsData(MySpeedZonesData chartsData) {
+ this.chartsData = chartsData;
+ }
+
+ MySpeedZonesData getChartsData() {
+ return chartsData;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/Spo2ChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/Spo2ChartFragment.java
new file mode 100644
index 0000000..f235cab
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/Spo2ChartFragment.java
@@ -0,0 +1,287 @@
+/* Copyright (C) 2023-2024 Martin.JM
+
+ 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.activities.charts;
+
+import android.graphics.Color;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.core.content.ContextCompat;
+
+import com.github.mikephil.charting.animation.Easing;
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.LimitLine;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+// Based on StressChartFragment
+
+public class Spo2ChartFragment extends AbstractChartFragment<Spo2ChartFragment.Spo2ChartsData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(Spo2ChartFragment.class);
+
+ private LineChart mSpo2Chart;
+
+ private int BACKGROUND_COLOR;
+ private int DESCRIPTION_COLOR;
+ private int CHART_TEXT_COLOR;
+ private int LEGEND_TEXT_COLOR;
+ private int CHART_LINE_COLOR;
+
+ private String SPO2_AVERAGE_LABEL;
+
+ private final Prefs prefs = GBApplication.getPrefs();
+
+ private final boolean CHARTS_SLEEP_RANGE_24H = prefs.getBoolean("chart_sleep_range_24h", false);
+ private final boolean SHOW_CHARTS_AVERAGE = prefs.getBoolean("charts_show_average", true);
+
+ @Override
+ protected void init() {
+ BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext());
+ LEGEND_TEXT_COLOR = DESCRIPTION_COLOR = GBApplication.getTextColor(requireContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+
+ if (prefs.getBoolean("chart_heartrate_color", false)) {
+ CHART_LINE_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate_alternative);
+ } else {
+ CHART_LINE_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate);
+ }
+
+ SPO2_AVERAGE_LABEL = requireContext().getString(R.string.charts_legend_spo2_average);
+ }
+
+ @Override
+ protected Spo2ChartsData refreshInBackground(final ChartsHost chartsHost, final DBHandler db, final GBDevice device) {
+ final List<? extends Spo2Sample> samples = getSamples(db, device);
+
+ LOG.info("Got {} SpO2 samples", samples.size());
+
+ return new Spo2ChartsDataBuilder(samples).build();
+ }
+
+ protected LineDataSet createDataSet(final List<Entry> values) {
+ final LineDataSet lineDataSet = new LineDataSet(values, "SpO2");
+ lineDataSet.setColor(CHART_LINE_COLOR);
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setLineWidth(2.2f);
+ lineDataSet.setFillAlpha(255);
+ lineDataSet.setValueTextColor(CHART_TEXT_COLOR);
+ lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
+ lineDataSet.setValueFormatter(new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ return String.format(Locale.ROOT, "%d", (int) value);
+ }
+ });
+ return lineDataSet;
+ }
+
+ @Override
+ protected void updateChartsnUIThread(final Spo2ChartsData spo2Data) {
+ final DefaultChartsData<LineData> chartsData = spo2Data.getChartsData();
+ mSpo2Chart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
+ mSpo2Chart.getXAxis().setValueFormatter(chartsData.getXValueFormatter());
+ mSpo2Chart.setData(chartsData.getData());
+ mSpo2Chart.getAxisLeft().removeAllLimitLines();
+
+ LOG.debug("SpO2 average: {}", spo2Data.getAverage());
+
+ if (spo2Data.getAverage() > 0 && SHOW_CHARTS_AVERAGE) {
+ final LimitLine averageLine = new LimitLine(spo2Data.getAverage());
+ averageLine.setLineColor(Color.RED);
+ averageLine.setLineWidth(1.5f);
+ averageLine.enableDashedLine(15f, 10f, 0f);
+ mSpo2Chart.getAxisLeft().addLimitLine(averageLine);
+ }
+
+ mSpo2Chart.getAxisRight().setEnabled(false);
+ }
+
+ @Override
+ public String getTitle() {
+ return requireContext().getString(R.string.pref_header_spo2);
+ }
+
+ @Override
+ public boolean isSingleDay() {
+ return false;
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater,
+ final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View rootView = inflater.inflate(R.layout.fragment_charts, container, false);
+
+ mSpo2Chart = rootView.findViewById(R.id.activitysleepchart);
+
+ setupLineChart();
+
+ // refresh immediately instead of use refreshIfVisible(), for perceived performance
+ refresh();
+
+ return rootView;
+ }
+
+ private void setupLineChart() {
+ mSpo2Chart.setBackgroundColor(BACKGROUND_COLOR);
+ mSpo2Chart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ configureBarLineChartDefaults(mSpo2Chart);
+
+ final XAxis x = mSpo2Chart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setDrawLimitLinesBehindData(true);
+
+ final YAxis yAxisLeft = mSpo2Chart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setAxisMaximum(100f);
+ yAxisLeft.setAxisMinimum(75f);
+ yAxisLeft.setDrawTopYLabelEntry(false);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+ yAxisLeft.setEnabled(true);
+ }
+
+ @Override
+ protected void setupLegend(final Chart<?> chart) {
+ final List<LegendEntry> legendEntries = new ArrayList<>(2);
+
+ final LegendEntry entry = new LegendEntry();
+ entry.label = requireContext().getString(R.string.pref_header_spo2);
+ entry.formColor = CHART_LINE_COLOR;
+ legendEntries.add(entry);
+
+ if (SHOW_CHARTS_AVERAGE) {
+ final LegendEntry averageEntry = new LegendEntry();
+ averageEntry.label = SPO2_AVERAGE_LABEL;
+ averageEntry.formColor = Color.RED;
+ legendEntries.add(averageEntry);
+ }
+
+ chart.getLegend().setCustom(legendEntries);
+ chart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ }
+
+ @Override
+ protected void renderCharts() {
+ mSpo2Chart.animateX(ANIM_TIME, Easing.EaseInOutQuart);
+ }
+
+ private List<? extends Spo2Sample> getSamples(final DBHandler db, final GBDevice device) {
+ final int tsStart = getTSStart();
+ final int tsEnd = getTSEnd();
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final TimeSampleProvider<? extends Spo2Sample> sampleProvider = coordinator.getSpo2SampleProvider(device, db.getDaoSession());
+ return sampleProvider.getAllSamples(tsStart * 1000L, tsEnd * 1000L);
+ }
+
+ protected class Spo2ChartsDataBuilder {
+ private final List<? extends Spo2Sample> samples;
+
+ private final TimestampTranslation tsTranslation = new TimestampTranslation();
+
+ private final List<Entry> lineEntries = new ArrayList<>();
+
+ long averageSum;
+ long averageNumSamples;
+
+ public Spo2ChartsDataBuilder(final List<? extends Spo2Sample> samples) {
+ this.samples = samples;
+ }
+
+ private void reset() {
+ tsTranslation.reset();
+ lineEntries.clear();
+
+ averageSum = 0;
+ averageNumSamples = 0;
+ }
+
+ private void processSamples() {
+ reset();
+
+ for (final Spo2Sample sample : samples) {
+ processSample(sample);
+ }
+ }
+
+ private void processSample(final Spo2Sample sample) {
+ final int ts = tsTranslation.shorten((int) (sample.getTimestamp() / 1000L));
+ lineEntries.add(new Entry(ts, sample.getSpo2()));
+
+ averageSum += sample.getSpo2();
+ averageNumSamples += 1;
+ }
+
+ public Spo2ChartsData build() {
+ processSamples();
+
+ final List<ILineDataSet> lineDataSets = new ArrayList<>();
+
+ lineDataSets.add(createDataSet(lineEntries));
+
+ final LineData lineData = new LineData(lineDataSets);
+ final ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm");
+ final DefaultChartsData<LineData> chartsData = new DefaultChartsData<>(lineData, xValueFormatter);
+ return new Spo2ChartsData(chartsData, Math.round((float) averageSum / averageNumSamples));
+ }
+ }
+
+ protected static class Spo2ChartsData extends ChartsData {
+ private final DefaultChartsData<LineData> chartsData;
+ private final int average;
+
+ public Spo2ChartsData(final DefaultChartsData<LineData> chartsData, final int average) {
+ this.chartsData = chartsData;
+ this.average = average;
+ }
+
+ public DefaultChartsData<LineData> getChartsData() {
+ return chartsData;
+ }
+
+ public int getAverage() {
+ return average;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepAnalysis.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepAnalysis.java
new file mode 100644
index 0000000..c35e8c9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepAnalysis.java
@@ -0,0 +1,260 @@
+/* Copyright (C) 2020-2024 Petr Van臎k
+
+ 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.activities.charts;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySession;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+
+public class StepAnalysis {
+ protected static final Logger LOG = LoggerFactory.getLogger(StepAnalysis.class);
+ private int totalDailySteps = 0;
+
+ public List<ActivitySession> calculateStepSessions(List<? extends ActivitySample> samples) {
+ LOG.debug("get all samples activity sessions: {}", samples.size());
+ List<ActivitySession> result = new ArrayList<>();
+ ActivityUser activityUser = new ActivityUser();
+ final int MIN_SESSION_LENGTH = 60 * GBApplication.getPrefs().getInt("chart_list_min_session_length", 5);
+ final int MAX_IDLE_PHASE_LENGTH = 60 * GBApplication.getPrefs().getInt("chart_list_max_idle_phase_length", 5);
+ final int MIN_STEPS_PER_MINUTE = GBApplication.getPrefs().getInt("chart_list_min_steps_per_minute", 40);
+ int stepLengthCm = activityUser.getStepLengthCm();
+ final double STEP_LENGTH_M = stepLengthCm * 0.01;
+ final double MIN_SESSION_INTENSITY = Math.max(0, Math.min(1, MIN_STEPS_PER_MINUTE * 0.01));
+ totalDailySteps = 0;
+
+ ActivitySample previousSample = null;
+ Date sessionStart = null;
+ Date sessionEnd;
+ int activeSteps = 0; //steps that we count
+ int activeDistanceCm = 0;
+ int stepsBetweenActivePeriods = 0; //steps during time when we maybe take a rest but then restart
+ int distanceBetweenActivePeriods = 0;
+ int durationSinceLastActiveStep = 0;
+ ActivityKind activityKind;
+
+ List<Integer> heartRateSum = new ArrayList<>();
+ List<Integer> heartRateBetweenActivePeriodsSum = new ArrayList<>();
+
+ float activeIntensity = 0;
+ float intensityBetweenActivePeriods = 0;
+ HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance();
+
+ for (ActivitySample sample : samples) {
+ int steps = sample.getSteps();
+ if (steps > 0) {
+ totalDailySteps += steps;
+ }
+
+ /*
+ * FIXME This should only consider non-sleep samples. However, this always had the wrong
+ * check for that, so it processed everything. In #3977, that was corrected, which
+ * introduces a regression for some devices such as the Amazfit Bip. Processing everything
+ * seems to work, but this logic needs to be reviewed.
+ */
+ if (!(sample instanceof TrailingActivitySample)) { //trailing samples have wrong date and make trailing activity have 0 duration
+
+ if (sessionStart == null) {
+ sessionStart = getDateFromSample(sample);
+ if (sample.getSteps() >= 0) {
+ activeSteps = sample.getSteps();
+ } else {
+ activeSteps = 0;
+ }
+ if (sample.getDistanceCm() >= 0) {
+ activeDistanceCm = sample.getDistanceCm();
+ } else if (activeSteps > 0) {
+ activeDistanceCm = activeSteps * stepLengthCm;
+ } else {
+ activeDistanceCm = 0;
+ }
+ activeIntensity = sample.getIntensity();
+ heartRateSum = new ArrayList<>();
+ if (heartRateUtilsInstance.isValidHeartRateValue(sample.getHeartRate())) {
+ heartRateSum.add(sample.getHeartRate());
+ }
+ durationSinceLastActiveStep = 0;
+ stepsBetweenActivePeriods = 0;
+ distanceBetweenActivePeriods = 0;
+ heartRateBetweenActivePeriodsSum = new ArrayList<>();
+ previousSample = null;
+ }
+ if (previousSample != null) {
+ int durationSinceLastSample = sample.getTimestamp() - previousSample.getTimestamp();
+
+ if (sample.getSteps() > MIN_STEPS_PER_MINUTE || //either some steps
+ (sample.getIntensity() > MIN_SESSION_INTENSITY && sample.getSteps() > 0)) { //or some intensity plus at least one step
+ activeSteps += sample.getSteps() + stepsBetweenActivePeriods;
+ if (sample.getDistanceCm() >= 0) {
+ activeDistanceCm += sample.getDistanceCm() + distanceBetweenActivePeriods;
+ } else {
+ activeDistanceCm += sample.getSteps() * stepLengthCm + distanceBetweenActivePeriods;
+ }
+ activeIntensity += sample.getIntensity() + intensityBetweenActivePeriods;
+ if (heartRateUtilsInstance.isValidHeartRateValue(sample.getHeartRate())) {
+ heartRateSum.add(sample.getHeartRate());
+ }
+ heartRateSum.addAll(heartRateBetweenActivePeriodsSum);
+ heartRateBetweenActivePeriodsSum = new ArrayList<>();
+ stepsBetweenActivePeriods = 0;
+ distanceBetweenActivePeriods = 0;
+ intensityBetweenActivePeriods = 0;
+ durationSinceLastActiveStep = 0;
+
+ } else { //short break data to remember, we will add it to the rest later, if break not too long
+ if (sample.getSteps() >= 0) {
+ stepsBetweenActivePeriods += sample.getSteps();
+ }
+ if (sample.getDistanceCm() >= 0) {
+ distanceBetweenActivePeriods += sample.getDistanceCm();
+ } else if (sample.getSteps() > 0) {
+ distanceBetweenActivePeriods += sample.getSteps() * stepLengthCm;
+ }
+ if (heartRateUtilsInstance.isValidHeartRateValue(sample.getHeartRate())) {
+ heartRateBetweenActivePeriodsSum.add(sample.getHeartRate());
+ }
+ durationSinceLastActiveStep += durationSinceLastSample;
+ intensityBetweenActivePeriods += sample.getIntensity();
+ }
+ if (durationSinceLastActiveStep >= MAX_IDLE_PHASE_LENGTH) { //break too long, we split here
+
+ int current = sample.getTimestamp();
+ int starting = (int) (sessionStart.getTime() / 1000);
+ int session_length = current - starting - durationSinceLastActiveStep;
+
+ if (session_length >= MIN_SESSION_LENGTH) { //valid activity session
+ int heartRateAverage = heartRateSum.toArray().length > 0 ? calculateSumOfInts(heartRateSum) / heartRateSum.toArray().length : 0;
+ float distance = activeDistanceCm * 0.01f;
+ sessionEnd = new Date((sample.getTimestamp() - durationSinceLastActiveStep) * 1000L);
+ activityKind = detect_activity_kind(session_length, activeSteps, heartRateAverage, activeIntensity);
+ ActivitySession activitySession = new ActivitySession(sessionStart, sessionEnd, activeSteps, heartRateAverage, activeIntensity, distance, activityKind);
+ //activitySession.setSessionType(ActivitySession.SESSION_ONGOING);
+ result.add(activitySession);
+ }
+ sessionStart = null;
+ }
+ }
+ previousSample = sample;
+ }
+ }
+ //trailing activity: make sure we show the last portion of the data as well in case no further activity is recorded yet
+
+ if (sessionStart != null) {
+ int current = previousSample.getTimestamp();
+ int starting = (int) (sessionStart.getTime() / 1000);
+ int session_length = current - starting - durationSinceLastActiveStep;
+
+ if (session_length >= MIN_SESSION_LENGTH) {
+ int heartRateAverage = heartRateSum.toArray().length > 0 ? calculateSumOfInts(heartRateSum) / heartRateSum.toArray().length : 0;
+ float distance = activeDistanceCm * 0.01f;
+ sessionEnd = getDateFromSample(previousSample);
+ activityKind = detect_activity_kind(session_length, activeSteps, heartRateAverage, activeIntensity);
+ ActivitySession ongoingActivity = new ActivitySession(sessionStart, sessionEnd, activeSteps, heartRateAverage, activeIntensity, distance, activityKind);
+ ongoingActivity.setSessionType(ActivitySession.SESSION_ONGOING);
+ result.add(ongoingActivity);
+ }
+ }
+ return result;
+ }
+
+ public ActivitySession calculateSummary(List<ActivitySession> sessions, boolean empty) {
+
+ Date startTime = null;
+ Date endTime = null;
+ int stepsSum = 0;
+ int heartRateAverage = 0;
+ List<Integer> heartRateSum = new ArrayList<>();
+ int distanceSum = 0;
+ float intensitySum = 0;
+ int sessionCount;
+ long durationSum = 0;
+
+ for (ActivitySession session : sessions) {
+ startTime = session.getStartTime();
+ endTime = session.getEndTime();
+ durationSum += endTime.getTime() - startTime.getTime();
+ stepsSum += session.getActiveSteps();
+ distanceSum += session.getDistance();
+ heartRateSum.add(session.getHeartRateAverage());
+ intensitySum += session.getIntensity();
+ }
+
+ sessionCount = sessions.toArray().length;
+ if (heartRateSum.toArray().length > 0) {
+ heartRateAverage = calculateSumOfInts(heartRateSum) / heartRateSum.toArray().length;
+ }
+ startTime = new Date(0);
+ endTime = new Date(durationSum);
+
+ ActivitySession stepSessionSummary = new ActivitySession(startTime, endTime,
+ stepsSum, heartRateAverage, intensitySum, distanceSum, ActivityKind.UNKNOWN);
+
+ stepSessionSummary.setSessionCount(sessionCount);
+ stepSessionSummary.setSessionType(ActivitySession.SESSION_SUMMARY);
+ stepSessionSummary.setEmptySummary(empty);
+
+
+ stepSessionSummary.setTotalDaySteps(totalDailySteps);
+ return stepSessionSummary;
+ }
+
+ public ActivitySession getOngoingSessions(List<ActivitySession> sessions) {
+
+ for (ActivitySession session : sessions) {
+ if (session.getSessionType() == ActivitySession.SESSION_ONGOING) {
+ return session;
+ }
+ }
+ return null;
+ }
+
+ private int calculateSumOfInts(List<Integer> samples) {
+ int result = 0;
+ for (Integer sample : samples) {
+ result += sample;
+ }
+ return result;
+ }
+
+ private ActivityKind detect_activity_kind(int session_length, int activeSteps, int heartRateAverage, float intensity) {
+ final int MIN_STEPS_PER_MINUTE_FOR_RUN = GBApplication.getPrefs().getInt("chart_list_min_steps_per_minute_for_run", 120);
+ int spm = (int) (activeSteps / (session_length / 60));
+ if (spm > MIN_STEPS_PER_MINUTE_FOR_RUN) {
+ return ActivityKind.RUNNING;
+ }
+ if (activeSteps > 200) {
+ return ActivityKind.WALKING;
+ }
+ if (heartRateAverage > 90 && intensity > 15) { //needs tuning
+ return ActivityKind.EXERCISE;
+ }
+ return ActivityKind.ACTIVITY;
+ }
+
+ private Date getDateFromSample(ActivitySample sample) {
+ return new Date(sample.getTimestamp() * 1000L);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java
new file mode 100644
index 0000000..39f138e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepStreaksDashboard.java
@@ -0,0 +1,496 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver, Petr Van臎k
+
+ 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.activities.charts;
+
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.format.DateUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.FileProvider;
+import androidx.fragment.app.FragmentActivity;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Calendar;
+import java.util.Date;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DailyTotals;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.dialogs.MaterialDialogFragment;
+
+public class StepStreaksDashboard extends MaterialDialogFragment {
+ protected static final Logger LOG = LoggerFactory.getLogger(StepStreaksDashboard.class);
+ GBDevice gbDevice;
+ int stepsGoal;
+ boolean cancelTasks = false;
+ boolean backgroundTaskFinished = false;
+ private StepsStreaks stepsStreaks = new StepsStreaks();
+ private static final String GOAL = "goal";
+ private static final String STREAKS = "streaks";
+ private static final String PERIOD_CURRENT = "current";
+ private static final String PERIOD_TOTALS = "totals";
+ private static final int MIN_YEAR = 2015; //we go back in time, this is minimal year boundary
+
+ public StepStreaksDashboard() {
+
+ }
+
+ //Calculates some stats for longest streak (daily steps goal being reached for subsequent days
+ //without interruption (day with steps less then goal)
+ //Possible improvements/nice to haves:
+ //- cache values until new activity fetch is performed
+ //- read the goals from the USER_ATTRIBUTES table. But, this would also require to be able
+ //to edit/add values there...
+
+ public static StepStreaksDashboard newInstance(int goal, GBDevice device) {
+
+ StepStreaksDashboard fragment = new StepStreaksDashboard();
+ Bundle args = new Bundle();
+ args.putInt(GOAL, goal);
+ args.putParcelable(GBDevice.EXTRA_DEVICE, device);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.steps_streaks_dashboard, container);
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ cancelTasks = true;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ cancelTasks = true;
+ }
+
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (backgroundTaskFinished) {
+ outState.putParcelable(STREAKS, stepsStreaks);
+ }
+ }
+
+ @Override
+ public void onViewCreated(final View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ stepsGoal = getArguments().getInt(GOAL, 0);
+ gbDevice = getArguments().getParcelable(GBDevice.EXTRA_DEVICE);
+ if (gbDevice == null) {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+
+ if (savedInstanceState != null) {
+ StepsStreaks streaks = (StepsStreaks) savedInstanceState.getParcelable(STREAKS);
+ if (streaks != null) {
+ stepsStreaks = streaks;
+ backgroundTaskFinished = true;
+ cancelTasks = true;
+ indicate_progress(false);
+ populateData();
+ }
+ }
+ createTaskCalculateLatestStepsStreak("Visualizing data current", getActivity(), PERIOD_CURRENT).execute();
+ createTaskCalculateLatestStepsStreak("Visualizing data maximum", getActivity(), PERIOD_TOTALS).execute();
+ }
+
+ void indicate_progress(boolean inProgress) {
+ ProgressBar step_streak_dashboard_loading_circle = getView().findViewById(R.id.step_streak_dashboard_loading_circle);
+ if (inProgress) {
+ step_streak_dashboard_loading_circle.setAlpha(0.4f); //make it a bit softer
+ } else {
+ step_streak_dashboard_loading_circle.setAlpha(0);
+ }
+ }
+
+ void populateData() {
+
+ LinearLayout current = getView().findViewById(R.id.step_streak_current_layout);
+ TextView days_current = current.findViewById(R.id.step_streak_days_value);
+ TextView average_current = current.findViewById(R.id.step_streak_average_value);
+ TextView total_current = current.findViewById(R.id.step_streak_total_value);
+ TextView date_current_value = current.findViewById(R.id.step_streak_current_date_value);
+
+ LinearLayout maximum = getView().findViewById(R.id.step_streak_maximum_layout);
+ TextView days_maximum = maximum.findViewById(R.id.step_streak_days_value);
+ TextView average_maximum = maximum.findViewById(R.id.step_streak_average_value);
+ TextView total_maximum = maximum.findViewById(R.id.step_streak_total_value);
+ TextView date_maximum_value = maximum.findViewById(R.id.step_streak_maximum_date_value);
+
+ LinearLayout total = getView().findViewById(R.id.step_streak_total_layout);
+ TextView days_total = total.findViewById(R.id.step_streak_days_value);
+ TextView days_total_label = total.findViewById(R.id.step_streak_days_label);
+ TextView total_total = total.findViewById(R.id.step_streak_total_value);
+ TextView date_total_value = total.findViewById(R.id.step_streak_total_date_value);
+ TextView date_total_label = total.findViewById(R.id.step_streak_total_label);
+
+ ImageButton step_streak_share_button = getView().findViewById(R.id.step_streak_share_button);
+ step_streak_share_button.setVisibility(View.GONE);
+ step_streak_share_button.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ take_share_screenshot(getActivity());
+ }
+ });
+
+ if (stepsStreaks.current.days > 0) {
+ current.setVisibility(View.VISIBLE);
+ days_current.setText(Integer.toString(stepsStreaks.current.days));
+ average_current.setText(Integer.toString(stepsStreaks.current.steps / stepsStreaks.current.days));
+ total_current.setText(Integer.toString(stepsStreaks.current.steps));
+
+ Date startDate = new Date(stepsStreaks.current.timestamp * 1000L);
+ Date endDate = DateTimeUtils.shiftByDays(startDate, stepsStreaks.current.days - 1); //first day is 1 not 0
+ date_current_value.setText(DateTimeUtils.formatDateRange(startDate, endDate));
+ }
+
+ if (stepsStreaks.maximum.days > 0) {
+ maximum.setVisibility(View.VISIBLE);
+ days_maximum.setText(Integer.toString(stepsStreaks.maximum.days));
+ average_maximum.setText(Integer.toString(stepsStreaks.maximum.steps / stepsStreaks.maximum.days));
+ total_maximum.setText(Integer.toString(stepsStreaks.maximum.steps));
+
+ Date startDate = new Date(stepsStreaks.maximum.timestamp * 1000L);
+ Date endDate = DateTimeUtils.shiftByDays(startDate, stepsStreaks.maximum.days - 1); //first day is 1 not 0
+ date_maximum_value.setText(DateTimeUtils.formatDateRange(startDate, endDate));
+ }
+ if (stepsStreaks.total.steps > 0 || backgroundTaskFinished) {
+ step_streak_share_button.setVisibility(View.VISIBLE);
+ total.setVisibility(View.VISIBLE);
+ days_total_label.setText(R.string.steps_streaks_achievement_rate);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ //labels here have diferent meaning, so we must also add proper hint
+ days_total_label.setTooltipText(getString(R.string.steps_streaks_total_days_hint_totals));
+ days_total.setTooltipText(getString(R.string.steps_streaks_total_days_hint_totals));
+ date_total_label.setTooltipText(getString(R.string.steps_streaks_total_steps_hint_totals));
+ }
+
+ days_total.setText(String.format("%.1f%%", 0.0));
+ if (stepsStreaks.total.total_days > 0) {
+ days_total.setText(String.format("%.1f%%", (float) stepsStreaks.total.days / stepsStreaks.total.total_days * 100));
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ total_total.setTooltipText(String.format(getString(R.string.steps_streaks_total_steps_average_hint), stepsStreaks.total.steps / stepsStreaks.total.total_days));
+ }
+ }
+ if (stepsStreaks.total.timestamp > 0) {
+ date_total_value.setVisibility(View.VISIBLE);
+ date_total_value.setText(String.format(getString(R.string.steps_streaks_since_date), DateTimeUtils.formatDate(new Date(stepsStreaks.total.timestamp * 1000L))));
+ } else {
+ date_total_value.setVisibility(View.GONE);
+ }
+ total_total.setText(Integer.toString(stepsStreaks.total.steps));
+ }
+ }
+
+ protected TaskCalculateLatestStepsStreak createTaskCalculateLatestStepsStreak(String taskName, Context context, String period) {
+ return new TaskCalculateLatestStepsStreak(taskName, context, period);
+ }
+
+ public class TaskCalculateLatestStepsStreak extends DBAccess {
+ String period;
+
+ public TaskCalculateLatestStepsStreak(String taskName, Context context, String period) {
+ super(taskName, context);
+ this.period = period;
+ }
+
+ @Override
+ protected void doInBackground(DBHandler db) {
+ switch (period) {
+ case PERIOD_CURRENT:
+ calculateStreakData(db, PERIOD_CURRENT, gbDevice, stepsGoal);
+
+ break;
+ case PERIOD_TOTALS:
+ calculateStreakData(db, PERIOD_TOTALS, gbDevice, stepsGoal);
+ break;
+ }
+ }
+
+ @Override
+ protected void onPreExecute() {
+ indicate_progress(true);
+ }
+
+ @Override
+ protected void onPostExecute(Object o) {
+ super.onPostExecute(o);
+ FragmentActivity activity = getActivity();
+ if (activity != null && !activity.isFinishing() && !activity.isDestroyed()) {
+ if (period.equals(PERIOD_TOTALS)) {
+ backgroundTaskFinished = true;
+ indicate_progress(false);
+ }
+ populateData();
+ } else {
+ LOG.info("Not filling data because activity is not available anymore");
+ }
+ }
+ }
+
+ private void calculateStreakData(DBHandler db, String period, GBDevice device, int goal) {
+ Calendar day = Calendar.getInstance();
+ int streak_steps = 0;
+ int streak_days = 0;
+ int timestamp = 0;
+
+ int all_step_days = 0;
+ int all_streak_days = 0;
+ int all_steps = 0;
+ int firstDataTimestamp = 0;
+
+ ActivitySample firstSample = DailyTotals.getFirstSample(db, device);
+ if (firstSample == null) { //no data at all
+ return;
+ }
+ Calendar firstDate = Calendar.getInstance();
+ firstDate.setTime(DateTimeUtils.shiftByDays(new Date(firstSample.getTimestamp() * 1000L), -1));
+ //go one day back, to ensure we are before the first day, to calculate first day data as well
+
+ while (true) {
+ if (cancelTasks) {
+ GB.toast("Cancelling background jobs", Toast.LENGTH_SHORT, GB.INFO);
+ break;
+ }
+
+ DailyTotals daily_data = DailyTotals.getDailyTotalsForDevice(device, day, db);
+ int steps_this_day = (int) daily_data.getSteps();
+
+ if (steps_this_day > 0) {
+ all_step_days++;
+ all_steps += steps_this_day;
+ firstDataTimestamp = (int) (day.getTimeInMillis() / 1000);
+ }
+
+ if (steps_this_day >= goal) {
+ streak_steps += steps_this_day;
+ streak_days++;
+ all_streak_days++;
+ timestamp = (int) (day.getTimeInMillis() / 1000);
+ Date newDate = DateTimeUtils.shiftByDays(new Date(day.getTimeInMillis()), -1);
+ day.setTime(newDate);
+ } else if (DateUtils.isToday(day.getTimeInMillis())) {
+ //if goal is not reached today, we might still get our steps later
+ // so do not count this day but do not interrupt
+ Date newDate = DateTimeUtils.shiftByDays(new Date(day.getTimeInMillis()), -1);
+ day.setTime(newDate);
+ } else {
+ if (period.equals(PERIOD_CURRENT)) {
+ stepsStreaks.current.days = streak_days;
+ stepsStreaks.current.steps = streak_steps;
+ stepsStreaks.current.timestamp = timestamp;
+ return;
+ } else if (period.equals(PERIOD_TOTALS)) {
+ //reset max
+ if (streak_days > stepsStreaks.maximum.days) {
+ stepsStreaks.maximum.steps = streak_steps;
+ stepsStreaks.maximum.days = streak_days;
+ stepsStreaks.maximum.timestamp = timestamp;
+ }
+
+ streak_days = 0;
+ streak_steps = 0;
+ Date newDate = DateTimeUtils.shiftByDays(new Date(day.getTimeInMillis()), -1);
+ day.setTime(newDate);
+ if (day.before(firstDate) || day.get(Calendar.YEAR) < MIN_YEAR) {
+ //avoid rolling back too far, if the data has a timestamp too far into future
+ //we could make this date configurable if needed for people who imported old data
+ stepsStreaks.total.steps = all_steps;
+ stepsStreaks.total.days = all_streak_days;
+ stepsStreaks.total.total_days = all_step_days;
+ stepsStreaks.total.timestamp = firstDataTimestamp;
+ return;
+ }
+ }
+ }
+ }
+ }
+
+ private void take_share_screenshot(Context context) {
+ final ScrollView layout = getView().findViewById(R.id.streaks_dashboard);
+ final LinearLayout sharingLayout = getView().findViewById(R.id.streaks_dashboard_inner);
+ int width = layout.getChildAt(0).getHeight();
+ int height = layout.getChildAt(0).getWidth();
+ Bitmap screenShot = getScreenShot(sharingLayout, width, height, context);
+ String fileName = FileUtils.makeValidFileName("Screenshot-" + "StepsStreak-" + DateTimeUtils.formatIso8601(new Date(Calendar.getInstance().getTimeInMillis())) + ".png");
+
+ try {
+ File targetFile = new File(FileUtils.getExternalFilesDir(), fileName);
+ FileOutputStream fOut = new FileOutputStream(targetFile);
+ screenShot.compress(Bitmap.CompressFormat.PNG, 85, fOut);
+ fOut.flush();
+ fOut.close();
+ shareScreenshot(targetFile, context);
+ GB.toast(getActivity(), "Screenshot saved", Toast.LENGTH_LONG, GB.INFO);
+ } catch (IOException e) {
+ LOG.error("Error getting screenshot", e);
+ }
+ }
+
+ private void shareScreenshot(File targetFile, Context context) {
+ Uri contentUri = FileProvider.getUriForFile(context,
+ context.getApplicationContext().getPackageName() + ".screenshot_provider", targetFile);
+ getActivity().grantUriPermission(
+ context.getApplicationContext().getPackageName(),
+ contentUri,
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION
+ );
+ Intent sharingIntent = new Intent(android.content.Intent.ACTION_SEND);
+ sharingIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ sharingIntent.setType("image/*");
+ String shareBody = getString(R.string.step_streaks_achievements_sharing_message);
+ sharingIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, getString(R.string.step_streaks_achievements_sharing_title));
+ sharingIntent.putExtra(android.content.Intent.EXTRA_TEXT, shareBody);
+ sharingIntent.putExtra(Intent.EXTRA_STREAM, contentUri);
+
+ try {
+ startActivity(Intent.createChooser(sharingIntent, "Share via"));
+ } catch (ActivityNotFoundException e) {
+ Toast.makeText(context, R.string.activity_error_no_app_for_png, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ public static Bitmap getScreenShot(View view, int height, int width, Context context) {
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ canvas.drawColor(GBApplication.getWindowBackgroundColor(context));
+ view.draw(canvas);
+ return bitmap;
+ }
+
+ private static class StepsStreak implements Parcelable {
+ private int days = 0;
+ private int steps = 0;
+ private int timestamp;
+ private int total_days = 0;
+
+ private StepsStreak() {
+
+ }
+
+ protected StepsStreak(Parcel in) {
+ days = in.readInt();
+ steps = in.readInt();
+ timestamp = in.readInt();
+ total_days = in.readInt();
+ }
+
+ public static final Creator<StepsStreak> CREATOR = new Creator<StepsStreak>() {
+ @Override
+ public StepsStreak createFromParcel(Parcel in) {
+ return new StepsStreak(in);
+ }
+
+ @Override
+ public StepsStreak[] newArray(int size) {
+ return new StepsStreak[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(days);
+ dest.writeInt(steps);
+ dest.writeInt(timestamp);
+ dest.writeInt(total_days);
+ }
+ }
+
+ private class StepsStreaks implements Parcelable {
+ private StepsStreak current = new StepsStreak();
+ private StepsStreak maximum = new StepsStreak();
+ private StepsStreak total = new StepsStreak();
+
+ private StepsStreaks() {
+
+ }
+
+ protected StepsStreaks(Parcel in) {
+ current = in.readParcelable(StepsStreak.class.getClassLoader());
+ maximum = in.readParcelable(StepsStreak.class.getClassLoader());
+ total = in.readParcelable(StepsStreak.class.getClassLoader());
+ }
+
+ public final Creator<StepsStreaks> CREATOR = new Creator<StepsStreaks>() {
+ @Override
+ public StepsStreaks createFromParcel(Parcel in) {
+ return new StepsStreaks(in);
+ }
+
+ @Override
+ public StepsStreaks[] newArray(int size) {
+ return new StepsStreaks[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeParcelable(current, flags);
+ dest.writeParcelable(maximum, flags);
+ dest.writeParcelable(total, flags);
+ }
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsCollectionFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsCollectionFragment.java
new file mode 100644
index 0000000..b900426
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsCollectionFragment.java
@@ -0,0 +1,45 @@
+/* Copyright (C) 2024 a0z, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.os.Bundle;
+
+import androidx.fragment.app.FragmentManager;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
+import nodomain.freeyourgadget.gadgetbridge.adapter.NestedFragmentAdapter;
+import nodomain.freeyourgadget.gadgetbridge.adapter.StepsFragmentAdapter;
+
+public class StepsCollectionFragment extends AbstractCollectionFragment {
+ public StepsCollectionFragment() {
+
+ }
+
+ public static StepsCollectionFragment newInstance(final boolean allowSwipe) {
+ final StepsCollectionFragment fragment = new StepsCollectionFragment();
+ final Bundle args = new Bundle();
+ args.putBoolean(ARG_ALLOW_SWIPE, allowSwipe);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public NestedFragmentAdapter getNestedFragmentAdapter(AbstractGBFragment fragment, FragmentManager childFragmentManager) {
+ return new StepsFragmentAdapter(this, getChildFragmentManager());
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsDailyFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsDailyFragment.java
new file mode 100644
index 0000000..f20b048
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsDailyFragment.java
@@ -0,0 +1,243 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.ColorInt;
+import androidx.fragment.app.FragmentManager;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.LimitLine;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.GaugeDrawer;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+
+public class StepsDailyFragment extends StepsFragment<StepsDailyFragment.StepsData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(BodyEnergyFragment.class);
+
+ private TextView mDateView;
+ private ImageView stepsGauge;
+ private TextView steps;
+ private TextView distance;
+ ImageView stepsStreaksButton;
+ private LineChart stepsChart;
+
+ protected int STEPS_GOAL;
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_steps, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ mDateView = rootView.findViewById(R.id.steps_date_view);
+ stepsGauge = rootView.findViewById(R.id.steps_gauge);
+ steps = rootView.findViewById(R.id.steps_count);
+ distance = rootView.findViewById(R.id.steps_distance);
+ stepsChart = rootView.findViewById(R.id.steps_daily_chart);
+ setupStepsChart();
+
+ STEPS_GOAL = GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_STEPS_GOAL, ActivityUser.defaultUserStepsGoal);
+ refresh();
+
+ stepsStreaksButton = rootView.findViewById(R.id.steps_streaks_button);
+ stepsStreaksButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ FragmentManager fm = getActivity().getSupportFragmentManager();
+ StepStreaksDashboard stepStreaksDashboard = StepStreaksDashboard.newInstance(STEPS_GOAL, getChartsHost().getDevice());
+ stepStreaksDashboard.show(fm, "steps_streaks_dashboard");
+ }
+ });
+
+ return rootView;
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.steps);
+ }
+
+ @Override
+ protected StepsDailyFragment.StepsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ Calendar day = Calendar.getInstance();
+ day.setTime(chartsHost.getEndDate());
+ List<StepsDay> stepsDayList = getMyStepsDaysData(db, day, device);
+ final StepsDay stepsDay;
+ if (stepsDayList.isEmpty()) {
+ LOG.error("Failed to get StepsDay for {}", day);
+ stepsDay = new StepsDay(day, 0, 0);
+ } else {
+ stepsDay = stepsDayList.get(0);
+ }
+ List<? extends ActivitySample> samplesOfDay = getSamplesOfDay(db, day, 0, device);
+ return new StepsDailyFragment.StepsData(stepsDay, samplesOfDay);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(StepsDailyFragment.StepsData stepsData) {
+ String formattedDate = new SimpleDateFormat("E, MMM dd").format(getEndDate());
+ mDateView.setText(formattedDate);
+
+ final int width = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 300,
+ GBApplication.getContext().getResources().getDisplayMetrics()
+ );
+
+ stepsGauge.setImageBitmap(GaugeDrawer.drawCircleGauge(
+ width,
+ width / 15,
+ getResources().getColor(R.color.steps_color),
+ (int) stepsData.todayStepsDay.steps,
+ STEPS_GOAL,
+ getContext()
+ ));
+
+ steps.setText(String.format(String.valueOf(stepsData.todayStepsDay.steps)));
+
+ final WorkoutValueFormatter valueFormatter = new WorkoutValueFormatter();
+ distance.setText(valueFormatter.formatValue(stepsData.todayStepsDay.distance, "km"));
+
+ // Chart
+ final List<LegendEntry> legendEntries = new ArrayList<>(1);
+ final LegendEntry stepsEntry = new LegendEntry();
+ stepsEntry.label = getString(R.string.steps);
+ stepsEntry.formColor = getResources().getColor(R.color.steps_color);
+ legendEntries.add(stepsEntry);
+ stepsChart.getLegend().setTextColor(TEXT_COLOR);
+ stepsChart.getLegend().setCustom(legendEntries);
+
+ final List<Entry> lineEntries = new ArrayList<>();
+ final TimestampTranslation tsTranslation = new TimestampTranslation();
+ int sum = 0;
+ for (final ActivitySample sample : stepsData.samples) {
+ if (sample.getSteps() > 0) {
+ sum += sample.getSteps();
+ }
+ lineEntries.add(new Entry(tsTranslation.shorten(sample.getTimestamp()), sum));
+ }
+
+ stepsChart.getXAxis().setValueFormatter(new SampleXLabelFormatter(tsTranslation, "HH:mm"));
+
+ if (sum < STEPS_GOAL) {
+ stepsChart.getAxisLeft().setAxisMaximum(STEPS_GOAL);
+ } else {
+ stepsChart.getAxisLeft().resetAxisMaximum();
+ }
+
+ final LineDataSet lineDataSet = new LineDataSet(lineEntries, getString(R.string.steps));
+ lineDataSet.setColor(getResources().getColor(R.color.steps_color));
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setLineWidth(2f);
+ lineDataSet.setFillAlpha(255);
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setCircleColor(getResources().getColor(R.color.steps_color));
+ lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
+ lineDataSet.setDrawValues(false);
+ lineDataSet.setMode(LineDataSet.Mode.CUBIC_BEZIER);
+ lineDataSet.setDrawFilled(true);
+ lineDataSet.setFillAlpha(60);
+ lineDataSet.setFillColor(getResources().getColor(R.color.steps_color ));
+
+ final LimitLine goalLine = new LimitLine(STEPS_GOAL);
+ goalLine.setLineColor(getResources().getColor(R.color.steps_color));
+ goalLine.setLineWidth(1.5f);
+ goalLine.enableDashedLine(15f, 10f, 0f);
+ stepsChart.getAxisLeft().removeAllLimitLines();
+ stepsChart.getAxisLeft().addLimitLine(goalLine);
+ stepsChart.getAxisLeft().setAxisMaximum(Math.max(lineDataSet.getYMax(), STEPS_GOAL) + 2000);
+
+ final List<ILineDataSet> lineDataSets = new ArrayList<>();
+ lineDataSets.add(lineDataSet);
+ final LineData lineData = new LineData(lineDataSets);
+ stepsChart.setData(lineData);
+ }
+
+ @Override
+ protected void renderCharts() {
+ stepsChart.invalidate();
+ }
+
+ protected void setupLegend(Chart<?> chart) {}
+
+ private void setupStepsChart() {
+ stepsChart.getDescription().setEnabled(false);
+ stepsChart.setDoubleTapToZoomEnabled(false);
+
+ final XAxis xAxisBottom = stepsChart.getXAxis();
+ xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
+ xAxisBottom.setDrawLabels(true);
+ xAxisBottom.setDrawGridLines(false);
+ xAxisBottom.setEnabled(true);
+ xAxisBottom.setDrawLimitLinesBehindData(true);
+ xAxisBottom.setTextColor(CHART_TEXT_COLOR);
+ xAxisBottom.setAxisMinimum(0f);
+ xAxisBottom.setAxisMaximum(86400f);
+ //xAxisBottom.setLabelCount(7, true);
+
+ final YAxis yAxisLeft = stepsChart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setAxisMinimum(0);
+ yAxisLeft.setDrawTopYLabelEntry(true);
+ yAxisLeft.setEnabled(true);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+
+ final YAxis yAxisRight = stepsChart.getAxisRight();
+ yAxisRight.setEnabled(true);
+ yAxisRight.setDrawLabels(false);
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setDrawAxisLine(true);
+ }
+
+ protected static class StepsData extends ChartsData {
+ StepsDay todayStepsDay;
+ List<? extends ActivitySample> samples;
+
+ public StepsData(final StepsDay todayStepsDay, final List<? extends ActivitySample> samplesOfDay) {
+ this.todayStepsDay = todayStepsDay;
+ this.samples = samplesOfDay;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsFragment.java
new file mode 100644
index 0000000..c3dfba4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsFragment.java
@@ -0,0 +1,134 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.app.Activity;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmount;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmounts;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
+
+abstract class StepsFragment<T extends ChartsData> extends AbstractChartFragment<T> {
+ protected static final Logger LOG = LoggerFactory.getLogger(StepsDailyFragment.class);
+
+ protected int CHART_TEXT_COLOR;
+ protected int TEXT_COLOR;
+
+ protected int BACKGROUND_COLOR;
+ protected int DESCRIPTION_COLOR;
+ protected int TOTAL_DAYS = 1;
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.steps);
+ }
+
+ @Override
+ protected void init() {
+ TEXT_COLOR = GBApplication.getTextColor(requireContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+ BACKGROUND_COLOR = GBApplication.getBackgroundColor(getContext());
+ DESCRIPTION_COLOR = GBApplication.getTextColor(getContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext());
+ }
+
+ protected List<StepsFragment.StepsDay> getMyStepsDaysData(DBHandler db, Calendar day, GBDevice device) {
+ day = (Calendar) day.clone(); // do not modify the caller's argument
+ day.add(Calendar.DATE, -TOTAL_DAYS + 1);
+
+ List<StepsDay> daysData = new ArrayList<>();;
+ for (int counter = 0; counter < TOTAL_DAYS; counter++) {
+ long totalSteps = 0;
+ long totalDistance = 0;
+ ActivityAmounts amounts = getActivityAmountsForDay(db, day, device);
+ for (ActivityAmount amount : amounts.getAmounts()) {
+ if (amount.getTotalSteps() > 0) {
+ totalSteps += amount.getTotalSteps();
+ }
+ if (amount.getTotalDistance() > 0) {
+ totalDistance += amount.getTotalDistance();
+ }
+ }
+ double distance = totalDistance;
+ if (totalDistance == 0 && totalSteps > 0) {
+ // For gadgets that do not report distance, compute it from the steps
+ ActivityUser activityUser = new ActivityUser();
+ int stepLength = activityUser.getStepLengthCm();
+ distance = stepLength * totalSteps;
+ }
+ Calendar d = (Calendar) day.clone();
+ daysData.add(new StepsDay(d, totalSteps, distance / 100_000));
+ day.add(Calendar.DATE, 1);
+ }
+ return daysData;
+ }
+
+ protected ActivityAmounts getActivityAmountsForDay(DBHandler db, Calendar day, GBDevice device) {
+ LimitedQueue<Integer, ActivityAmounts> activityAmountCache = null;
+ ActivityAmounts amounts = null;
+
+ Activity activity = getActivity();
+ int key = (int) (day.getTimeInMillis() / 1000);
+ if (activity != null) {
+ activityAmountCache = ((ActivityChartsActivity) activity).mActivityAmountCache;
+ amounts = activityAmountCache.lookup(key);
+ }
+
+ if (amounts == null) {
+ ActivityAnalysis analysis = new ActivityAnalysis();
+ amounts = analysis.calculateActivityAmounts(getSamplesOfDay(db, day, 0, device));
+ if (activityAmountCache != null) {
+ activityAmountCache.add(key, amounts);
+ }
+ }
+
+ return amounts;
+ }
+
+ protected List<? extends ActivitySample> getSamplesOfDay(DBHandler db, Calendar day, int offsetHours, GBDevice device) {
+ int startTs;
+ int endTs;
+
+ day = (Calendar) day.clone(); // do not modify the caller's argument
+ day.set(Calendar.HOUR_OF_DAY, 0);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ day.add(Calendar.HOUR, offsetHours);
+
+ startTs = (int) (day.getTimeInMillis() / 1000);
+ endTs = startTs + 24 * 60 * 60 - 1;
+
+ return getSamples(db, device, startTs, endTs);
+ }
+
+ protected List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ SampleProvider<? extends ActivitySample> provider = device.getDeviceCoordinator().getSampleProvider(device, db.getDaoSession());
+ return provider.getAllActivitySamples(tsFrom, tsTo);
+ }
+
+ protected static class StepsDay {
+ public long steps;
+ public double distance;
+ public Calendar day;
+
+ protected StepsDay(Calendar day, long steps, double distance) {
+ this.steps = steps;
+ this.distance = distance;
+ this.day = day;
+ }
+ }
+
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsPeriodFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsPeriodFragment.java
new file mode 100644
index 0000000..8e377db
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsPeriodFragment.java
@@ -0,0 +1,231 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.graphics.Color;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.github.mikephil.charting.charts.BarChart;
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.components.LimitLine;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.BarData;
+import com.github.mikephil.charting.data.BarDataSet;
+import com.github.mikephil.charting.data.BarEntry;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+
+import org.apache.commons.lang3.time.DateUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+
+public class StepsPeriodFragment extends StepsFragment<StepsPeriodFragment.StepsData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(BodyEnergyFragment.class);
+
+ private TextView mDateView;
+ private TextView stepsAvg;
+ private TextView stepsTotal;
+ private TextView distanceAvg;
+ private TextView distanceTotal;
+ private BarChart stepsChart;
+
+ protected int CHART_TEXT_COLOR;
+ protected int TEXT_COLOR;
+ protected int STEPS_GOAL;
+
+ protected int BACKGROUND_COLOR;
+ protected int DESCRIPTION_COLOR;
+
+ public static StepsPeriodFragment newInstance ( int totalDays ) {
+ StepsPeriodFragment fragmentFirst = new StepsPeriodFragment();
+ Bundle args = new Bundle();
+ args.putInt("totalDays", totalDays);
+ fragmentFirst.setArguments(args);
+ return fragmentFirst;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ TOTAL_DAYS = getArguments() != null ? getArguments().getInt("totalDays") : 0;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_steps_period, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ mDateView = rootView.findViewById(R.id.steps_date_view);
+ stepsChart = rootView.findViewById(R.id.steps_chart);
+ stepsAvg = rootView.findViewById(R.id.steps_avg);
+ distanceAvg = rootView.findViewById(R.id.distance_avg);
+ stepsTotal = rootView.findViewById(R.id.steps_total);
+ distanceTotal = rootView.findViewById(R.id.distance_total);
+ STEPS_GOAL = GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_STEPS_GOAL, ActivityUser.defaultUserStepsGoal);
+ setupStepsChart();
+ refresh();
+
+ return rootView;
+ }
+
+ protected void setupStepsChart() {
+ stepsChart.getDescription().setEnabled(false);
+ if (TOTAL_DAYS <= 7) {
+ stepsChart.setTouchEnabled(false);
+ stepsChart.setPinchZoom(false);
+ }
+ stepsChart.setDoubleTapToZoomEnabled(false);
+ stepsChart.getLegend().setEnabled(false);
+
+ final XAxis xAxisBottom = stepsChart.getXAxis();
+ xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
+ xAxisBottom.setDrawLabels(true);
+ xAxisBottom.setDrawGridLines(false);
+ xAxisBottom.setEnabled(true);
+ xAxisBottom.setDrawLimitLinesBehindData(true);
+ xAxisBottom.setTextColor(CHART_TEXT_COLOR);
+
+ final YAxis yAxisLeft = stepsChart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setDrawTopYLabelEntry(true);
+ yAxisLeft.setEnabled(true);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+ yAxisLeft.setAxisMinimum(0f);
+ final LimitLine goalLine = new LimitLine(STEPS_GOAL);
+ goalLine.setLineColor(getResources().getColor(R.color.steps_color));
+ goalLine.setLineWidth(1.5f);
+ goalLine.enableDashedLine(15f, 10f, 0f);
+ yAxisLeft.addLimitLine(goalLine);
+
+ final YAxis yAxisRight = stepsChart.getAxisRight();
+ yAxisRight.setEnabled(true);
+ yAxisRight.setDrawLabels(false);
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setDrawAxisLine(true);
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.steps);
+ }
+
+ @Override
+ protected void init() {
+ TEXT_COLOR = GBApplication.getTextColor(requireContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+ BACKGROUND_COLOR = GBApplication.getBackgroundColor(getContext());
+ DESCRIPTION_COLOR = GBApplication.getTextColor(getContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext());
+ }
+
+ @Override
+ protected StepsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ Calendar day = Calendar.getInstance();
+ day.setTime(getEndDate());
+ List<StepsDay> stepsDaysData = getMyStepsDaysData(db, day, device);
+ return new StepsData(stepsDaysData);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(StepsData stepsData) {
+ Date to = new Date((long) getTSEnd() * 1000);
+ Date from = DateUtils.addDays(to,-(TOTAL_DAYS - 1));
+ String toFormattedDate = new SimpleDateFormat("E, MMM dd").format(to);
+ String fromFormattedDate = new SimpleDateFormat("E, MMM dd").format(from);
+ mDateView.setText(fromFormattedDate + " - " + toFormattedDate);
+
+ stepsChart.setData(null);
+
+ List<BarEntry> entries = new ArrayList<>();
+ int counter = 0;
+ for(StepsDay day : stepsData.days) {
+ entries.add(new BarEntry(counter, day.steps));
+ counter++;
+ }
+ BarDataSet set = new BarDataSet(entries, "Steps");
+ set.setDrawValues(true);
+ set.setColors(getResources().getColor(R.color.steps_color));
+ final XAxis x = stepsChart.getXAxis();
+ x.setValueFormatter(getStepsChartDayValueFormatter(stepsData));
+ stepsChart.getAxisLeft().setAxisMaximum(Math.max(set.getYMax(), STEPS_GOAL) + 2000);
+
+ BarData barData = new BarData(set);
+ barData.setValueTextColor(Color.GRAY); //prevent tearing other graph elements with the black text. Another approach would be to hide the values cmpletely with data.setDrawValues(false);
+ barData.setValueTextSize(10f);
+ if (TOTAL_DAYS > 7) {
+ stepsChart.setRenderer(new AngledLabelsChartRenderer(stepsChart, stepsChart.getAnimator(), stepsChart.getViewPortHandler()));
+ }
+ stepsChart.setData(barData);
+ stepsAvg.setText(String.format(String.valueOf(stepsData.stepsDailyAvg)));
+ final WorkoutValueFormatter valueFormatter = new WorkoutValueFormatter();
+ distanceAvg.setText(valueFormatter.formatValue(stepsData.distanceDailyAvg, "km"));
+ stepsTotal.setText(String.format(String.valueOf(stepsData.totalSteps)));
+ distanceTotal.setText(valueFormatter.formatValue(stepsData.totalDistance, "km"));
+ }
+
+ ValueFormatter getStepsChartDayValueFormatter(StepsPeriodFragment.StepsData stepsData) {
+ return new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ StepsPeriodFragment.StepsDay day = stepsData.days.get((int) value);
+ String pattern = TOTAL_DAYS > 7 ? "dd" : "EEE";
+ SimpleDateFormat formatLetterDay = new SimpleDateFormat(pattern, Locale.getDefault());
+ return formatLetterDay.format(new Date(day.day.getTimeInMillis()));
+ }
+ };
+ }
+
+ @Override
+ protected void renderCharts() {
+ stepsChart.invalidate();
+ }
+
+ protected void setupLegend(Chart<?> chart) {}
+
+ protected static class StepsData extends ChartsData {
+ List<StepsDay> days;
+ long stepsDailyAvg = 0;
+ double distanceDailyAvg = 0;
+ long totalSteps = 0;
+ double totalDistance = 0;
+ StepsDay todayStepsDay;
+ protected StepsData(List<StepsDay> days) {
+ this.days = days;
+ int daysCounter = 0;
+ for(StepsDay day : days) {
+ this.totalSteps += day.steps;
+ this.totalDistance += day.distance;
+ if (day.steps > 0) {
+ daysCounter++;
+ }
+ }
+ if (daysCounter > 0) {
+ this.stepsDailyAvg = this.totalSteps / daysCounter;
+ this.distanceDailyAvg = this.totalDistance / daysCounter;
+ }
+ this.todayStepsDay = days.get(days.size() - 1);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StressChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StressChartFragment.java
new file mode 100644
index 0000000..1c095f9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StressChartFragment.java
@@ -0,0 +1,571 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.style.RelativeSizeSpan;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+
+import com.github.mikephil.charting.animation.Easing;
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.charts.PieChart;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.LimitLine;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.data.PieData;
+import com.github.mikephil.charting.data.PieDataSet;
+import com.github.mikephil.charting.data.PieEntry;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class StressChartFragment extends AbstractChartFragment<StressChartFragment.StressChartsData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(ActivitySleepChartFragment.class);
+
+ private LineChart mStressChart;
+ private PieChart mStressLevelsPieChart;
+ private TextView stressChartRelaxedTime;
+ private TextView stressChartMildTime;
+ private TextView stressChartModerateTime;
+ private TextView stressChartHighTime;
+ private TextView stressDate;
+
+ private int BACKGROUND_COLOR;
+ private int DESCRIPTION_COLOR;
+ private int CHART_TEXT_COLOR;
+ private int LEGEND_TEXT_COLOR;
+
+ private String STRESS_AVERAGE_LABEL;
+
+ private final Prefs prefs = GBApplication.getPrefs();
+
+ private final boolean CHARTS_SLEEP_RANGE_24H = prefs.getBoolean("chart_sleep_range_24h", false);
+ private final boolean SHOW_CHARTS_AVERAGE = prefs.getBoolean("charts_show_average", true);
+
+ @Override
+ protected void init() {
+ BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext());
+ LEGEND_TEXT_COLOR = DESCRIPTION_COLOR = GBApplication.getTextColor(requireContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+
+ STRESS_AVERAGE_LABEL = requireContext().getString(R.string.charts_legend_stress_average);
+ }
+
+ @Override
+ protected StressChartsData refreshInBackground(final ChartsHost chartsHost, final DBHandler db, final GBDevice device) {
+ int tsEnd = getTSEnd();
+ Calendar day = Calendar.getInstance();
+ day.setTimeInMillis(tsEnd * 1000L);
+ day.set(Calendar.HOUR_OF_DAY, 0);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ final int tsStart = (int) (day.getTimeInMillis() / 1000);
+ tsEnd = tsStart + 24 * 60 * 60 - 1;
+ final List<? extends StressSample> samples = getSamples(db, device, tsStart, tsEnd);
+
+ LOG.info("Got {} stress samples", samples.size());
+
+ ensureStartAndEndSamples((List<StressSample>) samples, tsStart, tsEnd);
+
+ return new StressChartsDataBuilder(samples, device.getDeviceCoordinator().getStressRanges()).build();
+ }
+
+ protected LineDataSet createDataSet(final StressType stressType, final List<Entry> values) {
+ final LineDataSet lineDataSet = new LineDataSet(values, stressType.getLabel(requireContext()));
+ lineDataSet.setColor(stressType.getColor(requireContext()));
+ lineDataSet.setDrawFilled(true);
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setFillColor(stressType.getColor(requireContext()));
+ lineDataSet.setFillAlpha(255);
+ lineDataSet.setDrawValues(false);
+ lineDataSet.setValueTextColor(CHART_TEXT_COLOR);
+ lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
+ return lineDataSet;
+ }
+
+ @Override
+ protected void updateChartsnUIThread(final StressChartsData stressData) {
+ final PieData pieData = stressData.getPieData();
+
+ Date date = new Date((long) this.getTSEnd() * 1000);
+ String formattedDate = new SimpleDateFormat("E, MMM dd").format(date);
+ stressDate.setText(formattedDate);
+
+ Map<StressType, Integer> stressZoneTimes = stressData.getStressZoneTimes();
+ Integer relaxedTime = stressZoneTimes.get(StressType.RELAXED);
+ if (0 < relaxedTime) {
+ stressChartRelaxedTime.setText(DateTimeUtils.formatDurationHoursMinutes(relaxedTime, TimeUnit.SECONDS));
+ } else {
+ stressChartRelaxedTime.setText(R.string.stats_empty_value);
+ }
+ Integer mildTime = stressZoneTimes.get(StressType.MILD);
+ if (mildTime > 0) {
+ stressChartMildTime.setText(DateTimeUtils.formatDurationHoursMinutes(mildTime, TimeUnit.SECONDS));
+ } else {
+ stressChartMildTime.setText(R.string.stats_empty_value);
+ }
+ Integer moderateTime = stressZoneTimes.get(StressType.MODERATE);
+ if (moderateTime > 0) {
+ stressChartModerateTime.setText(DateTimeUtils.formatDurationHoursMinutes(moderateTime, TimeUnit.SECONDS));
+ } else {
+ stressChartModerateTime.setText(R.string.stats_empty_value);
+ }
+ Integer highTime = stressZoneTimes.get(StressType.HIGH);
+ if (highTime > 0) {
+ stressChartHighTime.setText(DateTimeUtils.formatDurationHoursMinutes(highTime, TimeUnit.SECONDS));
+ } else {
+ stressChartHighTime.setText(R.string.stats_empty_value);
+ }
+
+ if (stressData.getAverage() > 0) {
+ int noc = String.valueOf(stressData.getAverage()).length();
+ SpannableString pieChartCenterText = new SpannableString(stressData.getAverage() + "\n" + requireContext().getString(R.string.stress_average));
+ pieChartCenterText.setSpan(new RelativeSizeSpan(1.75f), 0, noc, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ pieChartCenterText.setSpan(new RelativeSizeSpan(0.72f), noc, pieChartCenterText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ mStressLevelsPieChart.setCenterText(pieChartCenterText);
+ } else {
+ SpannableString pieChartCenterText = new SpannableString("-\n" + requireContext().getString(R.string.stress_average));
+ pieChartCenterText.setSpan(new RelativeSizeSpan(1.25f), 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ pieChartCenterText.setSpan(new RelativeSizeSpan(0.72f), 2, pieChartCenterText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ mStressLevelsPieChart.setCenterText(pieChartCenterText);
+ }
+ mStressLevelsPieChart.setData(pieData);
+
+ final DefaultChartsData<LineData> chartsData = stressData.getChartsData();
+ mStressChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
+ mStressChart.getXAxis().setValueFormatter(chartsData.getXValueFormatter());
+ mStressChart.setData(chartsData.getData());
+ mStressChart.getAxisRight().removeAllLimitLines();
+
+ if (stressData.getAverage() > 0) {
+ final LimitLine averageLine = new LimitLine(stressData.getAverage());
+ averageLine.setLineColor(Color.GRAY);
+ averageLine.setLineWidth(1.5f);
+ averageLine.enableDashedLine(15f, 10f, 0f);
+ mStressChart.getAxisRight().addLimitLine(averageLine);
+ }
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.menuitem_stress);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater,
+ final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View rootView = inflater.inflate(R.layout.fragment_stresschart, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ mStressChart = rootView.findViewById(R.id.stress_line_chart);
+ mStressLevelsPieChart = rootView.findViewById(R.id.stress_pie_chart);
+ stressChartRelaxedTime = rootView.findViewById(R.id.stress_chart_relaxed_time);
+ stressChartMildTime = rootView.findViewById(R.id.stress_chart_mild_time);
+ stressChartModerateTime = rootView.findViewById(R.id.stress_chart_moderate_time);
+ stressChartHighTime = rootView.findViewById(R.id.stress_chart_high_time);
+ stressDate = rootView.findViewById(R.id.stress_date);
+
+ setupLineChart();
+ setupPieChart();
+
+ // refresh immediately instead of use refreshIfVisible(), for perceived performance
+ refresh();
+
+ return rootView;
+ }
+
+ private void setupPieChart() {
+ mStressLevelsPieChart.setBackgroundColor(BACKGROUND_COLOR);
+ mStressLevelsPieChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ mStressLevelsPieChart.setEntryLabelColor(DESCRIPTION_COLOR);
+ mStressLevelsPieChart.getDescription().setText("");
+ mStressLevelsPieChart.setNoDataText("");
+ mStressLevelsPieChart.setTouchEnabled(false);
+ mStressLevelsPieChart.setCenterTextColor(GBApplication.getTextColor(getContext()));
+ mStressLevelsPieChart.setCenterTextSize(18f);
+ mStressLevelsPieChart.setHoleColor(getContext().getResources().getColor(R.color.transparent));
+ mStressLevelsPieChart.setHoleRadius(85);
+ mStressLevelsPieChart.setDrawEntryLabels(false);
+ mStressLevelsPieChart.getLegend().setEnabled(false);
+ }
+
+ private void setupLineChart() {
+ mStressChart.setBackgroundColor(BACKGROUND_COLOR);
+ mStressChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ configureBarLineChartDefaults(mStressChart);
+
+ final XAxis x = mStressChart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setDrawLimitLinesBehindData(true);
+ x.setAxisMinimum(0f);
+ x.setAxisMaximum(86400f);
+
+ final YAxis yAxisLeft = mStressChart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setAxisMaximum(100f);
+ yAxisLeft.setAxisMinimum(0);
+ yAxisLeft.setDrawTopYLabelEntry(false);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+ yAxisLeft.setEnabled(true);
+
+ final YAxis yAxisRight = mStressChart.getAxisRight();
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setEnabled(true);
+ yAxisRight.setDrawLabels(false);
+ yAxisRight.setDrawTopYLabelEntry(true);
+ yAxisRight.setTextColor(CHART_TEXT_COLOR);
+ yAxisRight.setAxisMaximum(100f);
+ yAxisRight.setAxisMinimum(0);
+ }
+
+ @Override
+ protected void setupLegend(final Chart<?> chart) {
+ final List<LegendEntry> legendEntries = new ArrayList<>(StressType.values().length + 1);
+
+ for (final StressType stressType : StressType.values()) {
+ final LegendEntry entry = new LegendEntry();
+ entry.label = stressType.getLabel(requireContext());
+ entry.formColor = stressType.getColor(requireContext());
+ legendEntries.add(entry);
+ }
+
+ if (!CHARTS_SLEEP_RANGE_24H && SHOW_CHARTS_AVERAGE) {
+ final LegendEntry averageEntry = new LegendEntry();
+ averageEntry.label = STRESS_AVERAGE_LABEL;
+ averageEntry.formColor = Color.GRAY;
+ legendEntries.add(averageEntry);
+ }
+
+ chart.getLegend().setCustom(legendEntries);
+ chart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ }
+
+ @Override
+ protected void renderCharts() {
+ mStressChart.animateX(ANIM_TIME, Easing.EaseInOutQuart);
+ mStressLevelsPieChart.invalidate();
+ }
+
+ private List<? extends StressSample> getSamples(final DBHandler db, final GBDevice device, int tsStart, int tsEnd) {
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final TimeSampleProvider<? extends StressSample> sampleProvider = coordinator.getStressSampleProvider(device, db.getDaoSession());
+ return sampleProvider.getAllSamples(tsStart * 1000L, tsEnd * 1000L);
+ }
+
+ protected void ensureStartAndEndSamples(final List<StressSample> samples, int tsStart, int tsEnd) {
+ if (samples == null || samples.isEmpty()) {
+ return;
+ }
+
+ final long tsEndMillis = tsEnd * 1000L;
+ final long tsStartMillis = tsStart * 1000L;
+
+ final StressSample lastSample = samples.get(samples.size() - 1);
+ if (lastSample.getTimestamp() < tsEndMillis) {
+ samples.add(new EmptyStressSample(tsEndMillis));
+ }
+
+ final StressSample firstSample = samples.get(0);
+ if (firstSample.getTimestamp() > tsStartMillis) {
+ samples.add(0, new EmptyStressSample(tsStartMillis));
+ }
+ }
+
+ protected static final class EmptyStressSample implements StressSample {
+ private final long ts;
+
+ public EmptyStressSample(final long ts) {
+ this.ts = ts;
+ }
+
+ @Override
+ public Type getType() {
+ return Type.AUTOMATIC;
+ }
+
+ @Override
+ public int getStress() {
+ return -1;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return ts;
+ }
+ }
+
+ protected class StressChartsDataBuilder {
+ private static final int UNKNOWN_VAL = 2;
+
+ private final List<? extends StressSample> samples;
+ private final int[] stressRanges;
+
+ private final TimestampTranslation tsTranslation = new TimestampTranslation();
+
+ private final Map<StressType, List<Entry>> lineEntriesPerLevel = new HashMap<>();
+ private final Map<StressType, Integer> accumulator = new HashMap<>();
+
+ int previousTs;
+ int currentTypeStartTs;
+ StressType previousStressType;
+ long averageSum;
+ long averageNumSamples;
+
+ public StressChartsDataBuilder(final List<? extends StressSample> samples, final int[] stressRanges) {
+ this.samples = samples;
+ this.stressRanges = stressRanges;
+ }
+
+ private void reset() {
+ tsTranslation.reset();
+ lineEntriesPerLevel.clear();
+ accumulator.clear();
+ for (final StressType stressType : StressType.values()) {
+ lineEntriesPerLevel.put(stressType, new ArrayList<>());
+ accumulator.put(stressType, 0);
+ }
+ previousTs = 0;
+ currentTypeStartTs = 0;
+ previousStressType = StressType.UNKNOWN;
+ }
+
+ private void processSamples() {
+ reset();
+
+ for (final StressSample sample : samples) {
+ processSample(sample);
+ }
+
+ // Add the last block, if any
+ if (currentTypeStartTs != previousTs) {
+ set(previousTs, previousStressType, samples.get(samples.size() - 1).getStress());
+ }
+ }
+
+ private void processSample(final StressSample sample) {
+ //LOG.debug("Processing sample {} {}", sdf.format(new Date(sample.getTimestamp())), sample.getStress());
+
+ final StressType stressType = StressType.fromStress(sample.getStress(), stressRanges);
+ final int ts = tsTranslation.shorten((int) (sample.getTimestamp() / 1000L));
+
+ if (ts == 0) {
+ // First sample
+ previousTs = ts;
+ currentTypeStartTs = ts;
+ previousStressType = stressType;
+ set(ts, stressType, sample.getStress());
+ return;
+ }
+
+ if (ts - previousTs > 60 * 10) {
+ // More than 15 minutes since last sample
+ // Set to unknown right after the last sample we got until the current time
+ int lastEndTs = Math.min(previousTs + 60 * 5, ts - 1);
+ set(lastEndTs, StressType.UNKNOWN, UNKNOWN_VAL);
+ set(ts - 1, StressType.UNKNOWN, UNKNOWN_VAL);
+ }
+
+ if (!stressType.equals(previousStressType)) {
+ currentTypeStartTs = ts;
+ }
+
+ set(ts, stressType, sample.getStress());
+
+ accumulator.put(stressType, accumulator.get(stressType) + 60);
+
+ if (stressType != StressType.UNKNOWN) {
+ averageSum += sample.getStress();
+ averageNumSamples++;
+ }
+
+ previousStressType = stressType;
+ previousTs = ts;
+ }
+
+ private void set(final int ts, final StressType stressType, final int stress) {
+ for (final Map.Entry<StressType, List<Entry>> stressTypeListEntry : lineEntriesPerLevel.entrySet()) {
+ if (stressTypeListEntry.getKey() == stressType) {
+ stressTypeListEntry.getValue().add(new Entry(ts, stress));
+ } else {
+ stressTypeListEntry.getValue().add(new Entry(ts, 0));
+ }
+ }
+ }
+
+ public StressChartsData build() {
+ processSamples();
+
+ final List<ILineDataSet> lineDataSets = new ArrayList<>();
+ final List<PieEntry> pieEntries = new ArrayList<>();
+ final List<Integer> pieColors = new ArrayList<>();
+ final Map<StressType, Integer> stressZoneTimes = new HashMap<>();
+
+ for (final StressType stressType : StressType.values()) {
+ final List<Entry> stressEntries = lineEntriesPerLevel.get(stressType);
+ lineDataSets.add(createDataSet(stressType, stressEntries));
+
+ final Integer stressTime = accumulator.get(stressType);
+ stressZoneTimes.put(stressType, stressTime);
+ if (stressType != StressType.UNKNOWN && stressTime != null && stressTime != 0) {
+ pieEntries.add(new PieEntry(stressTime, stressType.getLabel(requireContext())));
+ pieColors.add(stressType.getColor(requireContext()));
+ }
+ }
+
+ if (pieEntries.isEmpty()) {
+ pieEntries.add(new PieEntry(1));
+ pieColors.add(getResources().getColor(R.color.gauge_line_color));
+ }
+
+ final PieDataSet pieDataSet = new PieDataSet(pieEntries, "");
+ pieDataSet.setValueFormatter(new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ return DateTimeUtils.formatDurationHoursMinutes((long) value, TimeUnit.SECONDS);
+ }
+ });
+ pieDataSet.setColors(pieColors);
+ pieDataSet.setValueTextColor(DESCRIPTION_COLOR);
+ pieDataSet.setValueTextSize(13f);
+ pieDataSet.setXValuePosition(PieDataSet.ValuePosition.OUTSIDE_SLICE);
+ pieDataSet.setYValuePosition(PieDataSet.ValuePosition.OUTSIDE_SLICE);
+ pieDataSet.setDrawValues(false);
+ pieDataSet.setSliceSpace(2f);
+ final PieData pieData = new PieData(pieDataSet);
+
+ final LineData lineData = new LineData(lineDataSets);
+ final ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm");
+ final DefaultChartsData<LineData> chartsData = new DefaultChartsData<>(lineData, xValueFormatter);
+ return new StressChartsData(pieData, chartsData, Math.round((float) averageSum / averageNumSamples), stressZoneTimes);
+ }
+ }
+
+ protected static class StressChartsData extends ChartsData {
+ private final PieData pieData;
+ private final DefaultChartsData<LineData> chartsData;
+ private final int average;
+ private Map<StressType, Integer> stressZoneTimes;
+
+ public StressChartsData(final PieData pieData, final DefaultChartsData<LineData> chartsData, final int average, Map<StressType, Integer> stressZoneTimes) {
+ this.pieData = pieData;
+ this.chartsData = chartsData;
+ this.average = average;
+ this.stressZoneTimes = stressZoneTimes;
+ }
+
+ public Map<StressType, Integer> getStressZoneTimes() {
+ return stressZoneTimes;
+ }
+
+ public PieData getPieData() {
+ return pieData;
+ }
+
+ public DefaultChartsData<LineData> getChartsData() {
+ return chartsData;
+ }
+
+ public int getAverage() {
+ return average;
+ }
+ }
+
+ public enum StressType {
+ UNKNOWN(R.string.unknown, R.color.chart_stress_unknown),
+ RELAXED(R.string.stress_relaxed, R.color.chart_stress_relaxed),
+ MILD(R.string.stress_mild, R.color.chart_stress_mild),
+ MODERATE(R.string.stress_moderate, R.color.chart_stress_moderate),
+ HIGH(R.string.stress_high, R.color.chart_stress_high),
+ ;
+
+ private final int labelId;
+ private final int colorId;
+
+ StressType(final int labelId, final int colorId) {
+ this.labelId = labelId;
+ this.colorId = colorId;
+ }
+
+ public String getLabel(final Context context) {
+ return context.getString(labelId);
+ }
+
+ public int getColor(final Context context) {
+ return ContextCompat.getColor(context, colorId);
+ }
+
+ public static StressType fromStress(final int stress, final int[] stressRanges) {
+ if (stress < stressRanges[0]) {
+ return StressType.UNKNOWN;
+ } else if (stress < stressRanges[1]) {
+ return StressType.RELAXED;
+ } else if (stress < stressRanges[2]) {
+ return StressType.MILD;
+ } else if (stress < stressRanges[3]) {
+ return StressType.MODERATE;
+ } else {
+ return StressType.HIGH;
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TemperatureChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TemperatureChartFragment.java
new file mode 100644
index 0000000..7cea483
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TemperatureChartFragment.java
@@ -0,0 +1,245 @@
+/* Copyright (C) 2023 Alicia Hormann
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.github.mikephil.charting.animation.Easing;
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.DecimalFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+
+public class TemperatureChartFragment extends AbstractChartFragment<TemperatureChartFragment.TemperatureChartsData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(TemperatureChartFragment.class);
+
+ private LineChart mTemperatureChart;
+ private int BACKGROUND_COLOR;
+ private int DESCRIPTION_COLOR;
+ private int CHART_TEXT_COLOR;
+
+ protected final int TOTAL_DAYS = getRangeDays();
+
+
+ @Override
+ protected void init() {
+ BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext());
+ DESCRIPTION_COLOR = GBApplication.getTextColor(requireContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+
+ }
+ private int getRangeDays() {
+ if (GBApplication.getPrefs().getBoolean("charts_range", true)) {
+ return 30;
+ } else {
+ return 7;
+ }
+ }
+
+ @Override
+ protected TemperatureChartsData refreshInBackground(final ChartsHost chartsHost, final DBHandler db, final GBDevice device) {
+ final List<? extends TemperatureSample> samples = getSamples(db, device);
+
+ LOG.info("Got {} temperature samples", samples.size());
+ return new TemperatureChartsDataBuilder(samples).build();
+ }
+
+
+ @Override
+ protected void updateChartsnUIThread(final TemperatureChartsData temperatureData) {
+ mTemperatureChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
+ mTemperatureChart.getXAxis().setValueFormatter(temperatureData.getXValueFormatter());
+ mTemperatureChart.getXAxis().setAvoidFirstLastClipping(true);
+
+ // Using approximately the range of survivable body-temperatures (in celsius), rounded to multiples of 5
+ mTemperatureChart.getAxisLeft().setAxisMinimum(30f);
+ mTemperatureChart.getAxisLeft().setAxisMaximum(45f);
+
+ mTemperatureChart.setData(temperatureData.getData());
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.menuitem_temperature);
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater,
+ final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View rootView = inflater.inflate(R.layout.fragment_temperaturechart, container, false);
+
+ mTemperatureChart = rootView.findViewById(R.id.temperature_line_chart);
+
+ setupLineChart();
+
+ // refresh immediately instead of use refreshIfVisible(), for perceived performance
+ refresh();
+
+ return rootView;
+ }
+
+ private void setupLineChart() {
+ mTemperatureChart.setBackgroundColor(BACKGROUND_COLOR);
+ mTemperatureChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ configureBarLineChartDefaults(mTemperatureChart);
+
+ final XAxis x = mTemperatureChart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setDrawLimitLinesBehindData(true);
+
+ final YAxis yAxisLeft = mTemperatureChart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setDrawTopYLabelEntry(false);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+ yAxisLeft.setEnabled(true);
+
+ final YAxis yAxisRight = mTemperatureChart.getAxisRight();
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setDrawLabels(false);
+ }
+
+ @Override
+ protected void setupLegend(final Chart<?> chart) {
+ }
+
+ @Override
+ protected void renderCharts() {
+ mTemperatureChart.animateX(ANIM_TIME, Easing.EaseInOutQuart);
+ }
+
+ @Override
+ protected boolean isSingleDay() {
+ return false;
+ }
+
+ @Override
+ protected int getTSStart() {
+ return getTSEnd() - TOTAL_DAYS*24*60*60;
+ }
+
+ private List<? extends TemperatureSample> getSamples(final DBHandler db, final GBDevice device) {
+ final int tsStart = getTSStart();
+ final int tsEnd = getTSEnd();
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final TimeSampleProvider<? extends TemperatureSample> sampleProvider = coordinator.getTemperatureSampleProvider(device, db.getDaoSession());
+ return sampleProvider.getAllSamples(tsStart * 1000L, tsEnd * 1000L);
+ }
+
+ protected class TemperatureChartsDataBuilder {
+ private final List<? extends TemperatureSample> samples;
+
+ public TemperatureChartsDataBuilder(final List<? extends TemperatureSample> samples) {
+ this.samples = samples;
+ }
+
+ public TemperatureChartsData build() {
+ TimestampTranslation tsTranslation = new TimestampTranslation();
+ List<Entry> entries = new ArrayList<Entry>();
+ long firstTs = 0;
+
+ for (TemperatureSample sample : samples) {
+ int timestamp_in_seconds = (int) (sample.getTimestamp() / 1000L);
+ entries.add(new Entry(tsTranslation.shorten(timestamp_in_seconds), sample.getTemperature()));
+ if (firstTs == 0) {
+ firstTs = sample.getTimestamp();
+ }
+ }
+
+ LineDataSet dataSet = new LineDataSet(entries, getString(R.string.menuitem_temperature));
+ dataSet.setLineWidth(2.2f);
+ dataSet.setMode(LineDataSet.Mode.HORIZONTAL_BEZIER);
+ dataSet.setCubicIntensity(0.1f);
+ dataSet.setDrawCircles(true);
+ dataSet.setCircleRadius(5f);
+ dataSet.setDrawCircleHole(false);
+ dataSet.setDrawValues(true);
+ dataSet.setValueTextSize(10f);
+ dataSet.setValueTextColor(CHART_TEXT_COLOR);
+ dataSet.setHighlightEnabled(true);
+ dataSet.setValueFormatter(new MyValueFormatter());
+ LineData lineData = new LineData(dataSet);
+
+ return new TemperatureChartsData(lineData, tsTranslation);
+ }
+ }
+
+ protected static class TemperatureChartsData extends DefaultChartsData<LineData> {
+ public TemperatureChartsData(LineData lineData, TimestampTranslation tsTranslation) {
+ super(lineData, new dateFormatter(tsTranslation));
+ }
+ }
+
+
+ protected static class dateFormatter extends ValueFormatter {
+ private final TimestampTranslation tsTranslation;
+ SimpleDateFormat annotationDateFormat = new SimpleDateFormat("dd.MM.");
+ Calendar cal = GregorianCalendar.getInstance();
+
+ public dateFormatter(TimestampTranslation tsTranslation) {
+ this.tsTranslation = tsTranslation;
+ }
+
+ @Override
+ public String getFormattedValue(float value) {
+ cal.clear();
+ int ts = (int) value;
+ cal.setTimeInMillis(tsTranslation.toOriginalValue(ts) * 1000L);
+ Date date = cal.getTime();
+ return annotationDateFormat.format(date);
+ }
+ }
+
+ protected static class MyValueFormatter extends ValueFormatter {
+ private final DecimalFormat formatter = new DecimalFormat("0.00");
+
+ @Override
+ public String getPointLabel(Entry entry) {
+ return formatter.format(entry.getY());
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TemperatureDailyFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TemperatureDailyFragment.java
new file mode 100644
index 0000000..3cded0d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TemperatureDailyFragment.java
@@ -0,0 +1,234 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.graphics.Color;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.LimitLine;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+import nodomain.freeyourgadget.gadgetbridge.util.Accumulator;
+
+public class TemperatureDailyFragment extends AbstractChartFragment<TemperatureDailyFragment.TemperatureChartData> {
+
+ protected static final Logger LOG = LoggerFactory.getLogger(TemperatureDailyFragment.class);
+
+ protected int TEMPERATURE_COLOR;
+ protected int CHART_TEXT_COLOR;
+ protected int BACKGROUND_COLOR;
+ protected int DESCRIPTION_COLOR;
+ protected int LEGEND_TEXT_COLOR;
+
+ private TextView dateView;
+ private TextView tempAverage;
+ private TextView tempMinimum;
+ private TextView tempMaximum;
+ private LineChart tempLineChart;
+
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_temperature, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ dateView = rootView.findViewById(R.id.temp_date_view);
+ tempLineChart = rootView.findViewById(R.id.temp_line_chart);
+ tempAverage = rootView.findViewById(R.id.temp_average);
+ tempMinimum = rootView.findViewById(R.id.temp_minimum);
+ tempMaximum = rootView.findViewById(R.id.temp_maximum);
+
+ setupChart();
+ refresh();
+ setupLegend(tempLineChart);
+
+ return rootView;
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.menuitem_temperature);
+ }
+
+ @Override
+ protected void init() {
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+ DESCRIPTION_COLOR = LEGEND_TEXT_COLOR = GBApplication.getTextColor(requireContext());
+ TEMPERATURE_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+ }
+
+ @Override
+ protected TemperatureChartData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ int startTs = getTSStart();
+ int endTs = getTSEnd();
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final TimeSampleProvider<? extends TemperatureSample> sampleProvider = coordinator.getTemperatureSampleProvider(device, db.getDaoSession());
+
+ final List<? extends TemperatureSample> samples = sampleProvider.getAllSamples(startTs * 1000L, endTs * 1000L);
+ LOG.info("Got {} temperature samples", samples.size());
+
+ return new TemperatureChartData(samples);
+ }
+
+ @Override
+ protected void renderCharts() {
+ tempLineChart.invalidate();
+ }
+
+ private void setupChart() {
+ tempLineChart.setBackgroundColor(BACKGROUND_COLOR);
+ tempLineChart.getDescription().setTextColor(DESCRIPTION_COLOR);
+ tempLineChart.getDescription().setEnabled(false);
+
+
+ XAxis x = tempLineChart.getXAxis();
+ x.setDrawLabels(true);
+ x.setDrawGridLines(false);
+ x.setEnabled(true);
+ x.setTextColor(CHART_TEXT_COLOR);
+ x.setDrawLimitLinesBehindData(true);
+ x.setPosition(XAxis.XAxisPosition.BOTTOM);
+ x.setAxisMinimum(0f);
+ x.setAxisMaximum(86400f);
+
+ YAxis y = tempLineChart.getAxisLeft();
+ y.setDrawGridLines(false);
+ y.setDrawTopYLabelEntry(true);
+ y.setTextColor(CHART_TEXT_COLOR);
+ y.setEnabled(true);
+ y.setAxisMaximum(HeartRateUtils.getInstance().getMaxHeartRate());
+ y.setAxisMinimum(HeartRateUtils.getInstance().getMinHeartRate());
+
+ YAxis yAxisRight = tempLineChart.getAxisRight();
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setDrawLabels(true);
+ yAxisRight.setDrawTopYLabelEntry(true);
+ yAxisRight.setTextColor(CHART_TEXT_COLOR);
+ yAxisRight.setAxisMaximum(HeartRateUtils.getInstance().getMaxHeartRate());
+ yAxisRight.setAxisMinimum(HeartRateUtils.getInstance().getMinHeartRate());
+
+ refresh();
+ }
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {
+ List<LegendEntry> legendEntries = new ArrayList<>(1);
+ LegendEntry dataEntry = new LegendEntry();
+ dataEntry.label = getTitle();
+ dataEntry.formColor = TEMPERATURE_COLOR;
+ legendEntries.add(dataEntry);
+
+ if (GBApplication.getPrefs().getBoolean("charts_show_average", true)) {
+ LegendEntry dataAverageEntry = new LegendEntry();
+ dataAverageEntry.label = getString(R.string.hr_average);
+ dataAverageEntry.formColor = Color.CYAN;
+ legendEntries.add(dataAverageEntry);
+ }
+
+ chart.getLegend().setCustom(legendEntries);
+ chart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ chart.getLegend().setWordWrapEnabled(true);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(TemperatureDailyFragment.TemperatureChartData data) {
+ Date date = new Date(getTSEnd() * 1000L);
+ String formattedDate = new SimpleDateFormat("E, MMM dd").format(date);
+ dateView.setText(formattedDate);
+
+ final TimestampTranslation tsTranslation = new TimestampTranslation();
+ final List<Entry> lineEntries = new ArrayList<>();
+ List<? extends TemperatureSample> samples = data.samples;
+ final Accumulator accumulator = new Accumulator();
+
+ for (int i =0; i < samples.size(); i++) {
+ TemperatureSample sample = samples.get(i);
+ int timestamp_in_seconds = (int) (sample.getTimestamp() / 1000L);
+ lineEntries.add(new Entry(tsTranslation.shorten(timestamp_in_seconds), sample.getTemperature()));
+ accumulator.add(sample.getTemperature());
+ }
+
+ LineDataSet dataSet = new LineDataSet(lineEntries, "Heart Rate");
+ dataSet.setLineWidth(1.5f);
+ dataSet.setMode(LineDataSet.Mode.HORIZONTAL_BEZIER);
+ dataSet.setCubicIntensity(0.1f);
+ dataSet.setDrawCircles(false);
+ dataSet.setDrawValues(true);
+ dataSet.setAxisDependency(YAxis.AxisDependency.RIGHT);
+ dataSet.setColor(TEMPERATURE_COLOR);
+ dataSet.setValueTextColor(CHART_TEXT_COLOR);
+
+ final double average = accumulator.getCount() > 0 ? accumulator.getAverage() : -1;
+ final double minimum = accumulator.getCount() > 0 ? accumulator.getMin() : -1;
+ final double maximum = accumulator.getCount() > 0 ? accumulator.getMax() : -1;
+
+ tempAverage.setText(average > 0 ? String.format(Locale.ROOT, "%.1f", average) : "-");
+ tempMinimum.setText(minimum > 0 ? String.format(Locale.ROOT, "%.1f", minimum) : "-");
+ tempMaximum.setText(maximum > 0 ? String.format(Locale.ROOT, "%.1f", maximum) : "-");
+
+ if (minimum > 0) {
+ long axisMin = Math.max(Math.round(minimum) - 3, 0);
+ tempLineChart.getAxisLeft().setAxisMinimum(axisMin);
+ tempLineChart.getAxisRight().setAxisMinimum(axisMin);
+ }
+ if (maximum > 0) {
+ tempLineChart.getAxisLeft().setAxisMaximum(Math.round(maximum) + 3);
+ tempLineChart.getAxisRight().setAxisMaximum(Math.round(maximum) + 3);
+ }
+
+ tempLineChart.getXAxis().setValueFormatter(new SampleXLabelFormatter(tsTranslation, "HH:mm"));
+ tempLineChart.setData(new LineData(dataSet));
+
+ tempLineChart.getAxisLeft().removeAllLimitLines();
+
+ if (average > 0 && GBApplication.getPrefs().getBoolean("charts_show_average", true)) {
+ final LimitLine averageLine = new LimitLine((float) average);
+ averageLine.setLineWidth(1.5f);
+ averageLine.enableDashedLine(15f, 10f, 0f);
+ averageLine.setLineColor(Color.CYAN);
+ tempLineChart.getAxisLeft().addLimitLine(averageLine);
+ }
+
+ }
+
+ protected static class TemperatureChartData extends ChartsData {
+ public List<? extends TemperatureSample> samples;
+
+ protected TemperatureChartData(List<? extends TemperatureSample> samples) {
+ this.samples = samples;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TimestampTranslation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TimestampTranslation.java
new file mode 100644
index 0000000..8cc36aa
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TimestampTranslation.java
@@ -0,0 +1,48 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+/**
+ * Awkward class that helps in translating long timestamp
+ * values to float (sic!) values. It basically rebases all
+ * timestamps to a base (the very first) timestamp value.
+ * <p>
+ * It does this so that the large timestamp values can be used
+ * floating point values, where the mantissa is just 24 bits.
+ */
+public class TimestampTranslation {
+ private int tsOffset = -1;
+
+ public int shorten(int timestamp) {
+ if (tsOffset == -1) {
+ tsOffset = timestamp;
+ return 0;
+ }
+ return timestamp - tsOffset;
+ }
+
+ public int toOriginalValue(int timestamp) {
+ if (tsOffset == -1) {
+ return timestamp;
+ }
+ return timestamp + tsOffset;
+ }
+
+ public void reset() {
+ tsOffset = -1;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TimestampValueFormatter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TimestampValueFormatter.java
new file mode 100644
index 0000000..6d82079
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TimestampValueFormatter.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.activities.charts;
+
+import com.github.mikephil.charting.formatter.ValueFormatter;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+
+public class TimestampValueFormatter extends ValueFormatter {
+ private final Calendar cal;
+ // private DateFormat dateFormat = new SimpleDateFormat("dd.MM.yyyy HH:mm");
+ private DateFormat dateFormat;
+
+ public TimestampValueFormatter() {
+ this(new SimpleDateFormat("HH:mm"));
+
+ }
+
+ public TimestampValueFormatter(DateFormat dateFormat) {
+ this.dateFormat = dateFormat;
+ cal = GregorianCalendar.getInstance();
+ cal.clear();
+ }
+
+ @Override
+ public String getFormattedValue(float value) {
+ cal.setTimeInMillis((int) value * 1000L);
+ Date date = cal.getTime();
+ String dateString = dateFormat.format(date);
+ return dateString;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TrailingActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TrailingActivitySample.java
new file mode 100644
index 0000000..8bec737
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/TrailingActivitySample.java
@@ -0,0 +1,55 @@
+/* Copyright (C) 2016-2024 Carsten Pfeiffer
+
+ 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.activities.charts;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+
+public class TrailingActivitySample extends AbstractActivitySample {
+ private int timestamp;
+ private long userId;
+ private long deviceId;
+
+ @Override
+ public void setTimestamp(int timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public void setUserId(long userId) {
+ this.userId = userId;
+ }
+
+ @Override
+ public void setDeviceId(long deviceId) {
+ this.deviceId = deviceId;
+ }
+
+ @Override
+ public long getDeviceId() {
+ return deviceId;
+ }
+
+ @Override
+ public long getUserId() {
+ return userId;
+ }
+
+ @Override
+ public int getTimestamp() {
+ return timestamp;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java
new file mode 100644
index 0000000..c1d0a84
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java
@@ -0,0 +1,312 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.charts;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.GridLayout;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.AbstractDashboardVO2MaxWidget;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.GaugeDrawer;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.Vo2MaxSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+
+
+public class VO2MaxFragment extends AbstractChartFragment<VO2MaxFragment.VO2MaxData> {
+ protected static final Logger LOG = LoggerFactory.getLogger(VO2MaxFragment.class);
+
+ private TextView mDateView;
+ private TextView vo2MaxRunningValue;
+ private TextView vo2MaxCyclingValue;
+ private ImageView vo2MaxRunningGauge;
+ private ImageView vo2MaxCyclingGauge;
+ protected GaugeDrawer gaugeDrawer = new GaugeDrawer();
+ private LineChart vo2MaxChart;
+ private RelativeLayout vo2maxCyclingWrapper;
+ private RelativeLayout vo2maxRunningWrapper;
+ private GridLayout tilesGridWrapper;
+ private int tsFrom;
+ GBDevice device;
+
+ protected int CHART_TEXT_COLOR;
+ protected int LEGEND_TEXT_COLOR;
+ protected int TEXT_COLOR;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_vo2max, container, false);
+
+ mDateView = rootView.findViewById(R.id.vo2max_date_view);
+ vo2MaxRunningValue = rootView.findViewById(R.id.vo2max_running_gauge_value);
+ vo2MaxCyclingValue = rootView.findViewById(R.id.vo2max_cycling_gauge_value);
+ vo2MaxRunningGauge = rootView.findViewById(R.id.vo2max_running_gauge);
+ vo2MaxCyclingGauge = rootView.findViewById(R.id.vo2max_cycling_gauge);
+ vo2MaxChart = rootView.findViewById(R.id.vo2max_chart);
+ vo2maxCyclingWrapper = rootView.findViewById(R.id.vo2max_cycling_card_layout);
+ vo2maxRunningWrapper = rootView.findViewById(R.id.vo2max_running_card_layout);
+ tilesGridWrapper = rootView.findViewById(R.id.tiles_grid_wrapper);
+ device = getChartsHost().getDevice();
+ if (!supportsVO2MaxCycling(device)) {
+ tilesGridWrapper.removeView(vo2maxCyclingWrapper);
+ }
+ if (!supportsVO2MaxRunning(device)) {
+ tilesGridWrapper.removeView(vo2maxRunningWrapper);
+ }
+ setupVO2MaxChart();
+ refresh();
+ setupLegend(vo2MaxChart);
+
+
+ return rootView;
+ }
+
+ public boolean supportsVO2MaxCycling(GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator != null && coordinator.supportsVO2MaxCycling();
+ }
+
+ public boolean supportsVO2MaxRunning(GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator != null && coordinator.supportsVO2MaxRunning();
+ }
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.menuitem_vo2_max);
+ }
+
+ @Override
+ protected void init() {
+ TEXT_COLOR = GBApplication.getTextColor(requireContext());
+ LEGEND_TEXT_COLOR = GBApplication.getTextColor(requireContext());
+ CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
+ }
+
+ @Override
+ protected VO2MaxData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ List<VO2MaxRecord> records = new ArrayList<>();
+ int tsEnd = getTSEnd();
+ Calendar day = Calendar.getInstance();
+ day.setTimeInMillis(tsEnd * 1000L); //we need today initially, which is the end of the time range
+ day.set(Calendar.HOUR_OF_DAY, 0); //and we set time for the start and end of the same day
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ day.add(Calendar.DAY_OF_YEAR, -30);
+ tsFrom = (int) (day.getTimeInMillis() / 1000);
+ List<? extends Vo2MaxSample> samples = getAllSamples(db, device, tsFrom, tsEnd);
+ for (Vo2MaxSample sample : samples) {
+ records.add(new VO2MaxRecord(sample.getTimestamp() / 1000, sample.getValue(), sample.getType()));
+ }
+ Map<Vo2MaxSample.Type, VO2MaxRecord> latestValues = new HashMap<>();
+ for (Vo2MaxSample.Type type : Vo2MaxSample.Type.values()) {
+ Vo2MaxSample sample = getLatestVo2MaxSample(db, device, type);
+ if (sample != null) {
+ latestValues.put(type, new VO2MaxRecord(sample.getTimestamp() / 1000, sample.getValue(), type));
+ }
+ }
+ return new VO2MaxData(records, latestValues);
+ }
+
+ @Override
+ protected void updateChartsnUIThread(VO2MaxData vo2MaxData) {
+ String formattedDate = new SimpleDateFormat("E, MMM dd").format(getEndDate());
+ mDateView.setText(formattedDate);
+
+ List<Entry> runningEntries = new ArrayList<>();
+ List<Entry> cyclingEntries = new ArrayList<>();
+ vo2MaxData.records.forEach((record) -> {
+ float nd = (float) (record.timestamp - this.tsFrom) / (60 * 60 * 24);
+ switch (record.type) {
+ case RUNNING:
+ runningEntries.add(new Entry(nd, record.value));
+ break;
+ case CYCLING:
+ cyclingEntries.add(new Entry(nd, record.value));
+ break;
+ }
+ });
+ final int[] colors = AbstractDashboardVO2MaxWidget.getColors();
+ final float[] segments = AbstractDashboardVO2MaxWidget.getSegments();
+ float[] vo2MaxRanges = AbstractDashboardVO2MaxWidget.getVO2MaxRanges();
+ final List<ILineDataSet> lineDataSets = new ArrayList<>();
+ if (supportsVO2MaxRunning(device)) {
+ VO2MaxRecord latestRunningRecord = vo2MaxData.getLatestValue(Vo2MaxSample.Type.RUNNING);
+ float runningVO2MaxValue = calculateVO2maxGaugeValue(vo2MaxRanges, latestRunningRecord != null ? latestRunningRecord.value : 0);
+ vo2MaxRunningValue.setText(String.valueOf(latestRunningRecord != null ? Math.round(latestRunningRecord.value) : "-"));
+ gaugeDrawer.drawSegmentedGauge(vo2MaxRunningGauge, colors, segments, runningVO2MaxValue, false, true);
+ lineDataSets.add(createDataSet(runningEntries, getResources().getColor(R.color.vo2max_running_char_line_color), getString(R.string.vo2max_running)));
+ }
+ if (supportsVO2MaxCycling(device)) {
+ VO2MaxRecord latestCyclingRecord = vo2MaxData.getLatestValue(Vo2MaxSample.Type.CYCLING);
+ float cyclingVO2MaxValue = calculateVO2maxGaugeValue(vo2MaxRanges, latestCyclingRecord != null ? latestCyclingRecord.value : 0);
+ gaugeDrawer.drawSegmentedGauge(vo2MaxCyclingGauge, colors, segments, cyclingVO2MaxValue, false, true);
+ vo2MaxCyclingValue.setText(String.valueOf(latestCyclingRecord != null ? Math.round(latestCyclingRecord.value) : "-"));
+ lineDataSets.add(createDataSet(cyclingEntries, getResources().getColor(R.color.vo2max_cycling_char_line_color), getString(R.string.vo2max_cycling)));
+ }
+ final LineData lineData = new LineData(lineDataSets);
+ vo2MaxChart.getXAxis().setValueFormatter(getVO2MaxLineChartValueFormatter());
+ vo2MaxChart.setData(lineData);
+ }
+
+ ValueFormatter getVO2MaxLineChartValueFormatter() {
+ return new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ Calendar day = Calendar.getInstance();
+ day.setTimeInMillis(tsFrom * 1000L);
+ day.add(Calendar.DAY_OF_YEAR, (int) value);
+ return new SimpleDateFormat("dd/MM").format(day.getTime());
+ }
+ };
+ }
+
+ private float calculateVO2maxGaugeValue(float[] vo2MaxRanges, float vo2MaxValue) {
+ float value = -1;
+ for (int i = 0; i < vo2MaxRanges.length; i++) {
+ if (vo2MaxValue - vo2MaxRanges[i] > 0) {
+ float rangeValue = i - 1 >= 0 ? vo2MaxRanges[i-1] : 60F;
+ float rangeDiff = rangeValue - vo2MaxRanges[i];
+ float valueDiff = vo2MaxValue - vo2MaxRanges[i];
+ float multiplayer = valueDiff / rangeDiff;
+ value = (4 - i) * 0.2F + 0.2F * (multiplayer > 1 ? 1 : multiplayer) ;
+ break;
+ }
+ }
+ return value;
+ }
+
+ protected LineDataSet createDataSet(final List<Entry> values, int color, String label) {
+ final LineDataSet lineDataSet = new LineDataSet(values, label);
+ lineDataSet.setColor(color);
+ lineDataSet.setDrawCircles(false);
+ lineDataSet.setLineWidth(2f);
+ lineDataSet.setFillAlpha(255);
+ lineDataSet.setCircleRadius(5f);
+ lineDataSet.setDrawCircles(true);
+ lineDataSet.setDrawCircleHole(true);
+ lineDataSet.setCircleColor(color);
+ lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
+ lineDataSet.setDrawValues(true);
+ lineDataSet.setValueTextSize(10f);
+ lineDataSet.setValueTextColor(CHART_TEXT_COLOR);
+ lineDataSet.setValueFormatter(new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ return String.format(Locale.ROOT, "%d", Math.round(value));
+ }
+ });
+ return lineDataSet;
+ }
+
+ @Override
+ protected void renderCharts() {
+ vo2MaxChart.invalidate();
+ }
+
+
+ public List<? extends Vo2MaxSample> getAllSamples(final DBHandler db, final GBDevice device, int tsFrom, int tsTo) {
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final TimeSampleProvider<? extends Vo2MaxSample> sampleProvider = coordinator.getVo2MaxSampleProvider(device, db.getDaoSession());
+ return sampleProvider.getAllSamples(tsFrom * 1000L, tsTo * 1000L);
+ }
+
+ public Vo2MaxSample getLatestVo2MaxSample(final DBHandler db, final GBDevice device, Vo2MaxSample.Type type) {
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final Vo2MaxSampleProvider sampleProvider = (Vo2MaxSampleProvider) coordinator.getVo2MaxSampleProvider(device, db.getDaoSession());
+ return sampleProvider.getLatestSample(type, getTSEnd() * 1000L);
+ }
+
+ private void setupVO2MaxChart() {
+ final XAxis xAxisBottom = vo2MaxChart.getXAxis();
+ xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
+ xAxisBottom.setDrawLabels(true);
+ xAxisBottom.setDrawGridLines(false);
+ xAxisBottom.setEnabled(true);
+ xAxisBottom.setDrawLimitLinesBehindData(true);
+ xAxisBottom.setTextColor(CHART_TEXT_COLOR);
+ xAxisBottom.setAxisMinimum(0f);
+ xAxisBottom.setAxisMaximum(31f);
+ xAxisBottom.setGranularity(1f);
+ xAxisBottom.setGranularityEnabled(true);
+
+ final YAxis yAxisLeft = vo2MaxChart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setAxisMaximum(100);
+ yAxisLeft.setAxisMinimum(0);
+ yAxisLeft.setDrawTopYLabelEntry(true);
+ yAxisLeft.setEnabled(true);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+
+ final YAxis yAxisRight = vo2MaxChart.getAxisRight();
+ yAxisRight.setEnabled(true);
+ yAxisRight.setDrawLabels(false);
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setDrawAxisLine(true);
+ }
+
+ protected void setupLegend(Chart<?> chart) {
+ chart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ chart.getLegend().setWordWrapEnabled(true);
+ }
+
+ protected static class VO2MaxRecord {
+ float value;
+ long timestamp;
+ Vo2MaxSample.Type type;
+
+ protected VO2MaxRecord(long timestamp, float value, Vo2MaxSample.Type type) {
+ this.timestamp = timestamp;
+ this.value = value;
+ this.type = type;
+ }
+ }
+
+ protected static class VO2MaxData extends ChartsData {
+ private final List<? extends VO2MaxRecord> records;
+ private final Map<Vo2MaxSample.Type, VO2MaxRecord> latestValues;
+
+ protected VO2MaxData(List<? extends VO2MaxRecord> records, Map<Vo2MaxSample.Type, VO2MaxRecord> latestValues) {
+ this.records = records;
+ this.latestValues = latestValues;
+ }
+
+ @Nullable
+ public VO2MaxRecord getLatestValue(Vo2MaxSample.Type type) {
+ return this.latestValues.get(type);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/WeekSleepChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/WeekSleepChartFragment.java
new file mode 100644
index 0000000..bfef323
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/WeekSleepChartFragment.java
@@ -0,0 +1,499 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer, Jos茅 Rebelo,
+ Pavel Elagin, Petr Van臎k, a0z
+
+ 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.activities.charts;
+
+import android.os.Build;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.Legend;
+import com.github.mikephil.charting.components.LegendEntry;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.BarData;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.time.DateUtils;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmount;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmounts;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public class WeekSleepChartFragment extends AbstractWeekChartFragment {
+
+ private TextView awakeSleepTimeText;
+ private LinearLayout awakeSleepTimeTextWrapper;
+ private TextView remSleepTimeText;
+ private LinearLayout remSleepTimeTextWrapper;
+ private TextView deepSleepTimeText;
+ private TextView lightSleepTimeText;
+ private TextView sleepDatesText;
+ private MySleepWeeklyData mySleepWeeklyData;
+ private LinearLayout sleepScoreWrapper;
+ private LineChart sleepScoreChart;
+
+ public static WeekSleepChartFragment newInstance ( int totalDays ) {
+ WeekSleepChartFragment fragmentFirst = new WeekSleepChartFragment();
+ Bundle args = new Bundle();
+ args.putInt("totalDays", totalDays);
+ fragmentFirst.setArguments(args);
+ return fragmentFirst;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ TOTAL_DAYS = getArguments() != null ? getArguments().getInt("totalDays") : 0;
+ }
+
+ private MySleepWeeklyData getMySleepWeeklyData(DBHandler db, Calendar day, GBDevice device) {
+ day = (Calendar) day.clone(); // do not modify the caller's argument
+ day.add(Calendar.DATE, -TOTAL_DAYS + 1);
+ TOTAL_DAYS_FOR_AVERAGE=0;
+ long awakeWeeklyTotal = 0;
+ long remWeeklyTotal = 0;
+ long deepWeeklyTotal = 0;
+ long lightWeeklyTotal = 0;
+
+ for (int counter = 0; counter < TOTAL_DAYS; counter++) {
+ ActivityAmounts amounts = getActivityAmountsForDay(db, day, device);
+ if (calculateBalance(amounts) > 0) {
+ TOTAL_DAYS_FOR_AVERAGE++;
+ }
+
+ float[] totalAmounts = getTotalsForActivityAmounts(amounts);
+ int i = 0;
+ deepWeeklyTotal += (long) totalAmounts[i++];
+ lightWeeklyTotal += (long) totalAmounts[i++];
+ if (supportsRemSleep(getChartsHost().getDevice())) {
+ remWeeklyTotal += (long) totalAmounts[i++];
+ }
+ if (supportsAwakeSleep(getChartsHost().getDevice())) {
+ awakeWeeklyTotal += (long) totalAmounts[i++];
+ }
+
+ day.add(Calendar.DATE, 1);
+ }
+
+ return new MySleepWeeklyData(awakeWeeklyTotal, remWeeklyTotal, deepWeeklyTotal, lightWeeklyTotal);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ mLocale = getResources().getConfiguration().locale;
+ View rootView = inflater.inflate(R.layout.fragment_weeksleep_chart, container, false);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
+ getChartsHost().enableSwipeRefresh(scrollY == 0);
+ });
+ }
+
+ final int goal = getGoal();
+ if (goal >= 0) {
+ mTargetValue = goal;
+ }
+
+ mWeekChart = rootView.findViewById(R.id.weekstepschart);
+ sleepScoreWrapper = rootView.findViewById(R.id.sleep_score_wrapper);
+ sleepScoreChart = rootView.findViewById(R.id.sleep_score_chart);
+ remSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_rem_time);
+ remSleepTimeTextWrapper = rootView.findViewById(R.id.sleep_chart_legend_rem_time_wrapper);
+ awakeSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_awake_time);
+ awakeSleepTimeTextWrapper = rootView.findViewById(R.id.sleep_chart_legend_awake_time_wrapper);
+ deepSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_deep_time);
+ lightSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_light_time);
+ sleepDatesText = rootView.findViewById(R.id.sleep_dates);
+
+ mBalanceView = rootView.findViewById(R.id.balance);
+
+ if (!supportsSleepScore()) {
+ sleepScoreWrapper.setVisibility(View.GONE);
+ } else {
+ setupSleepScoreChart();
+ }
+
+ setupWeekChart();
+ // refresh immediately instead of use refreshIfVisible(), for perceived performance
+ refresh();
+
+ return rootView;
+ }
+
+ protected void setupWeekChart() {
+ super.setupWeekChart();
+ if (TOTAL_DAYS > 7) {
+ mWeekChart.setRenderer(new AngledLabelsChartRenderer(mWeekChart, mWeekChart.getAnimator(), mWeekChart.getViewPortHandler()));
+ } else {
+ mWeekChart.setScaleEnabled(false);
+ mWeekChart.setTouchEnabled(false);
+ }
+ }
+
+ @Override
+ protected void updateChartsnUIThread(MyChartsData mcd) {
+ setupLegend(mWeekChart);
+
+ mWeekChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
+ mWeekChart.setData(mcd.getWeekBeforeData().getData());
+ mWeekChart.getXAxis().setValueFormatter(mcd.getWeekBeforeData().getXValueFormatter());
+ mWeekChart.getBarData().setValueTextSize(10f);
+
+ if (supportsSleepScore()) {
+ sleepScoreChart.setData(null);
+ sleepScoreChart.getXAxis().setValueFormatter(mcd.getWeekBeforeData().getXValueFormatter());
+ sleepScoreChart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ sleepScoreChart.setData(mcd.getWeekBeforeData().getSleepScoreData());
+ }
+
+ // The last value is for awake time, which we do not want to include in the "total sleep time"
+ final int barIgnoreLast = supportsAwakeSleep(getChartsHost().getDevice()) ? 1 : 0;
+ mWeekChart.getBarData().setValueFormatter(new BarChartStackedTimeValueFormatter(false, "", 0, barIgnoreLast));
+
+ if (TOTAL_DAYS_FOR_AVERAGE > 0) {
+ float avgDeep = Math.abs(this.mySleepWeeklyData.getTotalDeep() / TOTAL_DAYS_FOR_AVERAGE);
+ deepSleepTimeText.setText(DateTimeUtils.formatDurationHoursMinutes((int) avgDeep, TimeUnit.MINUTES));
+ float avgLight = Math.abs(this.mySleepWeeklyData.getTotalLight() / TOTAL_DAYS_FOR_AVERAGE);
+ lightSleepTimeText.setText(DateTimeUtils.formatDurationHoursMinutes((int) avgLight, TimeUnit.MINUTES));
+ float avgRem = Math.abs(this.mySleepWeeklyData.getTotalRem() / TOTAL_DAYS_FOR_AVERAGE);
+ remSleepTimeText.setText(DateTimeUtils.formatDurationHoursMinutes((int) avgRem, TimeUnit.MINUTES));
+ float avgAwake = Math.abs(this.mySleepWeeklyData.getTotalAwake() / TOTAL_DAYS_FOR_AVERAGE);
+ awakeSleepTimeText.setText(DateTimeUtils.formatDurationHoursMinutes((int) avgAwake, TimeUnit.MINUTES));
+ } else {
+ deepSleepTimeText.setText("-");
+ lightSleepTimeText.setText("-");
+ remSleepTimeText.setText("-");
+ awakeSleepTimeText.setText("-");
+ }
+
+ if (!supportsRemSleep(getChartsHost().getDevice())) {
+ remSleepTimeTextWrapper.setVisibility(View.GONE);
+ }
+
+ if (!supportsAwakeSleep(getChartsHost().getDevice())) {
+ awakeSleepTimeTextWrapper.setVisibility(View.GONE);
+ }
+
+ Date to = new Date((long) this.getTSEnd() * 1000);
+ Date from = DateUtils.addDays(to,-(TOTAL_DAYS - 1));
+ String toFormattedDate = new SimpleDateFormat("E, MMM dd").format(to);
+ String fromFormattedDate = new SimpleDateFormat("E, MMM dd").format(from);
+ sleepDatesText.setText(fromFormattedDate + " - " + toFormattedDate);
+
+ mBalanceView.setText(mcd.getWeekBeforeData().getBalanceMessage());
+ }
+
+ @Override
+ protected MyChartsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ Calendar day = Calendar.getInstance();
+ day.setTime(chartsHost.getEndDate());
+ //NB: we could have omitted the day, but this way we can move things to the past easily
+ WeekChartsData<BarData> weekBeforeData = refreshWeekBeforeData(db, mWeekChart, day, device);
+ mySleepWeeklyData = getMySleepWeeklyData(db, day, device);
+
+ return new MyChartsData(weekBeforeData);
+ }
+
+ private void setupSleepScoreChart() {
+ final XAxis xAxisBottom = sleepScoreChart.getXAxis();
+ xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
+ xAxisBottom.setDrawLabels(true);
+ xAxisBottom.setDrawGridLines(false);
+ xAxisBottom.setEnabled(true);
+ xAxisBottom.setDrawLimitLinesBehindData(true);
+ xAxisBottom.setTextColor(CHART_TEXT_COLOR);
+ xAxisBottom.setAxisMinimum(0f);
+ xAxisBottom.setAxisMaximum(TOTAL_DAYS-1);
+ xAxisBottom.setGranularity(1f);
+ xAxisBottom.setGranularityEnabled(true);
+
+ final YAxis yAxisLeft = sleepScoreChart.getAxisLeft();
+ yAxisLeft.setDrawGridLines(true);
+ yAxisLeft.setAxisMaximum(100);
+ yAxisLeft.setAxisMinimum(0);
+ yAxisLeft.setDrawTopYLabelEntry(true);
+ yAxisLeft.setEnabled(true);
+ yAxisLeft.setTextColor(CHART_TEXT_COLOR);
+
+ final YAxis yAxisRight = sleepScoreChart.getAxisRight();
+ yAxisRight.setEnabled(true);
+ yAxisRight.setDrawLabels(false);
+ yAxisRight.setDrawGridLines(false);
+ yAxisRight.setDrawAxisLine(true);
+
+ sleepScoreChart.setDoubleTapToZoomEnabled(false);
+ sleepScoreChart.getDescription().setEnabled(false);
+ if (TOTAL_DAYS <= 7) {
+ sleepScoreChart.setScaleEnabled(false);
+ sleepScoreChart.setTouchEnabled(false);
+ }
+ }
+
+ @Override
+ protected void renderCharts() {
+ mWeekChart.invalidate();
+ sleepScoreChart.invalidate();
+ }
+
+ @Override
+ public String getTitle() {
+ if (GBApplication.getPrefs().getBoolean("charts_range", true)) {
+ return getString(R.string.weeksleepchart_sleep_a_month);
+ }
+ else{
+ return getString(R.string.weeksleepchart_sleep_a_week);
+ }
+ }
+
+ @Override
+ String getPieDescription(int targetValue) {
+ return getString(R.string.weeksleepchart_today_sleep_description, DateTimeUtils.formatDurationHoursMinutes(targetValue, TimeUnit.MINUTES));
+ }
+
+ @Override
+ int getGoal() {
+ return GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_SLEEP_DURATION, 8) * 60;
+ }
+
+ @Override
+ int getOffsetHours() {
+ return -12;
+ }
+
+
+ @Override
+ protected long calculateBalance(ActivityAmounts activityAmounts) {
+ long balance = 0;
+
+ for (ActivityAmount amount : activityAmounts.getAmounts()) {
+ if (amount.getActivityKind() == ActivityKind.DEEP_SLEEP ||
+ amount.getActivityKind() == ActivityKind.LIGHT_SLEEP ||
+ amount.getActivityKind() == ActivityKind.REM_SLEEP) {
+ balance += amount.getTotalSeconds();
+ }
+ }
+ return (int) (balance / 60);
+ }
+
+ @Override
+ protected String getBalanceMessage(long balance, int targetValue) {
+ if (balance > 0) {
+ final long totalBalance = balance - ((long)targetValue * TOTAL_DAYS_FOR_AVERAGE);
+ if (totalBalance > 0)
+ return getString(R.string.overslept, getHM(totalBalance));
+ else
+ return getString(R.string.lack_of_sleep, getHM(Math.abs(totalBalance)));
+ } else
+ return getString(R.string.no_data);
+ }
+
+ @Override
+ float[] getTotalsForActivityAmounts(ActivityAmounts activityAmounts) {
+ long totalSecondsDeepSleep = 0;
+ long totalSecondsLightSleep = 0;
+ long totalSecondsRemSleep = 0;
+ long totalSecondsAwakeSleep = 0;
+ for (ActivityAmount amount : activityAmounts.getAmounts()) {
+ if (amount.getActivityKind() == ActivityKind.DEEP_SLEEP) {
+ totalSecondsDeepSleep += amount.getTotalSeconds();
+ } else if (amount.getActivityKind() == ActivityKind.LIGHT_SLEEP) {
+ totalSecondsLightSleep += amount.getTotalSeconds();
+ } else if (amount.getActivityKind() == ActivityKind.REM_SLEEP) {
+ totalSecondsRemSleep += amount.getTotalSeconds();
+ } else if (amount.getActivityKind() == ActivityKind.AWAKE_SLEEP) {
+ totalSecondsAwakeSleep += amount.getTotalSeconds();
+ }
+ }
+ int totalMinutesDeepSleep = (int) (totalSecondsDeepSleep / 60);
+ int totalMinutesLightSleep = (int) (totalSecondsLightSleep / 60);
+ int totalMinutesRemSleep = (int) (totalSecondsRemSleep / 60);
+ int totalMinutesAwakeSleep = (int) (totalSecondsAwakeSleep / 60);
+
+ float[] activityAmountsTotals = {totalMinutesDeepSleep, totalMinutesLightSleep};
+ if (supportsRemSleep(getChartsHost().getDevice())) {
+ activityAmountsTotals = ArrayUtils.add(activityAmountsTotals, totalMinutesRemSleep);
+ }
+ if (supportsAwakeSleep(getChartsHost().getDevice())) {
+ activityAmountsTotals = ArrayUtils.add(activityAmountsTotals, totalMinutesAwakeSleep);
+ }
+
+ return activityAmountsTotals;
+ }
+
+ @Override
+ protected String formatPieValue(long value) {
+ return DateTimeUtils.formatDurationHoursMinutes(value, TimeUnit.MINUTES);
+ }
+
+ @Override
+ String[] getPieLabels() {
+ String[] labels = {
+ getString(R.string.abstract_chart_fragment_kind_deep_sleep),
+ getString(R.string.abstract_chart_fragment_kind_light_sleep)
+ };
+ if (supportsRemSleep(getChartsHost().getDevice())) {
+ labels = ArrayUtils.add(labels, getString(R.string.abstract_chart_fragment_kind_rem_sleep));
+ }
+ if (supportsAwakeSleep(getChartsHost().getDevice())) {
+ labels = ArrayUtils.add(labels, getString(R.string.abstract_chart_fragment_kind_awake_sleep));
+ }
+ return labels;
+ }
+
+ @Override
+ ValueFormatter getPieValueFormatter() {
+ return new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ return formatPieValue((long) value);
+ }
+ };
+ }
+
+ @Override
+ ValueFormatter getBarValueFormatter() {
+ return new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ return DateTimeUtils.minutesToHHMM((int) value);
+ }
+ };
+ }
+
+ @Override
+ ValueFormatter getYAxisFormatter() {
+ return new ValueFormatter() {
+ @Override
+ public String getFormattedValue(float value) {
+ return DateTimeUtils.minutesToHHMM((int) value);
+ }
+ };
+ }
+
+ @Override
+ int[] getColors() {
+ int[] colors = {akDeepSleep.color, akLightSleep.color};
+ if (supportsRemSleep(getChartsHost().getDevice())) {
+ colors = ArrayUtils.add(colors, akRemSleep.color);
+ }
+ if (supportsAwakeSleep(getChartsHost().getDevice())) {
+ colors = ArrayUtils.add(colors, akAwakeSleep.color);
+ }
+ return colors;
+ }
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {
+ List<LegendEntry> legendEntries = new ArrayList<>(2);
+
+ LegendEntry lightSleepEntry = new LegendEntry();
+ lightSleepEntry.label = getActivity().getString(R.string.sleep_colored_stats_light);
+ lightSleepEntry.formColor = akLightSleep.color;
+ legendEntries.add(lightSleepEntry);
+
+ LegendEntry deepSleepEntry = new LegendEntry();
+ deepSleepEntry.label = getActivity().getString(R.string.sleep_colored_stats_deep);
+ deepSleepEntry.formColor = akDeepSleep.color;
+ legendEntries.add(deepSleepEntry);
+
+ if (supportsRemSleep(getChartsHost().getDevice())) {
+ LegendEntry remSleepEntry = new LegendEntry();
+ remSleepEntry.label = getActivity().getString(R.string.sleep_colored_stats_rem);
+ remSleepEntry.formColor = akRemSleep.color;
+ legendEntries.add(remSleepEntry);
+ }
+
+ if (supportsAwakeSleep(getChartsHost().getDevice())) {
+ LegendEntry awakeSleepEntry = new LegendEntry();
+ awakeSleepEntry.label = getActivity().getString(R.string.abstract_chart_fragment_kind_awake_sleep);
+ awakeSleepEntry.formColor = akAwakeSleep.color;
+ legendEntries.add(awakeSleepEntry);
+ }
+
+ chart.getLegend().setCustom(legendEntries);
+ chart.getLegend().setTextColor(LEGEND_TEXT_COLOR);
+ chart.getLegend().setWordWrapEnabled(true);
+ chart.getLegend().setHorizontalAlignment(Legend.LegendHorizontalAlignment.CENTER);
+ }
+
+ private String getHM(long value) {
+ return DateTimeUtils.formatDurationHoursMinutes(value, TimeUnit.MINUTES);
+ }
+
+ @Override
+ String getAverage(float value) {
+ return getHM((long)value);
+ }
+
+ private static class MySleepWeeklyData {
+ private long totalAwake;
+ private long totalRem;
+ private long totalDeep;
+ private long totalLight;
+ private int totalDaysForAverage;
+
+ public MySleepWeeklyData(long totalAwake, long totalRem, long totalDeep, long totalLight) {
+ this.totalDeep = totalDeep;
+ this.totalRem = totalRem;
+ this.totalAwake = totalAwake;
+ this.totalLight = totalLight;
+ this.totalDaysForAverage = 0;
+ }
+
+ public long getTotalAwake() {
+ return this.totalAwake;
+ }
+
+ public long getTotalRem() {
+ return this.totalRem;
+ }
+
+ public long getTotalDeep() {
+ return this.totalDeep;
+ }
+
+ public long getTotalLight() {
+ return this.totalLight;
+ }
+
+ public int getTotalDaysForAverage() {
+ return this.totalDaysForAverage;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/WeightChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/WeightChartFragment.java
new file mode 100644
index 0000000..2653181
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/WeightChartFragment.java
@@ -0,0 +1,257 @@
+/* Copyright (C) 2024 Severin von Wnuck-Lipinski
+
+ 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.activities.charts;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.fragment.app.Fragment;
+
+import com.github.mikephil.charting.animation.Easing;
+import com.github.mikephil.charting.charts.Chart;
+import com.github.mikephil.charting.charts.LineChart;
+import com.github.mikephil.charting.components.LimitLine;
+import com.github.mikephil.charting.components.XAxis;
+import com.github.mikephil.charting.components.YAxis;
+import com.github.mikephil.charting.data.Entry;
+import com.github.mikephil.charting.data.LineData;
+import com.github.mikephil.charting.data.LineDataSet;
+import com.github.mikephil.charting.formatter.ValueFormatter;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.model.WeightSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+
+public class WeightChartFragment extends AbstractChartFragment<WeightChartFragment.WeightChartsData> {
+ private int colorBackground;
+ private int colorSecondaryText;
+
+ private int totalDays;
+ private boolean imperialUnits;
+ private int weightTargetKg;
+
+ private LineChart chart;
+ private TextView textTimeSpan;
+ private TextView textWeightLatest;
+ private TextView textWeightTarget;
+
+ @Override
+ public String getTitle() {
+ return getString(R.string.menuitem_weight);
+ }
+
+ @Override
+ protected void init() {
+ GBPrefs prefs = GBApplication.getPrefs();
+
+ colorBackground = GBApplication.getBackgroundColor(requireContext());
+ colorSecondaryText = GBApplication.getSecondaryTextColor(requireContext());
+
+ if (prefs.getBoolean("charts_range", true))
+ totalDays = 30;
+ else
+ totalDays = 7;
+
+ String unitSystem = prefs.getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, getString(R.string.p_unit_metric));
+
+ if (unitSystem.equals(getString(R.string.p_unit_imperial)))
+ imperialUnits = true;
+ else
+ imperialUnits = false;
+
+ weightTargetKg = prefs.getInt(ActivityUser.PREF_USER_GOAL_WEIGHT_KG, ActivityUser.defaultUserGoalWeightKg);
+ }
+
+ @Override
+ protected boolean isSingleDay() {
+ return false;
+ }
+
+ @Override
+ protected WeightChartsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
+ long tsStart = getTSStart() * 1000L;
+ long tsEnd = getTSEnd() * 1000L;
+
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ TimeSampleProvider<? extends WeightSample> provider = coordinator.getWeightSampleProvider(device, db.getDaoSession());
+ List<? extends WeightSample> samples = provider.getAllSamples(tsStart, tsEnd);
+ WeightSample latestSample = provider.getLatestSample();
+
+ return createChartsData(samples, latestSample);
+ }
+
+ @Override
+ protected void renderCharts() {
+ chart.animateX(ANIM_TIME, Easing.EaseInOutQuart);
+ }
+
+ @Override
+ protected void setupLegend(Chart<?> chart) {}
+
+ @Override
+ protected void updateChartsnUIThread(WeightChartsData chartsData) {
+ chart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317
+ chart.getXAxis().setValueFormatter(chartsData.getXValueFormatter());
+ chart.getXAxis().setAvoidFirstLastClipping(true);
+ chart.setData(chartsData.getData());
+
+ Date dateStart = DateTimeUtils.parseTimeStamp(getTSStart());
+ Date dateEnd = DateTimeUtils.parseTimeStamp(getTSEnd());
+ SimpleDateFormat format = new SimpleDateFormat("E, MMM dd");
+ WeightSample latestSample = chartsData.getLatestSample();
+
+ textTimeSpan.setText(format.format(dateStart) + " - " + format.format(dateEnd));
+
+ if (latestSample != null)
+ textWeightLatest.setText(formatWeight(weightFromKg(latestSample.getWeightKg())));
+
+ textWeightTarget.setText(formatWeight(weightFromKg(weightTargetKg)));
+ }
+
+ @Override
+ protected int getTSStart() {
+ return DateTimeUtils.shiftDays(getTSEnd(), -totalDays + 1);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_weightchart, container, false);
+
+ chart = rootView.findViewById(R.id.weight_chart);
+ textTimeSpan = rootView.findViewById(R.id.weight_time_span_text);
+ textWeightLatest = rootView.findViewById(R.id.weight_latest_text);
+ textWeightTarget = rootView.findViewById(R.id.weight_target_text);
+
+ configureBarLineChartDefaults(chart);
+ chart.setBackgroundColor(colorBackground);
+ chart.getDescription().setEnabled(false);
+ chart.getLegend().setEnabled(false);
+ chart.getAxisRight().setEnabled(false);
+ chart.setDoubleTapToZoomEnabled(false);
+
+ LimitLine targetLine = new LimitLine(weightFromKg(weightTargetKg));
+ targetLine.setTextColor(colorSecondaryText);
+
+ XAxis xAxis = chart.getXAxis();
+ xAxis.setTextColor(colorSecondaryText);
+ xAxis.setDrawLabels(true);
+ xAxis.setDrawLimitLinesBehindData(true);
+
+ YAxis yAxis = chart.getAxisLeft();
+ yAxis.setTextColor(colorSecondaryText);
+ yAxis.addLimitLine(targetLine);
+ yAxis.setDrawGridLines(true);
+
+ refresh();
+
+ return rootView;
+ }
+
+ private WeightChartsData createChartsData(List<? extends WeightSample> samples, WeightSample latestSample) {
+ List<Entry> entries = new ArrayList<>();
+ TimestampTranslation tsTranslation = new TimestampTranslation();
+
+ for (WeightSample sample : samples) {
+ int tsSeconds = (int)(sample.getTimestamp() / 1000L);
+ float weight = weightFromKg(sample.getWeightKg());
+
+ entries.add(new Entry(tsTranslation.shorten(tsSeconds), weight));
+ }
+
+ LineDataSet dataSet = new LineDataSet(entries, getString(R.string.menuitem_weight));
+ dataSet.setLineWidth(2.2f);
+ dataSet.setMode(LineDataSet.Mode.HORIZONTAL_BEZIER);
+ dataSet.setCubicIntensity(0.1f);
+ dataSet.setCircleRadius(5);
+ dataSet.setDrawCircleHole(false);
+ dataSet.setDrawValues(true);
+ dataSet.setValueTextSize(10);
+ dataSet.setValueTextColor(colorSecondaryText);
+ dataSet.setValueFormatter(new ValueFormatter() {
+ @Override
+ public String getPointLabel(Entry entry) {
+ return formatWeight(entry.getY());
+ }
+ });
+
+ return new WeightChartsData(new LineData(dataSet), tsTranslation, latestSample);
+ }
+
+ private float weightFromKg(float weight) {
+ // Convert to lbs
+ if (imperialUnits)
+ weight *= 2.2046226f;
+
+ return weight;
+ }
+
+ private String formatWeight(float weight) {
+ int weightString = imperialUnits ? R.string.weight_lbs : R.string.weight_kg;
+
+ return getString(weightString, weight);
+ }
+
+ protected static class WeightChartsData extends DefaultChartsData<LineData> {
+ private final WeightSample latestSample;
+
+ public WeightChartsData(LineData lineData, TimestampTranslation tsTranslation, WeightSample latestSample) {
+ super(lineData, new DateFormatter(tsTranslation));
+
+ this.latestSample = latestSample;
+ }
+
+ private WeightSample getLatestSample() {
+ return latestSample;
+ }
+ }
+
+ private static class DateFormatter extends ValueFormatter {
+ private TimestampTranslation translation;
+ private SimpleDateFormat format = new SimpleDateFormat("dd.MM.");
+ private Calendar calendar = GregorianCalendar.getInstance();
+
+ public DateFormatter(TimestampTranslation translation) {
+ this.translation = translation;
+ }
+
+ @Override
+ public String getFormattedValue(float value) {
+ calendar.clear();
+ calendar.setTimeInMillis(translation.toOriginalValue((int)value) * 1000L);
+
+ return format.format(calendar.getTime());
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardVO2MaxWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardVO2MaxWidget.java
new file mode 100644
index 0000000..d8b6ea7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardVO2MaxWidget.java
@@ -0,0 +1,124 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Serializable;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.Vo2MaxSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+
+public abstract class AbstractDashboardVO2MaxWidget extends AbstractGaugeWidget implements DashboardVO2MaxWidgetInterface {
+ protected static final Logger LOG = LoggerFactory.getLogger(AbstractDashboardVO2MaxWidget.class);
+
+ public AbstractDashboardVO2MaxWidget(int label, @Nullable String targetActivityTab) {
+ super(label, targetActivityTab);
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ final List<GBDevice> devices = getSupportedDevices(dashboardData);
+ final VO2MaxData data = new VO2MaxData();
+
+ // Latest vo2max sample.
+ Vo2MaxSample sample = null;
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ for (GBDevice dev : devices) {
+ final Vo2MaxSampleProvider sampleProvider = (Vo2MaxSampleProvider) dev.getDeviceCoordinator().getVo2MaxSampleProvider(dev, dbHandler.getDaoSession());
+ final Vo2MaxSample latestSample = sampleProvider.getLatestSample(getVO2MaxType(), dashboardData.timeTo * 1000L);
+ if (latestSample != null && (sample == null || latestSample.getTimestamp() > sample.getTimestamp())) {
+ sample = latestSample;
+ }
+ }
+
+ if (sample != null) {
+ data.value = sample.getValue();
+ }
+
+ } catch (final Exception e) {
+ LOG.error("Could not get vo2max for today", e);
+ }
+
+ dashboardData.put(getWidgetKey(), data);
+ }
+
+ public static int[] getColors() {
+ return new int[]{
+ ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_poor_color),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_fair_color),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_good_color),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_excellent_color),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_superior_color),
+ };
+ }
+
+ public static float[] getSegments() {
+ return new float[] {
+ 0.20F,
+ 0.20F,
+ 0.20F,
+ 0.20F,
+ 0.20F,
+ };
+ }
+
+ public static float[] getVO2MaxRanges() {
+ return new float[] {
+ 55.4F,
+ 51.1F,
+ 45.4F,
+ 41.7F,
+ 0.0F,
+ };
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ final VO2MaxData vo2MaxData = (VO2MaxData) dashboardData.get(getWidgetKey());
+ if (vo2MaxData == null) {
+ drawSimpleGauge(0, -1);
+ return;
+ }
+
+ final int[] colors = getColors();
+ final float[] segments = getSegments();
+ final float[] vo2MaxRanges = getVO2MaxRanges();
+ float vo2MaxValue = calculateVO2maxGaugeValue(vo2MaxRanges, vo2MaxData.value != -1 ? vo2MaxData.value : 0);
+ setText(String.valueOf(vo2MaxData.value != -1 ? Math.round(vo2MaxData.value) : "-"));
+ drawSegmentedGauge(
+ colors,
+ segments,
+ vo2MaxValue,
+ false,
+ true
+ );
+ }
+
+ private float calculateVO2maxGaugeValue(float[] vo2MaxRanges, float vo2MaxValue) {
+ float value = -1;
+ for (int i = 0; i < vo2MaxRanges.length; i++) {
+ if (vo2MaxValue - vo2MaxRanges[i] > 0) {
+ float rangeValue = i - 1 >= 0 ? vo2MaxRanges[i-1] : 60F;
+ float rangeDiff = rangeValue - vo2MaxRanges[i];
+ float valueDiff = vo2MaxValue - vo2MaxRanges[i];
+ float multiplayer = valueDiff / rangeDiff;
+ value = (4 - i) * 0.2F + 0.2F * (multiplayer > 1 ? 1 : multiplayer) ;
+ break;
+ }
+ }
+ return value;
+ }
+
+ private static class VO2MaxData implements Serializable {
+ private float value = -1;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardWidget.java
new file mode 100644
index 0000000..eebbfe0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardWidget.java
@@ -0,0 +1,136 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Toast;
+
+import androidx.annotation.ColorInt;
+import androidx.fragment.app.Fragment;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.ActivityChartsActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public abstract class AbstractDashboardWidget extends Fragment {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractDashboardWidget.class);
+
+ protected static String ARG_DASHBOARD_DATA = "dashboard_widget_argument_data";
+
+ protected DashboardFragment.DashboardData dashboardData;
+
+ protected @ColorInt int color_unknown = Color.argb(25, 128, 128, 128);
+ protected @ColorInt int color_not_worn = Color.BLACK;
+ protected @ColorInt int color_worn = Color.rgb(128, 128, 128);
+ protected @ColorInt int color_activity = Color.GREEN;
+ protected @ColorInt int color_exercise = Color.rgb(255, 128, 0);
+ protected @ColorInt int color_deep_sleep = Color.rgb(0, 84, 163);
+ protected @ColorInt int color_light_sleep = Color.rgb(7, 158, 243);
+ protected @ColorInt int color_rem_sleep = Color.rgb(228, 39, 199);
+ protected @ColorInt int color_awake_sleep = Color.rgb(0xff, 0x86, 0x6e);
+ protected @ColorInt int color_distance = Color.BLUE;
+ protected @ColorInt int color_active_time = Color.rgb(170, 0, 255);
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (getArguments() != null) {
+ dashboardData = (DashboardFragment.DashboardData) getArguments().getSerializable(ARG_DASHBOARD_DATA);
+ }
+ }
+
+ public void update() {
+ fillData();
+ }
+
+ protected abstract void fillData();
+
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsActivityTracking();
+ }
+
+ protected List<GBDevice> getSupportedDevices(final DashboardFragment.DashboardData dashboardData) {
+ return GBApplication.app().getDeviceManager().getDevices()
+ .stream()
+ .filter(dev -> dashboardData.showAllDevices || dashboardData.showDeviceList.contains(dev.getAddress()))
+ .filter(this::isSupportedBy)
+ .collect(Collectors.toList());
+ }
+
+ protected void onClickOpenChart(final View view, final String chart, final int label, final String mode) {
+ view.setOnClickListener(v -> {
+ chooseDevice(dashboardData, device -> {
+ final Intent startIntent;
+ startIntent = new Intent(requireContext(), ActivityChartsActivity.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ startIntent.putExtra(ActivityChartsActivity.EXTRA_SINGLE_FRAGMENT_NAME, chart);
+ startIntent.putExtra(ActivityChartsActivity.EXTRA_ACTIONBAR_TITLE, label);
+ startIntent.putExtra(ActivityChartsActivity.EXTRA_TIMESTAMP, dashboardData.timeTo);
+ startIntent.putExtra(ActivityChartsActivity.EXTRA_MODE, mode);
+ requireContext().startActivity(startIntent);
+ });
+ });
+ }
+
+ protected void chooseDevice(final DashboardFragment.DashboardData dashboardData,
+ final Consumer<GBDevice> consumer) {
+ final List<GBDevice> devices = getSupportedDevices(dashboardData);
+
+ if (devices.size() == 1) {
+ consumer.accept(devices.get(0));
+ return;
+ }
+
+ if (devices.isEmpty()) {
+ GB.toast(GBApplication.getContext(), R.string.no_supported_devices_found, Toast.LENGTH_LONG, GB.WARN);
+ return;
+ }
+
+ final String[] deviceNames = devices.stream()
+ .map(GBDevice::getAliasOrName)
+ .toArray(String[]::new);
+
+ final Context activity = getActivity();
+ if (activity == null) {
+ return;
+ }
+
+ new MaterialAlertDialogBuilder(activity)
+ .setCancelable(true)
+ .setTitle(R.string.choose_device)
+ .setItems(deviceNames, (dialog, which) -> consumer.accept(devices.get(which)))
+ .setNegativeButton(android.R.string.cancel, (dialog, which) -> {
+ })
+ .show();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractGaugeWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractGaugeWidget.java
new file mode 100644
index 0000000..4fd321d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractGaugeWidget.java
@@ -0,0 +1,164 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+
+public abstract class AbstractGaugeWidget extends AbstractDashboardWidget {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractGaugeWidget.class);
+
+ private TextView gaugeValue;
+ private ImageView gaugeBar;
+ protected GaugeDrawer gaugeDrawer;
+
+ private final int label;
+ private final String targetActivityTab;
+ private String mode = "";
+
+ public AbstractGaugeWidget(@StringRes final int label, @Nullable final String targetActivityTab) {
+ this.label = label;
+ this.targetActivityTab = targetActivityTab;
+ }
+
+ public AbstractGaugeWidget(@StringRes final int label, @Nullable final String targetActivityTab, final String mode) {
+ this(label, targetActivityTab);
+ this.mode = mode;
+ }
+
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) {
+ final View fragmentView = inflater.inflate(R.layout.dashboard_widget_generic_gauge, container, false);
+
+ if (targetActivityTab != null) {
+ onClickOpenChart(fragmentView, targetActivityTab, label, mode);
+ }
+
+ gaugeValue = fragmentView.findViewById(R.id.gauge_value);
+ gaugeBar = fragmentView.findViewById(R.id.gauge_bar);
+ gaugeDrawer = new GaugeDrawer();
+ final TextView gaugeLabel = fragmentView.findViewById(R.id.gauge_label);
+ gaugeLabel.setText(label);
+
+ fillData();
+
+ return fragmentView;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (gaugeValue != null && gaugeBar != null) fillData();
+ }
+
+ @Override
+ protected void fillData() {
+ if (gaugeBar == null) return;
+ gaugeBar.post(() -> {
+ final FillDataAsyncTask myAsyncTask = new FillDataAsyncTask();
+ myAsyncTask.execute();
+ });
+ }
+
+ /**
+ * This is called from the async task, outside of the UI thread. It's expected that
+ * {@link nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment.DashboardData} be
+ * populated with the necessary data for display.
+ *
+ * @param dashboardData the DashboardData to populate
+ */
+ protected abstract void populateData(DashboardFragment.DashboardData dashboardData);
+
+ /**
+ * This is called from the UI thread.
+ *
+ * @param dashboardData populated DashboardData
+ */
+ protected abstract void draw(DashboardFragment.DashboardData dashboardData);
+
+ private class FillDataAsyncTask extends AsyncTask<Void, Void, Void> {
+ @Override
+ protected Void doInBackground(final Void... params) {
+ final long nanoStart = System.nanoTime();
+ try {
+ populateData(dashboardData);
+ } catch (final Exception e) {
+ LOG.error("fillData for {} failed", AbstractGaugeWidget.this.getClass().getSimpleName(), e);
+ }
+ final long nanoEnd = System.nanoTime();
+ final long executionTime = (nanoEnd - nanoStart) / 1000000;
+ LOG.debug("fillData for {} took {}ms", AbstractGaugeWidget.this.getClass().getSimpleName(), executionTime);
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(final Void unused) {
+ super.onPostExecute(unused);
+ try {
+ draw(dashboardData);
+ } catch (final Exception e) {
+ LOG.error("draw for {} failed", AbstractGaugeWidget.this.getClass().getSimpleName(), e);
+ }
+ }
+ }
+
+ protected void setText(final CharSequence text) {
+ gaugeValue.setText(text);
+ }
+
+ /**
+ * Draw a simple gauge.
+ *
+ * @param color the gauge color
+ * @param value the gauge value. Range: [0, 1]
+ */
+ protected void drawSimpleGauge(final int color,
+ final float value) {
+ gaugeDrawer.drawSimpleGauge(gaugeBar, color, value);
+ }
+
+ /**
+ * Draws a segmented gauge.
+ *
+ * @param colors the colors of each segment
+ * @param segments the size of each segment. The sum of all segments should be 1
+ * @param value the gauge value, in range [0, 1], or -1 for no value and only segments
+ * @param fadeOutsideDot whether to fade out colors outside the dot value
+ * @param gapBetweenSegments whether to introduce a small gap between the segments
+ */
+ protected void drawSegmentedGauge(final int[] colors,
+ final float[] segments,
+ final float value,
+ final boolean fadeOutsideDot,
+ final boolean gapBetweenSegments) {
+ gaugeDrawer.drawSegmentedGauge(gaugeBar, colors, segments, value, fadeOutsideDot, gapBetweenSegments);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardActiveTimeWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardActiveTimeWidget.java
new file mode 100644
index 0000000..ebeaac5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardActiveTimeWidget.java
@@ -0,0 +1,74 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+
+/**
+ * A simple {@link AbstractDashboardWidget} subclass.
+ * Use the {@link DashboardActiveTimeWidget#newInstance} factory method to
+ * create an instance of this fragment.
+ */
+public class DashboardActiveTimeWidget extends AbstractGaugeWidget {
+ public DashboardActiveTimeWidget() {
+ super(R.string.activity_list_summary_active_time, "activity");
+ }
+
+ /**
+ * Use this factory method to create a new instance of
+ * this fragment using the provided parameters.
+ *
+ * @param dashboardData An instance of DashboardFragment.DashboardData.
+ * @return A new instance of fragment DashboardActiveTimeWidget.
+ */
+ public static DashboardActiveTimeWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardActiveTimeWidget fragment = new DashboardActiveTimeWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ dashboardData.getActiveMinutesTotal();
+ dashboardData.getActiveMinutesGoalFactor();
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ final long totalActiveMinutes = dashboardData.getActiveMinutesTotal();
+ final String valueText = String.format(
+ Locale.ROOT,
+ "%d:%02d",
+ (int) Math.floor(totalActiveMinutes / 60f),
+ (int) (totalActiveMinutes % 60f)
+ );
+
+ setText(valueText);
+
+ drawSimpleGauge(
+ color_active_time,
+ dashboardData.getActiveMinutesGoalFactor()
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBodyEnergyWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBodyEnergyWidget.java
new file mode 100644
index 0000000..820486a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBodyEnergyWidget.java
@@ -0,0 +1,191 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.text.style.RelativeSizeSpan;
+
+import androidx.core.content.ContextCompat;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Serializable;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BodyEnergySample;
+
+public class DashboardBodyEnergyWidget extends AbstractGaugeWidget {
+ private static final Logger LOG = LoggerFactory.getLogger(DashboardBodyEnergyWidget.class);
+
+ public DashboardBodyEnergyWidget() {
+ super(R.string.body_energy, "bodyenergy");
+ }
+
+ public static DashboardBodyEnergyWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardBodyEnergyWidget fragment = new DashboardBodyEnergyWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsBodyEnergy();
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ final List<GBDevice> devices = getSupportedDevices(dashboardData);
+
+ final boolean isToday = DateUtils.isToday(dashboardData.timeTo * 1000L);
+
+ final BodyEnergyData data = new BodyEnergyData();
+ data.isToday = isToday;
+
+ if (isToday) {
+ // Latest stress sample for today
+ BodyEnergySample sample = null;
+
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ for (GBDevice dev : devices) {
+ final BodyEnergySample latestSample = dev.getDeviceCoordinator().getBodyEnergySampleProvider(dev, dbHandler.getDaoSession())
+ .getLatestSample();
+
+ if (latestSample != null && (sample == null || latestSample.getTimestamp() > sample.getTimestamp())) {
+ sample = latestSample;
+ }
+ }
+
+ if (sample != null) {
+ data.value = sample.getEnergy();
+ }
+
+ } catch (final Exception e) {
+ LOG.error("Could not get body energy for today", e);
+ }
+ } else {
+ // Gain / loss for the period
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ for (GBDevice dev : devices) {
+ if ((dashboardData.showAllDevices || dashboardData.showDeviceList.contains(dev.getAddress())) && dev.getDeviceCoordinator().supportsBodyEnergy()) {
+ final List<? extends BodyEnergySample> samples = dev.getDeviceCoordinator()
+ .getBodyEnergySampleProvider(dev, dbHandler.getDaoSession())
+ .getAllSamples(dashboardData.timeFrom * 1000L, dashboardData.timeTo * 1000L);
+
+ if (samples.size() > 1) {
+ int gained = 0;
+ int lost = 0;
+ for (int i = 1; i < samples.size(); i++) {
+ final BodyEnergySample s1 = samples.get(i - 1);
+ final BodyEnergySample s2 = samples.get(i);
+ if (s2.getEnergy() > s1.getEnergy()) {
+ gained += s2.getEnergy() - s1.getEnergy();
+ } else {
+ lost += s1.getEnergy() - s2.getEnergy();
+ }
+ }
+
+ data.gained = gained;
+ data.lost = lost;
+ }
+ }
+ }
+ } catch (final Exception e) {
+ LOG.error("Could not calculate average stress", e);
+ }
+ }
+
+ dashboardData.put("bodyenergy", data);
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ final BodyEnergyData bodyEnergyData = (BodyEnergyData) dashboardData.get("bodyenergy");
+ if (bodyEnergyData == null) {
+ drawSimpleGauge(0, -1);
+ return;
+ }
+
+ final int colorEnergy = ContextCompat.getColor(GBApplication.getContext(), R.color.body_energy_level_color);
+
+ if (bodyEnergyData.isToday) {
+ if (bodyEnergyData.value < 0) {
+ drawSimpleGauge(0, -1);
+ return;
+ }
+
+ setText(String.valueOf(bodyEnergyData.value));
+ drawSimpleGauge(
+ colorEnergy,
+ bodyEnergyData.value / 100f
+ );
+ } else {
+ if (bodyEnergyData.gained < 0 || bodyEnergyData.lost < 0) {
+ drawSimpleGauge(0, -1);
+ return;
+ }
+
+ final int diff = bodyEnergyData.gained - bodyEnergyData.lost;
+
+ final SpannableString spanGain = new SpannableString("鈫�" + bodyEnergyData.gained);
+ final SpannableString spanLost = new SpannableString("鈫�" + bodyEnergyData.lost);
+ spanGain.setSpan(new RelativeSizeSpan(0.65f), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ spanLost.setSpan(new RelativeSizeSpan(0.65f), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ setText(TextUtils.concat(spanGain, " ", spanLost));
+ drawSimpleGauge(
+ colorEnergy,
+ Math.abs(diff) / 100f
+ );
+
+ final int[] colors = {
+ colorEnergy,
+ ContextCompat.getColor(GBApplication.getContext(), R.color.body_energy_lost_color)
+ };
+ final float[] segments = {
+ bodyEnergyData.gained / (float) (bodyEnergyData.gained + bodyEnergyData.lost),
+ bodyEnergyData.lost / (float) (bodyEnergyData.gained + bodyEnergyData.lost),
+ };
+
+ drawSegmentedGauge(
+ colors,
+ segments,
+ -1,
+ false,
+ true
+ );
+ }
+ }
+
+ private static class BodyEnergyData implements Serializable {
+ private int value = -1;
+ private int gained = -1;
+ private int lost = -1;
+ private boolean isToday;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCalendarActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCalendarActivity.java
new file mode 100644
index 0000000..01d754b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCalendarActivity.java
@@ -0,0 +1,374 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.content.Intent;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.LayerDrawable;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.style.ForegroundColorSpan;
+import android.util.DisplayMetrics;
+import android.view.Gravity;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.ColorInt;
+import androidx.gridlayout.widget.GridLayout;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.util.DashboardUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class DashboardCalendarActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(DashboardCalendarActivity.class);
+ public static String EXTRA_TIMESTAMP = "dashboard_calendar_chosen_day";
+ private final ConcurrentHashMap<Calendar, TextView> dayCells = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap<Integer, Integer> dayColors = new ConcurrentHashMap<>();
+
+ @ColorInt private int color_unknown = Color.argb(50, 128, 128, 128);
+ @ColorInt private int color_0_25 = Color.argb(128, 255, 0, 0); // Red
+ @ColorInt private int color_25_50 = Color.argb(128, 255, 128, 0); // Orange
+ @ColorInt private int color_50_75 = Color.argb(128, 255, 255, 0); // Yellow
+ @ColorInt private int color_75_100 = Color.argb(128, 0, 128, 0); // Dark green
+ @ColorInt private int color_100 = Color.argb(128, 0, 255, 0); // Green
+
+ private boolean showAllDevices;
+ private Set<String> showDeviceList;
+
+ TextView monthTextView;
+ TextView arrowLeft;
+ TextView arrowRight;
+ GridLayout calendarGrid;
+ Calendar currentDay;
+ Calendar cal;
+ ImageView monthGoalsChart;
+ TextView monthGoalsText;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_dashboard_calendar);
+ monthTextView = findViewById(R.id.calendar_month);
+ calendarGrid = findViewById(R.id.dashboard_calendar_grid);
+ monthGoalsChart = findViewById(R.id.dashboard_calendar_month_goals_chart);
+ monthGoalsText = findViewById(R.id.dashboard_calendar_month_goals_text);
+ currentDay = Calendar.getInstance();
+ cal = Calendar.getInstance();
+ long receivedTimestamp = getIntent().getLongExtra(EXTRA_TIMESTAMP, 0);
+ if (receivedTimestamp != 0) {
+ currentDay.setTimeInMillis(receivedTimestamp);
+ cal.setTimeInMillis(receivedTimestamp);
+ }
+
+ Prefs prefs = GBApplication.getPrefs();
+ showAllDevices = prefs.getBoolean("dashboard_devices_all", true);
+ showDeviceList = prefs.getStringSet("dashboard_devices_multiselect", new HashSet<>());
+
+ arrowLeft = findViewById(R.id.arrow_left);
+ arrowLeft.setOnClickListener(v -> {
+ cal.add(Calendar.MONTH, -1);
+ draw();
+ });
+ arrowRight = findViewById(R.id.arrow_right);
+ arrowRight.setOnClickListener(v -> {
+ Calendar today = GregorianCalendar.getInstance();
+ if (!DateTimeUtils.isSameMonth(today, cal)) {
+ cal.add(Calendar.MONTH, 1);
+ draw();
+ }
+ });
+
+ draw();
+ }
+
+ private void displayColorsAsync() {
+ calendarGrid.post(new Runnable() {
+ @Override
+ public void run() {
+ FillDataAsyncTask myAsyncTask = new FillDataAsyncTask();
+ myAsyncTask.execute();
+ }
+ });
+ }
+
+ private void draw() {
+ // Remove previous calendar days
+ dayCells.clear();
+ dayColors.clear();
+ calendarGrid.removeAllViews();
+ // Update month display
+ SimpleDateFormat monthFormat = new SimpleDateFormat("LLLL yyyy", Locale.getDefault());
+ monthTextView.setText(monthFormat.format(cal.getTime()));
+ Calendar today = GregorianCalendar.getInstance();
+ today.set(Calendar.HOUR, 23);
+ today.set(Calendar.MINUTE, 59);
+ today.set(Calendar.SECOND, 59);
+ if (DateTimeUtils.isSameMonth(today, cal)) {
+ arrowRight.setAlpha(0.5f);
+ } else {
+ arrowRight.setAlpha(1);
+ }
+ // Calculate grid cell size for dates
+ DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
+ int screenWidth = displayMetrics.widthPixels;
+ int cellSize = screenWidth / 7;
+ // Determine first day that should be displayed
+ Calendar drawCal = (Calendar) cal.clone();
+ drawCal.set(Calendar.DAY_OF_MONTH, 1);
+ int displayMonth = drawCal.get(Calendar.MONTH);
+ int firstDayOfWeek = cal.getFirstDayOfWeek();
+ int daysToFirstDay = (drawCal.get(Calendar.DAY_OF_WEEK) - firstDayOfWeek + 7) % 7;
+ drawCal.add(Calendar.DAY_OF_MONTH, -daysToFirstDay);
+ // Determine last day that should be displayed
+ Calendar lastDay = (Calendar) cal.clone();
+ lastDay.set(Calendar.DAY_OF_MONTH, cal.getActualMaximum(Calendar.DAY_OF_MONTH));
+ int daysAfterMonth = (firstDayOfWeek + 7 - lastDay.get(Calendar.DAY_OF_WEEK)) % 7;
+ if (daysAfterMonth == 0 && lastDay.get(Calendar.DAY_OF_WEEK) == firstDayOfWeek) {
+ daysAfterMonth = 7;
+ }
+ lastDay.add(Calendar.DAY_OF_MONTH, daysAfterMonth);
+ // Add day names header
+ SimpleDateFormat dayFormat = new SimpleDateFormat("E", Locale.getDefault());
+ Calendar weekdays = Calendar.getInstance();
+ for (int i=0; i<7; i++) {
+ int currentDayOfWeek = (firstDayOfWeek + i - 1) % 7 + 1;
+ weekdays.set(Calendar.DAY_OF_WEEK, currentDayOfWeek);
+ createWeekdayCell(dayFormat.format(weekdays.getTime()), cellSize);
+ }
+ // Loop through month days and create grid cells for them
+ while (!DateTimeUtils.isSameDay(drawCal, lastDay)) {
+ boolean clickable = drawCal.get(Calendar.MONTH) == displayMonth;
+ if (drawCal.after(today)) clickable = false;
+ createDateCell(drawCal, cellSize, clickable);
+ drawCal.add(Calendar.DAY_OF_MONTH, 1);
+ }
+ // Asynchronously determine and display goal colors
+ displayColorsAsync();
+ }
+
+ private TextView prepareGridElement(int cellSize) {
+ GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(
+ GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL,1f),
+ GridLayout.spec(GridLayout.UNDEFINED, 1, GridLayout.FILL,1f)
+ );
+ int margin = cellSize / 10;
+ layoutParams.width = 0;
+ layoutParams.height = cellSize - 2 * margin;
+ layoutParams.setMargins(margin, margin, margin, margin);
+ TextView text = new TextView(this);
+ text.setLayoutParams(layoutParams);
+ text.setGravity(Gravity.CENTER);
+ return text;
+ }
+
+ private void createWeekdayCell(String day, int cellSize) {
+ TextView text = prepareGridElement(cellSize);
+ text.setText(day);
+ calendarGrid.addView(text);
+ }
+
+ private void createDateCell(Calendar day, int cellSize, boolean clickable) {
+ TextView text = prepareGridElement(cellSize);
+ text.setText(String.valueOf(day.get(Calendar.DAY_OF_MONTH)));
+ if (clickable) {
+ // Save textview for later coloring
+ dayCells.put((Calendar) day.clone(), text);
+ }
+ calendarGrid.addView(text);
+ }
+
+ private class FillDataAsyncTask extends AsyncTask<Void, Void, Void> {
+ int amount_0_25 = 0;
+ int amount_25_50 = 0;
+ int amount_50_75 = 0;
+ int amount_75_100 = 0;
+ int amount_100 = 0;
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ for (Calendar day : dayCells.keySet()) {
+ // Determine day color by the amount of the steps goal reached
+ DashboardFragment.DashboardData dashboardData = new DashboardFragment.DashboardData();
+ dashboardData.showAllDevices = showAllDevices;
+ dashboardData.showDeviceList = showDeviceList;
+ dashboardData.timeTo = (int) (day.getTimeInMillis() / 1000);
+ dashboardData.timeFrom = DateTimeUtils.shiftDays(dashboardData.timeTo, -1);
+ float goalFactor = DashboardUtils.getStepsGoalFactor(dashboardData);
+ @ColorInt int dayColor;
+ if (goalFactor >= 1) {
+ dayColor = color_100;
+ amount_100++;
+ } else if (goalFactor >= 0.75) {
+ dayColor = color_75_100;
+ amount_75_100++;
+ } else if (goalFactor >= 0.5) {
+ dayColor = color_50_75;
+ amount_50_75++;
+ } else if (goalFactor >= 0.25) {
+ dayColor = color_25_50;
+ amount_25_50++;
+ } else if (goalFactor > 0) {
+ dayColor = color_0_25;
+ amount_0_25++;
+ } else {
+ dayColor = color_unknown;
+ }
+ dayColors.put(day.get(Calendar.DAY_OF_MONTH), dayColor);
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void unused) {
+ super.onPostExecute(unused);
+ for (Map.Entry<Calendar, TextView> entry : dayCells.entrySet()) {
+ Calendar day = entry.getKey();
+ TextView text = entry.getValue();
+ @ColorInt int dayColor;
+ try {
+ dayColor = dayColors.get(day.get(Calendar.DAY_OF_MONTH));
+ } catch (NullPointerException e) {
+ continue;
+ }
+ final long timestamp = day.getTimeInMillis();
+ // Draw colored circle
+ GradientDrawable backgroundDrawable = new GradientDrawable();
+ backgroundDrawable.setShape(GradientDrawable.OVAL);
+ backgroundDrawable.setColor(dayColor);
+ if (DateTimeUtils.isSameDay(day, currentDay)) {
+ GradientDrawable borderDrawable = new GradientDrawable();
+ borderDrawable.setShape(GradientDrawable.OVAL);
+ borderDrawable.setColor(Color.TRANSPARENT);
+ borderDrawable.setStroke(5, GBApplication.getTextColor(getApplicationContext()));
+ LayerDrawable layerDrawable = new LayerDrawable(new Drawable[]{backgroundDrawable, borderDrawable});
+ text.setBackground(layerDrawable);
+ } else {
+ text.setBackground(backgroundDrawable);
+ }
+ text.setOnClickListener(v -> {
+ Intent resultIntent = new Intent();
+ resultIntent.putExtra(EXTRA_TIMESTAMP, timestamp);
+ setResult(RESULT_OK, resultIntent);
+ finish();
+ });
+ }
+
+ // Draw visual representation of this month's day goals
+ drawMonthGoalsLine();
+
+ // Fill legend
+ Resources res = getResources();
+ SpannableString line_100 = new SpannableString("鈻� 100%: " + res.getQuantityString(R.plurals.amount_of_days, amount_100, amount_100));
+ line_100.setSpan(new ForegroundColorSpan(color_100), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString line_75_100 = new SpannableString("鈻� 75-100%: " + res.getQuantityString(R.plurals.amount_of_days, amount_75_100, amount_75_100));
+ line_75_100.setSpan(new ForegroundColorSpan(color_75_100), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString line_50_75 = new SpannableString("鈻� 50-75%: " + res.getQuantityString(R.plurals.amount_of_days, amount_50_75, amount_50_75));
+ line_50_75.setSpan(new ForegroundColorSpan(color_50_75), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString line_25_50 = new SpannableString("鈻� 25-50%: " + res.getQuantityString(R.plurals.amount_of_days, amount_25_50, amount_25_50));
+ line_25_50.setSpan(new ForegroundColorSpan(color_25_50), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString line_0_25 = new SpannableString("鈻� 0-25%: " + res.getQuantityString(R.plurals.amount_of_days, amount_0_25, amount_0_25));
+ line_0_25.setSpan(new ForegroundColorSpan(color_0_25), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableStringBuilder builder = new SpannableStringBuilder();
+ monthGoalsText.setText(builder.append(line_100).append("\n").append(line_75_100).append("\n").append(line_50_75).append("\n").append(line_25_50).append("\n").append(line_0_25));
+ }
+
+ private void drawMonthGoalsLine() {
+ int monthMaxDays = cal.getActualMaximum(Calendar.DAY_OF_MONTH);
+ int amountOfDays = amount_0_25 + amount_25_50 + amount_50_75 + amount_75_100 + amount_100;
+ int width = 700;
+ int height = 40;
+ int totalDrawWidth = width - height / 2;
+ float drawWidth = totalDrawWidth * ((float) amountOfDays / monthMaxDays);
+
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeCap(Paint.Cap.ROUND);
+ paint.setStrokeWidth(height);
+ paint.setColor(color_unknown);
+ canvas.drawLine(height / 2, height / 2, totalDrawWidth, height / 2, paint);
+
+ // 0-25%
+ if (amount_0_25 > 0) {
+ paint.setColor(color_0_25);
+ canvas.drawLine(height / 2, height / 2, drawWidth, height / 2, paint);
+ }
+
+ // 25-50%
+ if (amount_25_50 > 0) {
+ paint.setColor(color_25_50);
+ float barDays = amount_25_50 + amount_50_75 + amount_75_100 + amount_100;
+ float barFraction = barDays / amountOfDays;
+ canvas.drawLine(height / 2, height / 2, drawWidth * barFraction, height / 2, paint);
+ }
+
+ // 50-75%
+ if (amount_50_75 > 0) {
+ paint.setColor(color_50_75);
+ float barDays = amount_50_75 + amount_75_100 + amount_100;
+ float barFraction = barDays / amountOfDays;
+ canvas.drawLine(height / 2, height / 2, drawWidth * barFraction, height / 2, paint);
+ }
+
+ // 75-100%
+ if (amount_75_100 > 0) {
+ paint.setColor(color_75_100);
+ float barDays = amount_75_100 + amount_100;
+ float barFraction = barDays / amountOfDays;
+ canvas.drawLine(height / 2, height / 2, drawWidth * barFraction, height / 2, paint);
+ }
+
+ // 100%
+ if (amount_100 > 0) {
+ paint.setColor(color_100);
+ float barDays = amount_100;
+ float barFraction = barDays / amountOfDays;
+ canvas.drawLine(height / 2, height / 2, drawWidth * barFraction, height / 2, paint);
+ }
+
+ monthGoalsChart.setImageBitmap(bitmap);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCaloriesActiveGoalWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCaloriesActiveGoalWidget.java
new file mode 100644
index 0000000..3adce8e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCaloriesActiveGoalWidget.java
@@ -0,0 +1,58 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import androidx.core.content.ContextCompat;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.CaloriesDailyFragment;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+/**
+ * A simple {@link AbstractDashboardWidget} subclass.
+ * Use the {@link DashboardCaloriesActiveGoalWidget#newInstance} factory method to
+ * create an instance of this fragment.
+ */
+public class DashboardCaloriesActiveGoalWidget extends AbstractGaugeWidget {
+ public DashboardCaloriesActiveGoalWidget() {
+ super(R.string.active_calories, "calories", CaloriesDailyFragment.GaugeViewMode.ACTIVE_CALORIES_GOAL.toString());
+ }
+
+ /**
+ * Use this factory method to create a new instance of
+ * this fragment using the provided parameters.
+ *
+ * @param dashboardData An instance of DashboardFragment.DashboardData.
+ * @return A new instance of fragment DashboardStepsWidget.
+ */
+ public static DashboardCaloriesActiveGoalWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardCaloriesActiveGoalWidget fragment = new DashboardCaloriesActiveGoalWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsActiveCalories();
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ dashboardData.getActiveCaloriesTotal();
+ dashboardData.getActiveCaloriesGoalFactor();
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ setText(String.valueOf(dashboardData.getActiveCaloriesTotal()));
+ final int colorCalories = ContextCompat.getColor(GBApplication.getContext(), R.color.calories_color);
+ drawSimpleGauge(
+ colorCalories,
+ dashboardData.getActiveCaloriesGoalFactor()
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCaloriesTotalSegmentedWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCaloriesTotalSegmentedWidget.java
new file mode 100644
index 0000000..5df4159
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCaloriesTotalSegmentedWidget.java
@@ -0,0 +1,83 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.graphics.Color;
+import android.os.Bundle;
+
+import androidx.core.content.ContextCompat;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.CaloriesDailyFragment;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+/**
+ * A simple {@link AbstractDashboardWidget} subclass.
+ * Use the {@link DashboardCaloriesTotalSegmentedWidget#newInstance} factory method to
+ * create an instance of this fragment.
+ */
+public class DashboardCaloriesTotalSegmentedWidget extends AbstractGaugeWidget {
+ public DashboardCaloriesTotalSegmentedWidget() {
+ super(R.string.calories, "calories", CaloriesDailyFragment.GaugeViewMode.TOTAL_CALORIES_SEGMENT.toString());
+ }
+
+ /**
+ * Use this factory method to create a new instance of
+ * this fragment using the provided parameters.
+ *
+ * @param dashboardData An instance of DashboardFragment.DashboardData.
+ * @return A new instance of fragment DashboardStepsWidget.
+ */
+ public static DashboardCaloriesTotalSegmentedWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardCaloriesTotalSegmentedWidget fragment = new DashboardCaloriesTotalSegmentedWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsActiveCalories();
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ dashboardData.getActiveCaloriesTotal();
+ dashboardData.getRestingCaloriesTotal();
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ int activeCalories = dashboardData.getActiveCaloriesTotal();
+ int restingCalories = dashboardData.getRestingCaloriesTotal();
+ int totalCalories = activeCalories + restingCalories;
+ setText(String.valueOf(totalCalories));
+ final int[] colors;
+ final float[] segments;
+ if (totalCalories != 0) {
+ colors = new int[] {
+ ContextCompat.getColor(GBApplication.getContext(), R.color.calories_resting_color),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.calories_color)
+ };
+ segments = new float[] {
+ restingCalories > 0 ? (float) restingCalories / totalCalories : 0,
+ activeCalories > 0 ? (float) activeCalories / totalCalories : 0
+ };
+ } else {
+ colors = new int[]{
+ Color.argb(25, 128, 128, 128)
+ };
+ segments = new float[] {
+ 1f
+ };
+ }
+ drawSegmentedGauge(
+ colors,
+ segments,
+ -1,
+ false,
+ false
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardDistanceWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardDistanceWidget.java
new file mode 100644
index 0000000..0a32cbb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardDistanceWidget.java
@@ -0,0 +1,64 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.util.FormatUtils;
+
+/**
+ * A simple {@link AbstractDashboardWidget} subclass.
+ * Use the {@link DashboardDistanceWidget#newInstance} factory method to
+ * create an instance of this fragment.
+ */
+public class DashboardDistanceWidget extends AbstractGaugeWidget {
+ public DashboardDistanceWidget() {
+ super(R.string.distance, "stepsweek");
+ }
+
+ /**
+ * Use this factory method to create a new instance of
+ * this fragment using the provided parameters.
+ *
+ * @param dashboardData An instance of DashboardFragment.DashboardData.
+ * @return A new instance of fragment DashboardDistanceWidget.
+ */
+ public static DashboardDistanceWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardDistanceWidget fragment = new DashboardDistanceWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ dashboardData.getDistanceTotal();
+ dashboardData.getDistanceGoalFactor();
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ setText(FormatUtils.getFormattedDistanceLabel(dashboardData.getDistanceTotal()));
+ drawSimpleGauge(
+ color_distance,
+ dashboardData.getDistanceGoalFactor()
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardGoalsWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardGoalsWidget.java
new file mode 100644
index 0000000..1b857e2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardGoalsWidget.java
@@ -0,0 +1,177 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.style.ForegroundColorSpan;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+/**
+ * A simple {@link AbstractDashboardWidget} subclass.
+ * Use the {@link DashboardGoalsWidget#newInstance} factory method to
+ * create an instance of this fragment.
+ */
+public class DashboardGoalsWidget extends AbstractDashboardWidget {
+ private static final Logger LOG = LoggerFactory.getLogger(DashboardGoalsWidget.class);
+ private View goalsView;
+ private ImageView goalsChart;
+
+ public DashboardGoalsWidget() {
+ // Required empty public constructor
+ }
+
+ /**
+ * Use this factory method to create a new instance of
+ * this fragment using the provided parameters.
+ *
+ * @param dashboardData An instance of DashboardFragment.DashboardData.
+ * @return A new instance of fragment DashboardGoalsWidget.
+ */
+ public static DashboardGoalsWidget newInstance(DashboardFragment.DashboardData dashboardData) {
+ DashboardGoalsWidget fragment = new DashboardGoalsWidget();
+ Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ goalsView = inflater.inflate(R.layout.dashboard_widget_goals, container, false);
+ goalsChart = goalsView.findViewById(R.id.dashboard_goals_chart);
+
+ // Initialize legend
+ TextView legend = goalsView.findViewById(R.id.dashboard_goals_legend);
+ SpannableString l_steps = new SpannableString("鈻� " + getString(R.string.steps));
+ l_steps.setSpan(new ForegroundColorSpan(color_activity), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString l_distance = new SpannableString("鈻� " + getString(R.string.distance));
+ l_distance.setSpan(new ForegroundColorSpan(color_distance), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString l_active_time = new SpannableString("鈻� " + getString(R.string.activity_list_summary_active_time));
+ l_active_time.setSpan(new ForegroundColorSpan(color_active_time), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString l_sleep = new SpannableString("鈻� " + getString(R.string.menuitem_sleep));
+ l_sleep.setSpan(new ForegroundColorSpan(color_light_sleep), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableStringBuilder legendBuilder = new SpannableStringBuilder();
+ legend.setText(legendBuilder.append(l_steps).append(" ").append(l_distance).append("\n").append(l_active_time).append(" ").append(l_sleep));
+
+ Prefs prefs = GBApplication.getPrefs();
+ legend.setVisibility(prefs.getBoolean("dashboard_widget_goals_legend", true) ? View.VISIBLE : View.GONE);
+
+ return goalsView;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (goalsChart != null) fillData();
+ }
+
+ @Override
+ protected void fillData() {
+ if (goalsView == null) return;
+ goalsView.post(new Runnable() {
+ @Override
+ public void run() {
+ FillDataAsyncTask myAsyncTask = new FillDataAsyncTask();
+ myAsyncTask.execute();
+ }
+ });
+ }
+
+ private class FillDataAsyncTask extends AsyncTask<Void, Void, Void> {
+ private Bitmap goalsBitmap;
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ final long nanoStart = System.nanoTime();
+
+ int width = Resources.getSystem().getDisplayMetrics().widthPixels;
+ int height = width;
+ int barWidth = Math.round(height * 0.04f);
+ int barMargin = (int) Math.ceil(barWidth / 2f);
+
+ goalsBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(goalsBitmap);
+ Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeCap(Paint.Cap.ROUND);
+
+ paint.setStrokeWidth(barWidth * 0.75f);
+ paint.setColor(color_unknown);
+ canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360, false, paint);
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(color_activity);
+ canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360 * dashboardData.getStepsGoalFactor(), false, paint);
+
+ barMargin += barWidth * 1.5;
+ paint.setStrokeWidth(barWidth * 0.75f);
+ paint.setColor(color_unknown);
+ canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360, false, paint);
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(color_distance);
+ canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360 * dashboardData.getDistanceGoalFactor(), false, paint);
+
+ barMargin += barWidth * 1.5;
+ paint.setStrokeWidth(barWidth * 0.75f);
+ paint.setColor(color_unknown);
+ canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360, false, paint);
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(color_active_time);
+ canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360 * dashboardData.getActiveMinutesGoalFactor(), false, paint);
+
+ barMargin += barWidth * 1.5;
+ paint.setStrokeWidth(barWidth * 0.75f);
+ paint.setColor(color_unknown);
+ canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360, false, paint);
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(color_light_sleep);
+ canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360 * dashboardData.getSleepMinutesGoalFactor(), false, paint);
+
+ final long nanoEnd = System.nanoTime();
+ final long executionTime = (nanoEnd - nanoStart) / 1000000;
+ LOG.debug("fillData for {} took {}ms", DashboardGoalsWidget.this.getClass().getSimpleName(), executionTime);
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void unused) {
+ super.onPostExecute(unused);
+ goalsChart.setImageBitmap(goalsBitmap);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardHrvWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardHrvWidget.java
new file mode 100644
index 0000000..7252a0d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardHrvWidget.java
@@ -0,0 +1,157 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import androidx.core.content.ContextCompat;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Serializable;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
+
+public class DashboardHrvWidget extends AbstractGaugeWidget {
+ private static final Logger LOG = LoggerFactory.getLogger(DashboardHrvWidget.class);
+
+ public DashboardHrvWidget() {
+ super(R.string.hrv, "hrvstatus");
+ }
+
+ public static DashboardHrvWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardHrvWidget fragment = new DashboardHrvWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsHrvMeasurement();
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ final List<GBDevice> devices = getSupportedDevices(dashboardData);
+
+ HrvSummarySample latestSummary = null;
+
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ for (GBDevice dev : devices) {
+ final List<? extends HrvSummarySample> deviceLatestSummaries = dev.getDeviceCoordinator().getHrvSummarySampleProvider(dev, dbHandler.getDaoSession())
+ .getAllSamples(dashboardData.timeFrom * 1000L, dashboardData.timeTo * 1000L);
+
+ if (!deviceLatestSummaries.isEmpty() && (latestSummary == null || latestSummary.getTimestamp() < deviceLatestSummaries.get(deviceLatestSummaries.size() - 1).getTimestamp())) {
+ latestSummary = deviceLatestSummaries.get(deviceLatestSummaries.size() - 1);
+ }
+ }
+
+ final HrvData hrvData = new HrvData();
+
+ if (latestSummary != null) {
+ hrvData.weeklyAverage = latestSummary.getWeeklyAverage() != null ? latestSummary.getWeeklyAverage() : 0;
+ hrvData.lastNightAverage = latestSummary.getLastNightAverage() != null ? latestSummary.getLastNightAverage() : 0;
+ hrvData.lastNight5MinHigh = latestSummary.getLastNight5MinHigh() != null ? latestSummary.getLastNight5MinHigh() : 0;
+ hrvData.baselineLowUpper = latestSummary.getBaselineLowUpper() != null ? latestSummary.getBaselineLowUpper() : 0;
+ hrvData.baselineBalancedLower = latestSummary.getBaselineBalancedLower() != null ? latestSummary.getBaselineBalancedLower() : 0;
+ hrvData.baselineBalancedUpper = latestSummary.getBaselineBalancedUpper() != null ? latestSummary.getBaselineBalancedUpper() : 0;
+
+ dashboardData.put("hrv", hrvData);
+ }
+
+ } catch (final Exception e) {
+ LOG.error("Could not get hrv sample", e);
+ }
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ final int[] colors = getColors();
+ final float[] segments = getSegments();
+ final HrvData hrvData = (HrvData) dashboardData.get("hrv");
+ final float value = hrvData != null ? calculateGaugeValue(hrvData.weeklyAverage, hrvData.baselineLowUpper, hrvData.baselineBalancedLower, hrvData.baselineBalancedUpper) : -1;
+ final String valueText;
+ valueText = value > 0 ? getString(R.string.hrv_status_unit, hrvData.weeklyAverage) : getString(R.string.stats_empty_value);
+ setText(valueText);
+ drawSegmentedGauge(
+ colors,
+ segments,
+ value,
+ false,
+ true
+ );
+ }
+
+ public static int[] getColors() {
+ return new int[]{
+ ContextCompat.getColor(GBApplication.getContext(), R.color.hrv_status_low),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.hrv_status_unbalanced),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.hrv_status_balanced),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.hrv_status_unbalanced),
+ };
+ }
+
+ public static float[] getSegments() {
+ return new float[]{
+ 0.125f, // low
+ 0.125f, // unbalanced
+ 0.5f, // normal
+ 0.25f, // unbalanced
+ };
+ }
+
+ public static float calculateGaugeValue(int weeklyAverage, int baselineLowUpper, int baselineBalancedLower, int baselineBalancedUpper) {
+ final float value;
+ if (weeklyAverage != 0 && baselineLowUpper != 0 && baselineBalancedLower != 0 && baselineBalancedUpper != 0) {
+ if (weeklyAverage <= baselineLowUpper) {
+ value = (float) GaugeDrawer.normalize(weeklyAverage, 0f, baselineLowUpper, 0, 0.124f);
+ } else if (weeklyAverage < baselineBalancedLower) {
+ value = (float) GaugeDrawer.normalize((float) weeklyAverage, baselineLowUpper + 1, baselineBalancedLower - 1, 0.126f, 0.249f);
+ } else if (weeklyAverage <= baselineBalancedUpper) {
+ value = (float) GaugeDrawer.normalize((float) weeklyAverage, baselineBalancedLower, baselineBalancedUpper, 0.251f, 0.749f);
+ } else {
+ value = (float) GaugeDrawer.normalize((float) weeklyAverage, baselineBalancedUpper, 2 * baselineBalancedUpper, 0.751f, 1);
+ }
+ } else {
+ value = -1;
+ }
+ return value;
+ }
+
+ private static class HrvData implements Serializable {
+ private int weeklyAverage;
+ private int lastNightAverage;
+ private int lastNight5MinHigh;
+ private int baselineLowUpper;
+ private int baselineBalancedLower;
+ private int baselineBalancedUpper;
+ private int statusNum;
+
+ public boolean hasBaselines() {
+ return baselineLowUpper != 0 && baselineBalancedLower != 0 && baselineBalancedUpper != 0;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepScoreWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepScoreWidget.java
new file mode 100644
index 0000000..b542224
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepScoreWidget.java
@@ -0,0 +1,105 @@
+/* Copyright (C) 2023-2024 a0z
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Serializable;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.SleepScoreSample;
+
+/**
+ * A simple {@link AbstractDashboardWidget} subclass.
+ * Use the {@link DashboardSleepScoreWidget#newInstance} factory method to
+ * create an instance of this fragment.
+ */
+public class DashboardSleepScoreWidget extends AbstractGaugeWidget {
+ protected static final Logger LOG = LoggerFactory.getLogger(AbstractDashboardVO2MaxWidget.class);
+ public DashboardSleepScoreWidget() {
+ super(R.string.sleep_score, "sleep");
+ }
+
+ /**
+ * Use this factory method to create a new instance of
+ * this fragment using the provided parameters.
+ *
+ * @param dashboardData An instance of DashboardFragment.DashboardData.
+ * @return A new instance of fragment DashboardSleepScoreWidget.
+ */
+ public static DashboardSleepScoreWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardSleepScoreWidget fragment = new DashboardSleepScoreWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ final List<GBDevice> devices = getSupportedDevices(dashboardData);
+ final SleepScoreData data = new SleepScoreData();
+
+ SleepScoreSample sample = null;
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ for (GBDevice dev : devices) {
+ TimeSampleProvider<? extends SleepScoreSample> provider = dev.getDeviceCoordinator().getSleepScoreProvider(dev, dbHandler.getDaoSession());
+ final SleepScoreSample latestSample = provider.getLatestSample(dashboardData.timeTo * 1000L);
+ if (latestSample != null && (sample == null || latestSample.getTimestamp() > sample.getTimestamp())) {
+ sample = latestSample;
+ }
+ }
+
+ if (sample != null) {
+ data.value = sample.getSleepScore();
+ }
+
+ } catch (final Exception e) {
+ LOG.error("Could not get vo2max for today", e);
+ }
+
+ dashboardData.put("sleepscore", data);
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ final SleepScoreData data = (SleepScoreData) dashboardData.get("sleepscore");
+ setText(String.valueOf(data.value));
+ drawSimpleGauge(
+ color_light_sleep,
+ (float) data.value / 100
+ );
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsSleepScore();
+ }
+
+ private static class SleepScoreData implements Serializable {
+ public int value = -1;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepWidget.java
new file mode 100644
index 0000000..41818bd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepWidget.java
@@ -0,0 +1,79 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+/**
+ * A simple {@link AbstractDashboardWidget} subclass.
+ * Use the {@link DashboardSleepWidget#newInstance} factory method to
+ * create an instance of this fragment.
+ */
+public class DashboardSleepWidget extends AbstractGaugeWidget {
+ public DashboardSleepWidget() {
+ super(R.string.menuitem_sleep, "sleep");
+ }
+
+ /**
+ * Use this factory method to create a new instance of
+ * this fragment using the provided parameters.
+ *
+ * @param dashboardData An instance of DashboardFragment.DashboardData.
+ * @return A new instance of fragment DashboardSleepWidget.
+ */
+ public static DashboardSleepWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardSleepWidget fragment = new DashboardSleepWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsSleepMeasurement();
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ dashboardData.getSleepMinutesTotal();
+ dashboardData.getSleepMinutesGoalFactor();
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ final long totalSleepMinutes = dashboardData.getSleepMinutesTotal();
+ final String valueText = String.format(
+ Locale.ROOT,
+ "%d:%02d",
+ (int) Math.floor(totalSleepMinutes / 60f),
+ (int) (totalSleepMinutes % 60f)
+ );
+
+ setText(valueText);
+ drawSimpleGauge(
+ color_light_sleep,
+ dashboardData.getSleepMinutesGoalFactor()
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStepsWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStepsWidget.java
new file mode 100644
index 0000000..d86e1e3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStepsWidget.java
@@ -0,0 +1,63 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+
+/**
+ * A simple {@link AbstractDashboardWidget} subclass.
+ * Use the {@link DashboardStepsWidget#newInstance} factory method to
+ * create an instance of this fragment.
+ */
+public class DashboardStepsWidget extends AbstractGaugeWidget {
+ public DashboardStepsWidget() {
+ super(R.string.steps, "stepsweek");
+ }
+
+ /**
+ * Use this factory method to create a new instance of
+ * this fragment using the provided parameters.
+ *
+ * @param dashboardData An instance of DashboardFragment.DashboardData.
+ * @return A new instance of fragment DashboardStepsWidget.
+ */
+ public static DashboardStepsWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardStepsWidget fragment = new DashboardStepsWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ dashboardData.getStepsTotal();
+ dashboardData.getStepsGoalFactor();
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ setText(String.valueOf(dashboardData.getStepsTotal()));
+ drawSimpleGauge(
+ color_activity,
+ dashboardData.getStepsGoalFactor()
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressBreakdownWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressBreakdownWidget.java
new file mode 100644
index 0000000..af98222
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressBreakdownWidget.java
@@ -0,0 +1,89 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import androidx.core.content.ContextCompat;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.data.DashboardStressData;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class DashboardStressBreakdownWidget extends AbstractGaugeWidget {
+ public DashboardStressBreakdownWidget() {
+ super(R.string.menuitem_stress, "stress");
+ }
+
+ public static DashboardStressBreakdownWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardStressBreakdownWidget fragment = new DashboardStressBreakdownWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsStressMeasurement();
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ dashboardData.computeIfAbsent("stress", () -> DashboardStressData.compute(dashboardData));
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardStressData stressData = (DashboardStressData) dashboardData.get("stress");
+ if (stressData == null) {
+ drawSimpleGauge(0, -1);
+ return;
+ }
+
+ final int[] colors = new int[]{
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_stress_relaxed),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_stress_mild),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_stress_moderate),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_stress_high),
+ };
+
+ final float[] segments = new float[4];
+
+ int sum = 0;
+ for (final int stressTime : stressData.totalTime) {
+ sum += stressTime;
+ }
+ if (sum != 0) {
+ for (int i = 0; i < 4; i++) {
+ segments[i] = stressData.totalTime[i] / (float) sum;
+ }
+ }
+
+ setText(String.valueOf(stressData.value));
+
+ drawSegmentedGauge(
+ colors,
+ segments,
+ -1,
+ false,
+ true
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressSegmentedWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressSegmentedWidget.java
new file mode 100644
index 0000000..7f39697
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressSegmentedWidget.java
@@ -0,0 +1,96 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import androidx.core.content.ContextCompat;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.data.DashboardStressData;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class DashboardStressSegmentedWidget extends AbstractGaugeWidget {
+ public DashboardStressSegmentedWidget() {
+ super(R.string.menuitem_stress, "stress");
+ }
+
+ public static DashboardStressSegmentedWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardStressSegmentedWidget fragment = new DashboardStressSegmentedWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsStressMeasurement();
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ dashboardData.computeIfAbsent("stress", () -> DashboardStressData.compute(dashboardData));
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ final int[] colors = new int[]{
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_stress_relaxed),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_stress_mild),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_stress_moderate),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_stress_high),
+ };
+
+ final float[] segments;
+ final float value;
+ final String valueText;
+
+ final DashboardStressData stressData = (DashboardStressData) dashboardData.get("stress");
+
+ if (stressData != null) {
+ segments = new float[]{
+ (stressData.ranges[1] - stressData.ranges[0]) / 100f,
+ (stressData.ranges[2] - stressData.ranges[1]) / 100f,
+ (stressData.ranges[3] - stressData.ranges[2]) / 100f,
+ 1 - stressData.ranges[2] / 100f,
+ };
+ value = stressData.value / 100f;
+ valueText = String.valueOf(stressData.value);
+ } else {
+ segments = new float[]{
+ 40 / 100f,
+ 20 / 100f,
+ 20 / 100f,
+ 20 / 100f,
+ };
+ value = -1;
+ valueText = GBApplication.getContext().getString(R.string.stats_empty_value);
+ }
+
+ setText(valueText);
+ drawSegmentedGauge(
+ colors,
+ segments,
+ value,
+ false,
+ true
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressSimpleWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressSimpleWidget.java
new file mode 100644
index 0000000..d1f1149
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStressSimpleWidget.java
@@ -0,0 +1,70 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.StressChartFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.data.DashboardStressData;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class DashboardStressSimpleWidget extends AbstractGaugeWidget {
+ public DashboardStressSimpleWidget() {
+ super(R.string.menuitem_stress, "stress");
+ }
+
+ public static DashboardStressSimpleWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardStressSimpleWidget fragment = new DashboardStressSimpleWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsStressMeasurement();
+ }
+
+ @Override
+ protected void populateData(final DashboardFragment.DashboardData dashboardData) {
+ dashboardData.computeIfAbsent("stress", () -> DashboardStressData.compute(dashboardData));
+ }
+
+ @Override
+ protected void draw(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardStressData stressData = (DashboardStressData) dashboardData.get("stress");
+ if (stressData == null) {
+ drawSimpleGauge(0, -1);
+ return;
+ }
+
+ final int color = StressChartFragment.StressType.fromStress(
+ stressData.value,
+ stressData.ranges
+ ).getColor(GBApplication.getContext());
+
+ final float value = stressData.value / 100f;
+ final String valueText = String.valueOf(stressData.value);
+
+ setText(valueText);
+ drawSimpleGauge(color, value);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardTodayWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardTodayWidget.java
new file mode 100644
index 0000000..7e1f58b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardTodayWidget.java
@@ -0,0 +1,600 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.format.DateFormat;
+import android.text.style.ForegroundColorSpan;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.StepAnalysis;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySession;
+import nodomain.freeyourgadget.gadgetbridge.util.DashboardUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+/**
+ * A simple {@link AbstractDashboardWidget} subclass.
+ * Use the {@link DashboardTodayWidget#newInstance} factory method to
+ * create an instance of this fragment.
+ */
+public class DashboardTodayWidget extends AbstractDashboardWidget {
+ private static final Logger LOG = LoggerFactory.getLogger(DashboardTodayWidget.class);
+
+ private View todayView;
+ private ImageView todayChart;
+
+ private boolean mode_24h;
+
+ public DashboardTodayWidget() {
+ // Required empty public constructor
+ }
+
+ /**
+ * Use this factory method to create a new instance of
+ * this fragment using the provided parameters.
+ *
+ * @param dashboardData An instance of DashboardFragment.DashboardData.
+ * @return A new instance of fragment DashboardTodayWidget.
+ */
+ public static DashboardTodayWidget newInstance(DashboardFragment.DashboardData dashboardData) {
+ DashboardTodayWidget fragment = new DashboardTodayWidget();
+ Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ todayView = inflater.inflate(R.layout.dashboard_widget_today, container, false);
+ todayChart = todayView.findViewById(R.id.dashboard_today_chart);
+
+ // Determine whether to draw a single or a double chart. In case 24h mode is selected,
+ // use just the outer chart (chart_12_24) for all data.
+ Prefs prefs = GBApplication.getPrefs();
+ mode_24h = prefs.getBoolean("dashboard_widget_today_24h", false);
+
+ // Initialize legend
+ TextView legend = todayView.findViewById(R.id.dashboard_piechart_legend);
+ SpannableString l_not_worn = new SpannableString("鈻� " + getString(R.string.abstract_chart_fragment_kind_not_worn));
+ l_not_worn.setSpan(new ForegroundColorSpan(color_not_worn), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString l_worn = new SpannableString("鈻� " + getString(R.string.activity_type_worn));
+ l_worn.setSpan(new ForegroundColorSpan(color_worn), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString l_activity = new SpannableString("鈻� " + getString(R.string.activity_type_activity));
+ l_activity.setSpan(new ForegroundColorSpan(color_activity), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString l_exercise = new SpannableString("鈻� " + getString(R.string.activity_type_exercise));
+ l_exercise.setSpan(new ForegroundColorSpan(color_exercise), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString l_deep_sleep = new SpannableString("鈻� " + getString(R.string.activity_type_deep_sleep));
+ l_deep_sleep.setSpan(new ForegroundColorSpan(color_deep_sleep), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString l_light_sleep = new SpannableString("鈻� " + getString(R.string.activity_type_light_sleep));
+ l_light_sleep.setSpan(new ForegroundColorSpan(color_light_sleep), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableString l_rem_sleep = new SpannableString("鈻� " + getString(R.string.activity_type_rem_sleep));
+ l_rem_sleep.setSpan(new ForegroundColorSpan(color_rem_sleep), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ SpannableStringBuilder legendBuilder = new SpannableStringBuilder();
+ legend.setText(legendBuilder.append(l_not_worn).append(" ").append(l_worn).append("\n").append(l_activity).append(" ").append(l_exercise).append("\n").append(l_light_sleep).append(" ").append(l_deep_sleep).append(" ").append(l_rem_sleep));
+
+ legend.setVisibility(prefs.getBoolean("dashboard_widget_today_legend", true) ? View.VISIBLE : View.GONE);
+
+ if (!dashboardData.generalizedActivities.isEmpty()) {
+ draw();
+ }
+
+ return todayView;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (todayChart != null) fillData();
+ }
+
+ private void draw() {
+ Prefs prefs = GBApplication.getPrefs();
+ boolean upsideDown24h = prefs.getBoolean("dashboard_widget_today_24h_upside_down", false);
+ boolean showYesterday = prefs.getBoolean("dashboard_widget_today_show_yesterday", false);
+
+ // Prepare circular chart
+ long currentDayStart = dashboardData.timeTo - 86400;
+ long midDaySecond = currentDayStart + (12 * 60 * 60);
+ int width = Resources.getSystem().getDisplayMetrics().widthPixels;
+ int height = width;
+ int barWidth = Math.round(width * 0.08f);
+ int hourTextSp = Math.round(width * 0.024f);
+ float hourTextPixels = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_SP,
+ hourTextSp,
+ GBApplication.getContext().getResources().getDisplayMetrics()
+ );
+ float outerCircleMargin = mode_24h ? barWidth / 2f : barWidth / 2f + hourTextPixels * 1.3f;
+ float innerCircleMargin = outerCircleMargin + barWidth * 1.3f;
+ float degreeFactor = mode_24h ? 240 : 120;
+ Bitmap todayBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(todayBitmap);
+ Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setStyle(Paint.Style.STROKE);
+
+ // Draw clock stripes
+ float clockMargin = outerCircleMargin + (mode_24h ? barWidth : barWidth * 2.3f);
+ int clockStripesInterval = mode_24h ? 15 : 30;
+ float clockStripesWidth = barWidth / 3f;
+ paint.setStrokeWidth(clockStripesWidth);
+ paint.setColor(color_worn);
+ for (int i = 0; i < 360; i += clockStripesInterval) {
+ canvas.drawArc(clockMargin, clockMargin, width - clockMargin, height - clockMargin, i, 1, false, paint);
+ }
+
+ // Draw hours
+ boolean normalClock = DateFormat.is24HourFormat(GBApplication.getContext());
+ Map<Integer, String> hours = new HashMap<Integer, String>() {
+ {
+ put(0, normalClock ? (mode_24h ? "0" : "12") : "12pm");
+ put(3, "3");
+ put(6, normalClock ? "6" : "6am");
+ put(9, "9");
+ put(12, normalClock ? (mode_24h ? "12" : "0") : "12am");
+ put(15, normalClock ? "15" : "3");
+ put(18, normalClock ? "18" : "6pm");
+ put(21, normalClock ? "21" : "9");
+ }
+ };
+ Paint textPaint = new Paint();
+ textPaint.setAntiAlias(true);
+ textPaint.setColor(color_worn);
+ textPaint.setTextSize(hourTextPixels);
+ textPaint.setTextAlign(Paint.Align.CENTER);
+ Rect textBounds = new Rect();
+ if (mode_24h && upsideDown24h) {
+ textPaint.getTextBounds(hours.get(0), 0, hours.get(0).length(), textBounds);
+ canvas.drawText(hours.get(0), width / 2f, height - (clockMargin + clockStripesWidth), textPaint);
+ 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);
+ } else if (mode_24h) {
+ textPaint.getTextBounds(hours.get(0), 0, hours.get(0).length(), textBounds);
+ canvas.drawText(hours.get(0), width / 2f, clockMargin + clockStripesWidth + textBounds.height(), textPaint);
+ 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);
+ canvas.drawText(hours.get(12), width / 2f, height - (clockMargin + clockStripesWidth), textPaint);
+ textPaint.getTextBounds(hours.get(18), 0, hours.get(18).length(), textBounds);
+ canvas.drawText(hours.get(18), clockMargin + clockStripesWidth + textBounds.width() / 2f, height / 2f + textBounds.height() / 2f, textPaint);
+ } else {
+ textPaint.getTextBounds(hours.get(0), 0, hours.get(0).length(), textBounds);
+ canvas.drawText(hours.get(0), width / 2f, textBounds.height(), textPaint);
+ textPaint.getTextBounds(hours.get(3), 0, hours.get(3).length(), textBounds);
+ canvas.drawText(hours.get(3), width - (clockMargin + clockStripesWidth + textBounds.width()), height / 2f + textBounds.height() / 2f, textPaint);
+ textPaint.getTextBounds(hours.get(6), 0, hours.get(6).length(), textBounds);
+ canvas.drawText(hours.get(6), width / 2f, height - (clockMargin + clockStripesWidth), textPaint);
+ textPaint.getTextBounds(hours.get(9), 0, hours.get(9).length(), textBounds);
+ canvas.drawText(hours.get(9), 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(15), 0, hours.get(15).length(), textBounds);
+ canvas.drawText(hours.get(15), (float) (width - Math.ceil(textBounds.width() / 2f)), height / 2f + textBounds.height() / 2f, textPaint);
+ textPaint.getTextBounds(hours.get(18), 0, hours.get(18).length(), textBounds);
+ canvas.drawText(hours.get(18), width / 2f, height - textBounds.height() / 2f, textPaint);
+ textPaint.setTextAlign(Paint.Align.LEFT);
+ textPaint.getTextBounds(hours.get(21), 0, hours.get(21).length(), textBounds);
+ canvas.drawText(hours.get(21), 1, height / 2f + textBounds.height() / 2f, textPaint);
+ }
+
+ // Draw generalized activities on circular chart
+ long secondIndex = dashboardData.timeFrom;
+ long currentTime = Calendar.getInstance().getTimeInMillis() / 1000;
+ boolean dayIsToday = !(dashboardData.timeTo < currentTime);
+ int startAngle = mode_24h && upsideDown24h ? 90 : 270;
+ synchronized (dashboardData.generalizedActivities) {
+ for (DashboardFragment.DashboardData.GeneralizedActivity activity : dashboardData.generalizedActivities) {
+ // Determine margin
+ float margin = innerCircleMargin;
+ if (mode_24h || activity.timeFrom >= midDaySecond) {
+ margin = outerCircleMargin;
+ }
+ if (!mode_24h && showYesterday && dayIsToday) {
+ if (activity.timeFrom < currentDayStart && activity.timeFrom > midDaySecond - 86400) {
+ margin = outerCircleMargin;
+ }
+ }
+ // Skip activities from before 24h ago (to prevent double drawing the same position)
+ if (showYesterday && dayIsToday && (activity.timeTo < currentTime - 86400)) {
+ continue;
+ }
+ // Draw inactive slices
+ if (!mode_24h && secondIndex < midDaySecond && activity.timeFrom >= midDaySecond) {
+ paint.setStrokeWidth(barWidth / 3f);
+ paint.setColor(color_unknown);
+ 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, startAngle + (secondIndex - dashboardData.timeFrom) / degreeFactor, (activity.timeFrom - secondIndex) / degreeFactor, false, paint);
+ }
+ float start_angle = startAngle + (activity.timeFrom - dashboardData.timeFrom) / degreeFactor;
+ float sweep_angle = (activity.timeTo - activity.timeFrom) / degreeFactor;
+ if (activity.activityKind == ActivityKind.NOT_MEASURED) {
+ paint.setStrokeWidth(barWidth / 3f);
+ paint.setColor(color_worn);
+ if (showYesterday && dayIsToday && activity.timeFrom < currentDayStart) {
+ paint.setAlpha(64);
+ }
+ canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
+ } else if (activity.activityKind == ActivityKind.NOT_WORN) {
+ paint.setStrokeWidth(barWidth / 3f);
+ paint.setColor(color_not_worn);
+ if (showYesterday && dayIsToday && activity.timeFrom < currentDayStart) {
+ paint.setAlpha(64);
+ }
+ canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
+ } else if (activity.activityKind == ActivityKind.LIGHT_SLEEP || activity.activityKind == ActivityKind.SLEEP_ANY) {
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(color_light_sleep);
+ if (showYesterday && dayIsToday && activity.timeFrom < currentDayStart) {
+ paint.setAlpha(64);
+ }
+ canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
+ } else if (activity.activityKind == ActivityKind.REM_SLEEP) {
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(color_rem_sleep);
+ if (showYesterday && dayIsToday && activity.timeFrom < currentDayStart) {
+ paint.setAlpha(64);
+ }
+ canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
+ } else if (activity.activityKind == ActivityKind.DEEP_SLEEP) {
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(color_deep_sleep);
+ if (showYesterday && dayIsToday && activity.timeFrom < currentDayStart) {
+ paint.setAlpha(64);
+ }
+ canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
+ } else if (activity.activityKind == ActivityKind.AWAKE_SLEEP) {
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(color_awake_sleep);
+ if (showYesterday && dayIsToday && activity.timeFrom < currentDayStart) {
+ paint.setAlpha(64);
+ }
+ canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
+ } else if (activity.activityKind == ActivityKind.EXERCISE) {
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(color_exercise);
+ if (showYesterday && dayIsToday && activity.timeFrom < currentDayStart) {
+ paint.setAlpha(64);
+ }
+ canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
+ } else {
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(color_activity);
+ if (showYesterday && dayIsToday && activity.timeFrom < currentDayStart) {
+ paint.setAlpha(64);
+ }
+ canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
+ }
+ secondIndex = activity.timeTo;
+ }
+ }
+ // Draw indicator for current time
+ if (prefs.getBoolean("dashboard_widget_today_time_indicator", false) && currentTime < dashboardData.timeTo) {
+ float margin = (mode_24h || currentTime >= midDaySecond) ? outerCircleMargin : innerCircleMargin;
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(GBApplication.getTextColor(requireContext()));
+ canvas.drawArc(margin, margin, width - margin, height - margin, startAngle + (currentTime - dashboardData.timeFrom) / degreeFactor, 300 / degreeFactor, false, paint);
+ }
+ // Fill remaining time until current time in 12h mode before midday
+ if (!mode_24h && currentTime < midDaySecond) {
+ // Fill inner bar up until current time
+ paint.setStrokeWidth(barWidth / 3f);
+ paint.setColor(color_unknown);
+ 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, startAngle + (currentTime - dashboardData.timeFrom) / degreeFactor, (midDaySecond - currentTime) / 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, 0, 360, false, paint);
+ }
+ // Fill remaining time until current time in 24h mode or in 12h mode after midday
+ if ((mode_24h || currentTime >= midDaySecond) && currentTime < dashboardData.timeTo) {
+ // Fill inner bar up until midday
+ if (!mode_24h && secondIndex < midDaySecond) {
+ paint.setStrokeWidth(barWidth / 3f);
+ paint.setColor(color_unknown);
+ 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, 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, 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, startAngle + (secondIndex - dashboardData.timeFrom) / degreeFactor, (dashboardData.timeTo - secondIndex) / degreeFactor, false, paint);
+ }
+
+ todayChart.setImageBitmap(todayBitmap);
+ }
+
+ protected void fillData() {
+ if (todayView == null) return;
+
+ Prefs prefs = GBApplication.getPrefs();
+ if (prefs.getBoolean("dashboard_widget_today_show_yesterday", false)) {
+ Calendar today = Calendar.getInstance();
+ Calendar dashboardDate = Calendar.getInstance();
+ dashboardDate.setTimeInMillis((dashboardData.timeFrom + 1) * 1000L);
+ if (DateTimeUtils.isSameDay(today, dashboardDate)) {
+ dashboardData.timeFrom -= 86400;
+ }
+ }
+
+ todayView.post(new Runnable() {
+ @Override
+ public void run() {
+ FillDataAsyncTask myAsyncTask = new FillDataAsyncTask();
+ myAsyncTask.execute();
+ }
+ });
+ }
+
+ private class FillDataAsyncTask extends AsyncTask<Void, Void, Void> {
+ private final HashMap<Long, ActivityKind> activityTimestamps = new HashMap<>();
+
+ /**
+ * Add per-second activities to `activityTimestamps`
+ */
+ private void addActivity(long timeFrom, long timeTo, ActivityKind activityKind) {
+ for (long i = timeFrom; i <= timeTo; i++) {
+ // If the current timestamp isn't saved yet, do so immediately
+ if (activityTimestamps.get(i) == null) {
+ activityTimestamps.put(i, activityKind);
+ continue;
+ }
+ // If the current timestamp is already saved, compare the activity kinds and
+ // keep the most 'important' one
+ switch (activityTimestamps.get(i)) {
+ case EXERCISE:
+ break;
+ case ACTIVITY:
+ if (activityKind == ActivityKind.EXERCISE)
+ activityTimestamps.put(i, activityKind);
+ break;
+ case DEEP_SLEEP:
+ if (activityKind == ActivityKind.EXERCISE ||
+ activityKind == ActivityKind.ACTIVITY)
+ activityTimestamps.put(i, activityKind);
+ break;
+ case LIGHT_SLEEP:
+ if (activityKind == ActivityKind.EXERCISE ||
+ activityKind == ActivityKind.ACTIVITY ||
+ activityKind == ActivityKind.DEEP_SLEEP)
+ activityTimestamps.put(i, activityKind);
+ break;
+ case REM_SLEEP:
+ if (activityKind == ActivityKind.EXERCISE ||
+ activityKind == ActivityKind.ACTIVITY ||
+ activityKind == ActivityKind.DEEP_SLEEP ||
+ activityKind == ActivityKind.LIGHT_SLEEP)
+ activityTimestamps.put(i, activityKind);
+ break;
+ case AWAKE_SLEEP:
+ if (activityKind == ActivityKind.EXERCISE ||
+ activityKind == ActivityKind.ACTIVITY ||
+ activityKind == ActivityKind.DEEP_SLEEP ||
+ activityKind == ActivityKind.LIGHT_SLEEP ||
+ activityKind == ActivityKind.REM_SLEEP)
+ activityTimestamps.put(i, activityKind);
+ break;
+ case SLEEP_ANY:
+ case NOT_MEASURED:
+ if (activityKind == ActivityKind.EXERCISE ||
+ activityKind == ActivityKind.ACTIVITY ||
+ activityKind == ActivityKind.DEEP_SLEEP ||
+ activityKind == ActivityKind.LIGHT_SLEEP ||
+ activityKind == ActivityKind.REM_SLEEP ||
+ activityKind == ActivityKind.AWAKE_SLEEP)
+ activityTimestamps.put(i, activityKind);
+ break;
+ default:
+ activityTimestamps.put(i, activityKind);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Add NOT_MEASURED (worn) activities for every successful heart rate measurement
+ */
+ private void calculateWornSessions(List<ActivitySample> samples) {
+ int firstTimestamp = 0;
+ int lastTimestamp = 0;
+
+ for (ActivitySample sample : samples) {
+ if (sample.getHeartRate() < 10 && firstTimestamp == 0) continue;
+ if (firstTimestamp == 0) firstTimestamp = sample.getTimestamp();
+ if (lastTimestamp == 0) lastTimestamp = sample.getTimestamp();
+ if (HeartRateUtils.getInstance().isValidHeartRateValue(sample.getHeartRate())
+ && sample.getTimestamp() > lastTimestamp + dashboardData.hrIntervalSecs
+ && firstTimestamp != lastTimestamp) {
+ LOG.debug("Registered worn session from {} to {}", firstTimestamp, lastTimestamp);
+ addActivity(firstTimestamp, lastTimestamp, ActivityKind.NOT_MEASURED);
+ if (sample.getHeartRate() < 10) {
+ firstTimestamp = 0;
+ lastTimestamp = 0;
+ } else {
+ firstTimestamp = sample.getTimestamp();
+ lastTimestamp = sample.getTimestamp();
+ }
+ continue;
+ }
+ if (HeartRateUtils.getInstance().isValidHeartRateValue(sample.getHeartRate())) {
+ lastTimestamp = sample.getTimestamp();
+ }
+ }
+ if (firstTimestamp != lastTimestamp) {
+ LOG.debug("Registered worn session from {} to {}", firstTimestamp, lastTimestamp);
+ addActivity(firstTimestamp, lastTimestamp, ActivityKind.NOT_MEASURED);
+ }
+ }
+
+ /**
+ * Merge per-second activities from `activityTimestamps` into generalized activity ranges
+ * with minute-based resolution
+ */
+ private void createGeneralizedActivities() {
+ long currentTime = Calendar.getInstance().getTimeInMillis() / 1000;
+ long midDaySecond = dashboardData.timeTo - (12 * 60 * 60);
+ DashboardFragment.DashboardData.GeneralizedActivity previous = null;
+ List<Map.Entry<Long, ActivityKind>> sortedActivityTimestamps = activityTimestamps.entrySet()
+ .stream()
+ .sorted(Map.Entry.comparingByKey())
+ .collect(Collectors.toList());
+ for (Map.Entry<Long, ActivityKind> activity : sortedActivityTimestamps) {
+ long timestamp = activity.getKey();
+ ActivityKind activityKind = activity.getValue();
+ // Start a new merged activity on certain conditions
+ if (previous == null ||
+ previous.activityKind != activityKind ||
+ (!mode_24h && timestamp == midDaySecond) ||
+ (!mode_24h && timestamp == midDaySecond - 86400) ||
+ timestamp == dashboardData.timeTo - 86400 ||
+ timestamp == currentTime - 86400 ||
+ previous.timeTo < timestamp - 60) {
+ previous = new DashboardFragment.DashboardData.GeneralizedActivity(activityKind, timestamp, timestamp);
+ dashboardData.generalizedActivities.add(previous);
+ } else {
+ previous.timeTo = timestamp;
+ }
+ }
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ final long nanoStart = System.nanoTime();
+
+ // Retrieve activity data
+ dashboardData.generalizedActivities.clear();
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
+ List<ActivitySample> allActivitySamples = new ArrayList<>();
+ List<ActivitySession> stepSessions = new ArrayList<>();
+ List<BaseActivitySummary> activitySummaries = null;
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ for (GBDevice dev : devices) {
+ if ((dashboardData.showAllDevices || dashboardData.showDeviceList.contains(dev.getAddress())) && dev.getDeviceCoordinator().supportsActivityTracking()) {
+ List<? extends ActivitySample> activitySamples = DashboardUtils.getAllSamples(dbHandler, dev, dashboardData);
+ allActivitySamples.addAll(activitySamples);
+ StepAnalysis stepAnalysis = new StepAnalysis();
+ stepSessions.addAll(stepAnalysis.calculateStepSessions(activitySamples));
+ }
+ }
+ activitySummaries = DashboardUtils.getWorkoutSamples(dbHandler, dashboardData);
+ } catch (Exception e) {
+ LOG.warn("Could not retrieve activity amounts: ", e);
+ }
+ Collections.sort(allActivitySamples, (lhs, rhs) -> Integer.valueOf(lhs.getTimestamp()).compareTo(rhs.getTimestamp()));
+
+ // Determine worn sessions from heart rate samples
+ calculateWornSessions(allActivitySamples);
+
+ // Integrate various data from multiple devices
+ for (ActivitySample sample : allActivitySamples) {
+ // Handle only TYPE_NOT_WORN and TYPE_SLEEP (including variants) here
+ if (sample.getKind() != ActivityKind.NOT_WORN && (sample.getKind() == ActivityKind.NOT_MEASURED || !ActivityKind.isSleep(sample.getKind())))
+ continue;
+ // Add to day results
+ addActivity(sample.getTimestamp(), sample.getTimestamp() + 60, sample.getKind());
+ }
+ if (activitySummaries != null) {
+ for (BaseActivitySummary baseActivitySummary : activitySummaries) {
+ addActivity(baseActivitySummary.getStartTime().getTime() / 1000, baseActivitySummary.getEndTime().getTime() / 1000, ActivityKind.EXERCISE);
+ }
+ }
+ for (ActivitySession session : stepSessions) {
+ addActivity(session.getStartTime().getTime() / 1000, session.getEndTime().getTime() / 1000, ActivityKind.ACTIVITY);
+ }
+
+ // Merge per-second activities
+ createGeneralizedActivities();
+
+ final long nanoEnd = System.nanoTime();
+ final long executionTime = (nanoEnd - nanoStart) / 1000000;
+ LOG.debug("fillData for {} took {}ms", DashboardTodayWidget.this.getClass().getSimpleName(), executionTime);
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(final Void unused) {
+ super.onPostExecute(unused);
+ try {
+ draw();
+ } catch (final Exception e) {
+ LOG.error("calling draw() failed", e);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxAnyWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxAnyWidget.java
new file mode 100644
index 0000000..2f1c19a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxAnyWidget.java
@@ -0,0 +1,36 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+
+public class DashboardVO2MaxAnyWidget extends AbstractDashboardVO2MaxWidget {
+
+ public DashboardVO2MaxAnyWidget() {
+ super(R.string.menuitem_vo2_max, "vo2max");
+ }
+
+ public static DashboardVO2MaxAnyWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardVO2MaxAnyWidget fragment = new DashboardVO2MaxAnyWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ public Vo2MaxSample.Type getVO2MaxType() {
+ return Vo2MaxSample.Type.ANY;
+ }
+
+ public String getWidgetKey() {
+ return "vo2max";
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsVO2Max();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxCyclingWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxCyclingWidget.java
new file mode 100644
index 0000000..94d45f4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxCyclingWidget.java
@@ -0,0 +1,36 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+
+public class DashboardVO2MaxCyclingWidget extends AbstractDashboardVO2MaxWidget {
+
+ public DashboardVO2MaxCyclingWidget() {
+ super(R.string.vo2max_cycling, "vo2max");
+ }
+
+ public static DashboardVO2MaxCyclingWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardVO2MaxCyclingWidget fragment = new DashboardVO2MaxCyclingWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ public Vo2MaxSample.Type getVO2MaxType() {
+ return Vo2MaxSample.Type.CYCLING;
+ }
+
+ public String getWidgetKey() {
+ return "vo2max_cycling";
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsVO2MaxCycling();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxRunningWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxRunningWidget.java
new file mode 100644
index 0000000..544c0cf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxRunningWidget.java
@@ -0,0 +1,36 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.os.Bundle;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+
+public class DashboardVO2MaxRunningWidget extends AbstractDashboardVO2MaxWidget {
+
+ public DashboardVO2MaxRunningWidget() {
+ super(R.string.vo2max_running, "vo2max");
+ }
+
+ public static DashboardVO2MaxRunningWidget newInstance(final DashboardFragment.DashboardData dashboardData) {
+ final DashboardVO2MaxRunningWidget fragment = new DashboardVO2MaxRunningWidget();
+ final Bundle args = new Bundle();
+ args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ public Vo2MaxSample.Type getVO2MaxType() {
+ return Vo2MaxSample.Type.RUNNING;
+ }
+
+ public String getWidgetKey() {
+ return "vo2max_running";
+ }
+
+ @Override
+ protected boolean isSupportedBy(final GBDevice device) {
+ return device.getDeviceCoordinator().supportsVO2MaxRunning();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxWidgetInterface.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxWidgetInterface.java
new file mode 100644
index 0000000..10ccc1c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardVO2MaxWidgetInterface.java
@@ -0,0 +1,8 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+
+public interface DashboardVO2MaxWidgetInterface {
+ Vo2MaxSample.Type getVO2MaxType();
+ String getWidgetKey();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/GaugeDrawer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/GaugeDrawer.java
new file mode 100644
index 0000000..b35db2f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/GaugeDrawer.java
@@ -0,0 +1,356 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.util.TypedValue;
+import android.widget.ImageView;
+
+import androidx.annotation.ColorInt;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class GaugeDrawer {
+ private static final Logger LOG = LoggerFactory.getLogger(GaugeDrawer.class);
+ protected @ColorInt int color_unknown = Color.argb(25, 128, 128, 128);
+
+ /**
+ * Draw a simple gauge.
+ *
+ * @param color the gauge color
+ * @param value the gauge value. Range: [0, 1]
+ */
+ public void drawSimpleGauge(ImageView gaugeBar, final int color,
+ final float value) {
+
+ final int width = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 150,
+ GBApplication.getContext().getResources().getDisplayMetrics()
+ );
+
+ // Draw gauge
+ gaugeBar.setImageBitmap(drawSimpleGaugeInternal(
+ width,
+ Math.round(width * 0.075f),
+ color,
+ value
+ ));
+ }
+
+ /**
+ * @param width Bitmap width in pixels
+ * @param barWidth Gauge bar width in pixels
+ * @param filledColor Color of the filled part of the gauge
+ * @param filledFactor Factor between 0 and 1 that determines the amount of the gauge that should be filled
+ * @return Bitmap containing the gauge
+ */
+ private Bitmap drawSimpleGaugeInternal(final int width, final int barWidth, @ColorInt final int filledColor, final float filledFactor) {
+ final int height = width / 2;
+ final int barMargin = (int) Math.ceil(barWidth / 2f);
+
+ final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(bitmap);
+ final Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeCap(Paint.Cap.ROUND);
+ paint.setStrokeWidth(barWidth * 0.75f);
+ paint.setColor(color_unknown);
+ canvas.drawArc(barMargin, barMargin, width - barMargin, width - barMargin, 180 + 180 * filledFactor, 180 - 180 * filledFactor, false, paint);
+
+ if (filledFactor >= 0) {
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(filledColor);
+ canvas.drawArc(barMargin, barMargin, width - barMargin, width - barMargin, 180, 180 * filledFactor, false, paint);
+ }
+
+ return bitmap;
+ }
+
+ /**
+ * Draws a segmented gauge.
+ *
+ * @param colors the colors of each segment
+ * @param segments the size of each segment. The sum of all segments should be 1
+ * @param value the gauge value, in range [0, 1], or -1 for no value and only segments
+ * @param fadeOutsideDot whether to fade out colors outside the dot value
+ * @param gapBetweenSegments whether to introduce a small gap between the segments
+ */
+ public void drawSegmentedGauge(ImageView gaugeBar,
+ final int[] colors,
+ final float[] segments,
+ final float value,
+ final boolean fadeOutsideDot,
+ final boolean gapBetweenSegments) {
+ if (colors.length != segments.length) {
+ LOG.error("Colors length {} differs from segments length {}", colors.length, segments.length);
+ return;
+ }
+
+ final int width = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ 150,
+ GBApplication.getContext().getResources().getDisplayMetrics()
+ );
+
+ final int barWidth = Math.round(width * 0.075f);
+
+ final int height = width / 2;
+ final int barMargin = (int) Math.ceil(barWidth / 2f);
+
+ final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(bitmap);
+ final Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeCap(Paint.Cap.BUTT);
+ paint.setStrokeWidth(barWidth);
+
+ final double cornersGapRadians = Math.asin((width * 0.055f) / (double) height);
+ final double cornersGapFactor = cornersGapRadians / Math.PI;
+
+ int dotColor = 0;
+ float angleSum = 0;
+ for (int i = 0; i < segments.length; i++) {
+ if (segments[i] == 0) {
+ continue;
+ }
+
+ paint.setColor(colors[i]);
+ paint.setStrokeWidth(barWidth);
+
+ if (value < 0 || (value >= angleSum && value <= angleSum + segments[i])) {
+ dotColor = colors[i];
+ } else {
+ if (fadeOutsideDot) {
+ paint.setColor(colors[i] - 0xB0000000);
+ } else {
+ paint.setStrokeWidth(barWidth * 0.75f);
+ }
+ }
+
+ float startAngleDegrees = 180 + angleSum * 180;
+ float sweepAngleDegrees = segments[i] * 180;
+
+ if (value >= 0) {
+ // Do not draw to the end if it will be overlapped by the dot
+ if (i == 0 && value <= cornersGapFactor) {
+ startAngleDegrees += (float) Math.toDegrees(cornersGapRadians);
+ sweepAngleDegrees -= (float) Math.toDegrees(cornersGapRadians);
+ } else if (i == segments.length - 1 && value >= 1 - cornersGapFactor) {
+ sweepAngleDegrees -= (float) Math.toDegrees(cornersGapRadians);
+ }
+ }
+
+ if (gapBetweenSegments) {
+ if (i + 1 < segments.length) {
+ sweepAngleDegrees -= 2;
+ }
+ }
+
+ canvas.drawArc(
+ barMargin,
+ barMargin,
+ width - barMargin,
+ width - barMargin,
+ startAngleDegrees,
+ sweepAngleDegrees,
+ false,
+ paint
+ );
+ angleSum += segments[i];
+ }
+
+ if (value >= 0) {
+ // Prevent the dot from going outside the widget in the extremities
+ final float angleRadians = (float) normalize(value, 0, 1, cornersGapRadians, Math.toRadians(180) - cornersGapRadians);
+
+ paint.setColor(Color.TRANSPARENT);
+ paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
+
+ // In the corners the circle is slightly offset, so adjust it slightly
+ final float widthAdjustment = width * 0.04f * (float) normalize(Math.abs(value - 0.5d), 0, 0.5d);
+
+ final float x = ((width - (barWidth / 2f) - widthAdjustment) / 2f) * (float) Math.cos(angleRadians);
+ final float y = (height - (barWidth / 2f)) * (float) Math.sin(angleRadians);
+
+ // Draw hole
+ paint.setStyle(Paint.Style.FILL);
+ canvas.drawCircle((width / 2f) - x, height - y, barMargin * 1.6f, paint);
+
+ // Draw dot
+ paint.setColor(dotColor);
+ paint.setXfermode(null);
+ canvas.drawCircle((width / 2f) - x, height - y, barMargin, paint);
+ }
+
+ gaugeBar.setImageBitmap(bitmap);
+ }
+
+ public static Bitmap drawCircleGaugeSegmented(int width,
+ int barWidth,
+ final int[] colors,
+ final float[] segments,
+ final boolean gapBetweenSegments,
+ String text,
+ String lowerText,
+ Context context) {
+ int TEXT_COLOR = GBApplication.getTextColor(context);
+ int height = width;
+ int barMargin = (int) Math.ceil(barWidth / 2f);
+
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeCap(Paint.Cap.BUTT);
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(context.getResources().getColor(R.color.gauge_line_color));
+ canvas.drawArc(
+ barMargin,
+ barMargin,
+ width - barMargin,
+ width - barMargin,
+ 90,
+ 360,
+ false,
+ paint);
+ paint.setStrokeWidth(barWidth);
+
+ float remainingAngle = 360;
+ float gapDegree = 1f;
+ if (gapBetweenSegments) {
+ int validSegments = segments.length;
+ for (int i = 0; i < segments.length; i++) {
+ if (segments[i] == 0) {
+ validSegments--;
+ }
+ }
+
+ remainingAngle = 360 - (validSegments * gapDegree);
+ }
+
+ float angleSum = 0;
+ for (int i = 0; i < segments.length; i++) {
+ if (segments[i] == 0) {
+ continue;
+ }
+
+ paint.setColor(colors[i]);
+ paint.setStrokeWidth(barWidth);
+
+ float startAngleDegrees = 270 + (angleSum * remainingAngle);
+ float sweepAngleDegrees = segments[i] * remainingAngle;
+
+ canvas.drawArc(
+ barMargin,
+ barMargin,
+ width - barMargin,
+ height - barMargin,
+ startAngleDegrees,
+ sweepAngleDegrees,
+ false,
+ paint
+ );
+ angleSum += segments[i];
+ if (gapBetweenSegments) {
+ angleSum += (gapDegree / 360f);
+ }
+ }
+
+ Paint textPaint = new Paint();
+ textPaint.setColor(TEXT_COLOR);
+ float textPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, width * 0.06f, context.getResources().getDisplayMetrics());
+ textPaint.setTextSize(textPixels);
+ textPaint.setTextAlign(Paint.Align.CENTER);
+ int yPos = (int) ((float) height / 2 - ((textPaint.descent() + textPaint.ascent()) / 2)) ;
+ canvas.drawText(String.valueOf(text), width / 2f, yPos, textPaint);
+ Paint textLowerPaint = new Paint();
+ textLowerPaint.setColor(TEXT_COLOR);
+ textLowerPaint.setTextAlign(Paint.Align.CENTER);
+ float textLowerPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, width * 0.025f, context.getResources().getDisplayMetrics());
+ textLowerPaint.setTextSize(textLowerPixels);
+ int yPosLowerText = (int) ((float) height / 2 - textPaint.ascent()) ;
+ canvas.drawText(String.valueOf(lowerText), width / 2f, yPosLowerText, textLowerPaint);
+
+ return bitmap;
+ }
+
+ public static Bitmap drawCircleGauge(int width,
+ int barWidth,
+ @ColorInt int filledColor,
+ int value,
+ int maxValue,
+ Context context) {
+ int TEXT_COLOR = GBApplication.getTextColor(context);
+ int height = width;
+ int barMargin = (int) Math.ceil(barWidth / 2f);
+ float filledFactor = (float) value / maxValue;
+
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeCap(Paint.Cap.ROUND);
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(context.getResources().getColor(R.color.gauge_line_color));
+ canvas.drawArc(
+ barMargin,
+ barMargin,
+ width - barMargin,
+ width - barMargin,
+ 90,
+ 360,
+ false,
+ paint);
+ paint.setStrokeWidth(barWidth);
+ paint.setColor(filledColor);
+ canvas.drawArc(
+ barMargin,
+ barMargin,
+ width - barMargin,
+ height - barMargin,
+ 270,
+ 360 * filledFactor,
+ false,
+ paint
+ );
+
+ Paint textPaint = new Paint();
+ textPaint.setColor(TEXT_COLOR);
+ float textPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, width * 0.06f, context.getResources().getDisplayMetrics());
+ textPaint.setTextSize(textPixels);
+ textPaint.setTextAlign(Paint.Align.CENTER);
+ int yPos = (int) ((float) height / 2 - ((textPaint.descent() + textPaint.ascent()) / 2)) ;
+ canvas.drawText(String.valueOf(value), width / 2f, yPos, textPaint);
+ Paint textLowerPaint = new Paint();
+ textLowerPaint.setColor(TEXT_COLOR);
+ textLowerPaint.setTextAlign(Paint.Align.CENTER);
+ float textLowerPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, width * 0.025f, context.getResources().getDisplayMetrics());
+ textLowerPaint.setTextSize(textLowerPixels);
+ int yPosLowerText = (int) ((float) height / 2 - textPaint.ascent()) ;
+ canvas.drawText(String.valueOf(maxValue), width / 2f, yPosLowerText, textLowerPaint);
+
+ return bitmap;
+ }
+
+ public static double normalize(final double value, final double min, final double max) {
+ return normalize(value, min, max, 0, 1);
+ }
+
+ public static double normalize(final double value, final double minSource, final double maxSource, final double minTarget, final double maxTarget) {
+ return ((value - minSource) * (maxTarget - minTarget)) / (maxSource - minSource) + minTarget;
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/data/DashboardStressData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/data/DashboardStressData.java
new file mode 100644
index 0000000..3106ae6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/data/DashboardStressData.java
@@ -0,0 +1,85 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.dashboard.data;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Serializable;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.StressChartFragment;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+
+public class DashboardStressData implements Serializable {
+ private static final Logger LOG = LoggerFactory.getLogger(DashboardStressData.class);
+
+ public int value;
+ public int[] ranges;
+ public int[] totalTime;
+
+ public static DashboardStressData compute(final DashboardFragment.DashboardData dashboardData) {
+ final List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
+
+ GBDevice stressDevice = null;
+ double averageStress = -1;
+
+ final int[] totalTime = new int[StressChartFragment.StressType.values().length];
+
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ for (GBDevice dev : devices) {
+ if ((dashboardData.showAllDevices || dashboardData.showDeviceList.contains(dev.getAddress())) && dev.getDeviceCoordinator().supportsStressMeasurement()) {
+ final List<? extends StressSample> samples = dev.getDeviceCoordinator()
+ .getStressSampleProvider(dev, dbHandler.getDaoSession())
+ .getAllSamples(dashboardData.timeFrom * 1000L, dashboardData.timeTo * 1000L);
+
+ if (!samples.isEmpty()) {
+ stressDevice = dev;
+ final int[] stressRanges = dev.getDeviceCoordinator().getStressRanges();
+ averageStress = samples.stream()
+ .mapToInt(StressSample::getStress)
+ .peek(stress -> {
+ final StressChartFragment.StressType stressType = StressChartFragment.StressType.fromStress(stress, stressRanges);
+ if (stressType != StressChartFragment.StressType.UNKNOWN) {
+ totalTime[stressType.ordinal() - 1] += 60;
+ }
+ })
+ .average()
+ .orElse(0);
+ }
+ }
+ }
+ } catch (final Exception e) {
+ LOG.error("Could not compute stress", e);
+ }
+
+ if (stressDevice != null) {
+ final DashboardStressData stressData = new DashboardStressData();
+ stressData.value = (int) Math.round(averageStress);
+ stressData.ranges = stressDevice.getDeviceCoordinator().getStressRanges();
+ stressData.totalTime = totalTime;
+
+ return stressData;
+ }
+
+ return null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsActivity.java
new file mode 100644
index 0000000..49fe217
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsActivity.java
@@ -0,0 +1,45 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities.devicesettings;
+
+import androidx.preference.PreferenceFragmentCompat;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class DeviceSettingsActivity extends AbstractSettingsActivityV2 {
+ public static final String MENU_ENTRY_POINT = "MENU_ENTRY_POINT";
+
+ public enum MENU_ENTRY_POINTS {
+ DEVICE_SETTINGS,
+ AUTH_SETTINGS,
+ APPLICATION_SETTINGS
+ }
+
+ @Override
+ protected String fragmentTag() {
+ return DeviceSpecificSettingsFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ final GBDevice device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ final MENU_ENTRY_POINTS menu_entry = (MENU_ENTRY_POINTS) getIntent().getSerializableExtra(MENU_ENTRY_POINT);
+
+ return DeviceSpecificSettingsFragment.newInstance(device, menu_entry);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java
new file mode 100644
index 0000000..79596e5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java
@@ -0,0 +1,602 @@
+/* Copyright (C) 2019-2024 115ek, akasaka / Genjitsu Labs, Alicia Hormann,
+ Andreas B枚hler, Andreas Shimokawa, Arjan Schrijver, Damien Gaignon, Daniel
+ Dakhno, Daniele Gobbetti, Davis Mosenkovs, foxstidious, Gordon Williams,
+ Jos茅 Rebelo, Lukas, LukasEdl, mamucho, narektor, NekoBox, opavlov, Petr
+ Van臎k, Yoran Vulker, Yukai Li
+
+ 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.activities.devicesettings;
+
+public class DeviceSettingsPreferenceConst {
+ public static final String PREF_HEADER_APPS = "pref_header_apps";
+ public static final String PREF_HEADER_TIME = "pref_header_time";
+ public static final String PREF_HEADER_DISPLAY = "pref_header_display";
+ public static final String PREF_HEADER_HEALTH = "pref_header_health";
+ public static final String PREF_HEADER_WORKOUT = "pref_header_workout";
+ public static final String PREF_HEADER_WORKOUT_DETECTION = "pref_header_workout_detection";
+ public static final String PREF_HEADER_GPS = "pref_header_gps";
+ public static final String PREF_HEADER_AGPS = "pref_header_agps";
+ public static final String PREF_HEADER_WIFI_HOTSPOT_CONFIGURATION = "pref_header_wifi_hotspot_configuration";
+ public static final String PREF_HEADER_WIFI_HOTSPOT_STATUS = "pref_header_wifi_hotspot_status";
+ public static final String PREF_HEADER_FTP_SERVER_STATUS = "pref_header_ftp_server_status";
+ public static final String PREF_HEADER_FTP_SERVER_CONFIGURATION = "pref_header_ftp_server_configuration";
+
+ public static final String PREF_BATTERY_SHOW_IN_NOTIFICATION = "battery_show_in_notification_";
+ public static final String PREF_BATTERY_NOTIFY_LOW_ENABLED = "battery_notify_low_enabled_";
+ public static final String PREF_BATTERY_NOTIFY_LOW_THRESHOLD = "battery_notify_low_threshold_";
+ public static final String PREF_BATTERY_NOTIFY_FULL_ENABLED = "battery_notify_full_enabled_";
+ public static final String PREF_BATTERY_NOTIFY_FULL_THRESHOLD = "battery_notify_full_threshold_";
+
+ public static final String PREF_BATTERY_POLLING_ENABLE = "pref_battery_polling_enabled";
+ public static final String PREF_BATTERY_POLLING_INTERVAL = "pref_battery_polling_interval";
+
+ public static final String PREF_SCREEN_NIGHT_MODE = "pref_screen_night_mode";
+ public static final String PREF_SCREEN_SLEEP_MODE = "pref_screen_sleep_mode";
+ public static final String PREF_SCREEN_LIFT_WRIST = "pref_screen_lift_wrist";
+ public static final String PREF_SCREEN_PASSWORD = "pref_screen_password";
+ public static final String PREF_SCREEN_ALWAYS_ON_DISPLAY = "pref_screen_always_on_display";
+ public static final String PREF_SCREEN_HEARTRATE_MONITORING = "pref_screen_heartrate_monitoring";
+ public static final String PREF_SCREEN_INACTIVITY_EXTENDED = "pref_screen_inactivity_extended";
+ public static final String PREF_SCREEN_SOUND_AND_VIBRATION = "pref_screen_sound_and_vibration";
+ public static final String PREF_SCREEN_DO_NOT_DISTURB = "pref_screen_do_not_disturb";
+ public static final String PREF_SCREEN_OFFLINE_VOICE = "pref_screen_offline_voice";
+ public static final String PREF_SCREEN_WIFI_HOTSPOT = "pref_screen_wifi_hotspot";
+ public static final String PREF_SCREEN_FTP_SERVER = "pref_screen_ftp_server";
+ public static final String PREF_SCREEN_MORNING_UPDATES = "pref_morning_updates";
+
+ public static final String PREF_LANGUAGE = "language";
+ public static final String PREF_LANGUAGE_AUTO = "auto";
+ public static final String PREF_DEVICE_REGION = "device_region";
+ public static final String PREF_DEVICE_NAME = "pref_device_name";
+ public static final String PREF_DATEFORMAT = "dateformat";
+ public static final String PREF_DATEFORMAT_AUTO = "auto";
+ public static final String PREF_DATEFORMAT_DAY_MONTH = "day_month";
+ public static final String PREF_DATEFORMAT_MONTH_DAY = "month_day";
+ public static final String PREF_TIMEFORMAT = "timeformat";
+ public static final String PREF_TIMEFORMAT_24H = "24h";
+ public static final String PREF_TIMEFORMAT_12H = "am/pm";
+ public static final String PREF_TIMEFORMAT_AUTO = "auto";
+ public static final String PREF_WEARLOCATION = "wearlocation";
+ public static final String PREF_WEARDIRECTION = "weardirection";
+ public static final String PREF_WEARMODE = "wearmode";
+ public static final String PREF_VIBRATION_ENABLE = "vibration_enable";
+ public static final String PREF_NOTIFICATION_ENABLE = "notification_enable";
+ public static final String PREF_SCREEN_BRIGHTNESS = "screen_brightness";
+ public static final String PREF_SCREEN_AUTO_BRIGHTNESS = "screen_auto_brightness";
+ public static final String PREF_SCREEN_ORIENTATION = "screen_orientation";
+ public static final String PREF_SCREEN_TIMEOUT = "screen_timeout";
+ public static final String PREF_RESERVER_ALARMS_CALENDAR = "reserve_alarms_calendar";
+ public static final String PREF_RESERVE_REMINDERS_CALENDAR = "reserve_reminders_calendar";
+ public static final String PREF_ALLOW_HIGH_MTU = "allow_high_mtu";
+ public static final String PREF_SYNC_CALENDAR = "sync_calendar";
+ public static final String PREF_CALENDAR_LOOKAHEAD_DAYS = "calendar_lookahead_days";
+ public static final String PREF_TIME_SYNC = "time_sync";
+ public static final String PREF_USE_CUSTOM_DEVICEICON = "use_custom_deviceicon";
+ public static final String PREF_BUTTON_1_FUNCTION_SHORT = "button_1_function_short";
+ public static final String PREF_BUTTON_2_FUNCTION_SHORT = "button_2_function_short";
+ public static final String PREF_BUTTON_3_FUNCTION_SHORT = "button_3_function_short";
+ public static final String PREF_BUTTON_1_FUNCTION_LONG = "button_1_function_long";
+ public static final String PREF_BUTTON_2_FUNCTION_LONG = "button_2_function_long";
+ public static final String PREF_BUTTON_3_FUNCTION_LONG = "button_3_function_long";
+ public static final String PREF_BUTTON_1_FUNCTION_DOUBLE = "button_1_function_double";
+ public static final String PREF_BUTTON_2_FUNCTION_DOUBLE = "button_2_function_double";
+ public static final String PREF_BUTTON_3_FUNCTION_DOUBLE = "button_3_function_double";
+ public static final String PREF_UPPER_BUTTON_LONG_PRESS = "pref_button_action_upper_long";
+ public static final String PREF_LOWER_BUTTON_SHORT_PRESS = "pref_button_action_lower_short";
+ public static final String PREF_VIBRATION_STRENGH_PERCENTAGE = "vibration_strength";
+ public static final String PREF_RELAX_FIRMWARE_CHECKS = "relax_firmware_checks";
+
+ public static final String PREF_DEVICE_GPS_UPDATE = "banglejs_gps_update";
+ public static final String PREF_DEVICE_GPS_UPDATE_INTERVAL = "banglejs_gps_update_interval";
+ public static final String PREF_DEVICE_GPS_USE_NETWORK_ONLY = "banglejs_gps_use_network_only";
+
+ public static final String PREF_DEVICE_INTERNET_ACCESS = "device_internet_access";
+ public static final String PREF_DEVICE_INTENTS = "device_intents";
+
+ public static final String PREF_ACTIVE_NOISE_CANCELLING_TOGGLE = "active_noise_cancelling_toggle";
+ public static final String PREF_WEAR_SENSOR_TOGGLE = "wear_sensor_toggle";
+ public static final String PREF_BANDW_PSERIES_VPT_ENABLED = "bandw_pseries_vpt_enabled";
+ public static final String PREF_BANDW_PSERIES_VPT_LEVEL = "bandw_pseries_vpt_level";
+ public static final String PREF_BANDW_PSERIES_GUI_VPT_LEVEL = "bandw_pseries_gui_vpt_level";
+
+ public static final String PREF_BANGLEJS_TEXT_BITMAP = "banglejs_text_bitmap";
+ public static final String PREF_BANGLEJS_TEXT_BITMAP_SIZE = "banglejs_txt_bitmap_size";
+ public static final String PREF_BANGLEJS_WEBVIEW_URL = "banglejs_webview_url";
+
+ public static final String PREF_DISCONNECT_NOTIFICATION = "disconnect_notification";
+ public static final String PREF_DISCONNECT_NOTIFICATION_START = "disconnect_notification_start";
+ public static final String PREF_DISCONNECT_NOTIFICATION_END = "disconnect_notification_end";
+
+ public static final String PREF_HYBRID_HR_FORCE_WHITE_COLOR = "force_white_color_scheme";
+ public static final String PREF_HYBRID_HR_DRAW_WIDGET_CIRCLES = "widget_draw_circles";
+ public static final String PREF_HYBRID_HR_SAVE_RAW_ACTIVITY_FILES = "save_raw_activity_files";
+ public static final String PREF_HYBRID_HR_DANGEROUS_EXTERNAL_INTENTS = "dangerous_external_intents";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_RUNNING_ENABLED = "activity_recognize_running_enabled";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_RUNNING_ASK_FIRST = "activity_recognize_running_ask_first";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_RUNNING_MINUTES = "activity_recognize_running_minutes";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_BIKING_ENABLED = "activity_recognize_biking_enabled";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_BIKING_ASK_FIRST = "activity_recognize_biking_ask_first";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_BIKING_MINUTES = "activity_recognize_biking_minutes";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_WALKING_ENABLED = "activity_recognize_walking_enabled";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_WALKING_ASK_FIRST = "activity_recognize_walking_ask_first";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_WALKING_MINUTES = "activity_recognize_walking_minutes";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_ROWING_ENABLED = "activity_recognize_rowing_enabled";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_ROWING_ASK_FIRST = "activity_recognize_rowing_ask_first";
+ public static final String PREF_HYBRID_HR_ACTIVITY_RECOGNITION_ROWING_MINUTES = "activity_recognize_rowing_minutes";
+
+ public static final String PREF_ACTIVATE_DISPLAY_ON_LIFT = "activate_display_on_lift_wrist";
+ public static final String PREF_DISPLAY_ON_LIFT_START = "display_on_lift_start";
+ public static final String PREF_DISPLAY_ON_LIFT_END = "display_on_lift_end";
+ public static final String PREF_DISPLAY_ON_LIFT_SENSITIVITY = "display_on_lift_sensitivity";
+
+ public static final String PREF_ALWAYS_ON_DISPLAY_MODE = "always_on_display_mode";
+ public static final String PREF_ALWAYS_ON_DISPLAY_START = "always_on_display_start";
+ public static final String PREF_ALWAYS_ON_DISPLAY_END = "always_on_display_end";
+ public static final String PREF_ALWAYS_ON_DISPLAY_OFF = "off";
+ public static final String PREF_ALWAYS_ON_DISPLAY_AUTOMATIC = "automatic";
+ public static final String PREF_ALWAYS_ON_DISPLAY_ALWAYS = "always";
+ public static final String PREF_ALWAYS_ON_DISPLAY_SCHEDULED = "scheduled";
+ public static final String PREF_ALWAYS_ON_DISPLAY_FOLLOW_WATCHFACE = "always_on_display_follow_watchface";
+ public static final String PREF_ALWAYS_ON_DISPLAY_STYLE = "always_on_display_style";
+
+ public static final String PREF_VOLUME = "volume";
+ public static final String PREF_CROWN_VIBRATION = "crown_vibration";
+ public static final String PREF_ALERT_TONE = "alert_tone";
+ public static final String PREF_COVER_TO_MUTE = "cover_to_mute";
+ public static final String PREF_VIBRATE_FOR_ALERT = "vibrate_for_alert";
+ public static final String PREF_TEXT_TO_SPEECH = "text_to_speech";
+
+ public static final String PREF_OFFLINE_VOICE_RESPOND_TURN_WRIST = "offline_voice_respond_turn_wrist";
+ public static final String PREF_OFFLINE_VOICE_RESPOND_SCREEN_ON = "offline_voice_respond_screen_on";
+ public static final String PREF_OFFLINE_VOICE_RESPONSE_DURING_SCREEN_LIGHTING = "offline_voice_response_during_screen_lighting";
+ public static final String PREF_OFFLINE_VOICE_LANGUAGE = "offline_voice_language";
+
+ public static final String PREF_SLEEP_TIME = "prefs_enable_sleep_time";
+ public static final String PREF_SLEEP_TIME_START = "prefs_sleep_time_start";
+ public static final String PREF_SLEEP_TIME_END = "prefs_sleep_time_end";
+ public static final String PREF_SLEEP_MODE_SCHEDULE_ENABLED = "sleep_mode_schedule_enabled";
+ public static final String PREF_SLEEP_MODE_SCHEDULE_START = "sleep_mode_schedule_start";
+ public static final String PREF_SLEEP_MODE_SCHEDULE_END = "sleep_mode_schedule_end";
+ public static final String PREF_SLEEP_MODE_SLEEP_SCREEN = "pref_sleep_mode_sleep_screen";
+ public static final String PREF_SLEEP_MODE_SMART_ENABLE = "pref_sleep_mode_smart_enable";
+
+ public static final String PREF_LIFTWRIST_NOSHED = "activate_display_on_lift_wrist_noshed";
+ public static final String PREF_DISCONNECTNOTIF_NOSHED = "disconnect_notification_noshed";
+ public static final String PREF_INACTIVITY_KEY = "inactivity_warnings_key";
+ public static final String PREF_INACTIVITY_ENABLE = "inactivity_warnings_enable";
+ public static final String PREF_INACTIVITY_ENABLE_NOSHED = "inactivity_warnings_enable_noshed";
+ public static final String PREF_INACTIVITY_START = "inactivity_warnings_start";
+ public static final String PREF_INACTIVITY_END = "inactivity_warnings_end";
+ public static final String PREF_INACTIVITY_THRESHOLD = "inactivity_warnings_threshold";
+ public static final String PREF_INACTIVITY_THRESHOLD_EXTENDED = "inactivity_warnings_threshold_extended";
+ public static final String PREF_INACTIVITY_MO = "inactivity_warnings_mo";
+ public static final String PREF_INACTIVITY_TU = "inactivity_warnings_tu";
+ public static final String PREF_INACTIVITY_WE = "inactivity_warnings_we";
+ public static final String PREF_INACTIVITY_TH = "inactivity_warnings_th";
+ public static final String PREF_INACTIVITY_FR = "inactivity_warnings_fr";
+ public static final String PREF_INACTIVITY_SA = "inactivity_warnings_sa";
+ public static final String PREF_INACTIVITY_SU = "inactivity_warnings_su";
+ public static final String PREF_INACTIVITY_DND = "inactivity_warnings_dnd";
+ public static final String PREF_INACTIVITY_DND_START = "inactivity_warnings_dnd_start";
+ public static final String PREF_INACTIVITY_DND_END = "inactivity_warnings_dnd_end";
+
+ public static final String PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION = "heartrate_sleep_detection";
+ public static final String PREF_HEARTRATE_MEASUREMENT_INTERVAL = "heartrate_measurement_interval";
+ public static final String PREF_HEARTRATE_ACTIVITY_MONITORING = "heartrate_activity_monitoring";
+ public static final String PREF_HEARTRATE_ALERT_ENABLED = "heartrate_alert_enabled";
+ public static final String PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD = "heartrate_alert_active_high_threshold";
+ public static final String PREF_HEARTRATE_ALERT_HIGH_THRESHOLD = "heartrate_alert_threshold";
+ public static final String PREF_HEARTRATE_ALERT_LOW_THRESHOLD = "heartrate_alert_low_threshold";
+ public static final String PREF_HEARTRATE_STRESS_MONITORING = "heartrate_stress_monitoring";
+ public static final String PREF_HEARTRATE_STRESS_RELAXATION_REMINDER = "heartrate_stress_relaxation_reminder";
+ public static final String PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING = "heartrate_sleep_breathing_quality_monitoring";
+ public static final String PREF_SPO2_ALL_DAY_MONITORING = "spo2_all_day_monitoring_enabled";
+ public static final String PREF_SPO2_LOW_ALERT_THRESHOLD = "spo2_low_alert_threshold";
+ public static final String PREF_HRV_ALL_DAY_MONITORING = "hrv_all_day_monitoring_enabled";
+
+ public static final String PREF_AUTOHEARTRATE_SWITCH = "pref_autoheartrate_switch";
+ public static final String PREF_AUTOHEARTRATE_SLEEP = "pref_autoheartrate_sleep";
+ public static final String PREF_AUTOHEARTRATE_INTERVAL = "pref_autoheartrate_interval";
+ public static final String PREF_AUTOHEARTRATE_START = "pref_autoheartrate_start";
+ public static final String PREF_AUTOHEARTRATE_END = "pref_autoheartrate_end";
+
+ public static final String PREF_POWER_MODE = "power_mode";
+ public static final String PREF_CONNECTION_DURATION = "connection_duration";
+ public static final String PREF_BUTTON_BP_CALIBRATE = "prefs_sensors_button_bp_calibration";
+ public static final String PREF_ALTITUDE_CALIBRATE = "pref_sensors_altitude";
+ public static final String PREF_DO_NOT_DISTURB_NOAUTO = "do_not_disturb_no_auto";
+ public static final String PREF_DO_NOT_DISTURB_NOAUTO_START = "do_not_disturb_no_auto_start";
+ public static final String PREF_DO_NOT_DISTURB_NOAUTO_END = "do_not_disturb_no_auto_end";
+ public static final String PREF_DO_NOT_DISTURB = "do_not_disturb";
+ public static final String PREF_DO_NOT_DISTURB_START = "do_not_disturb_start";
+ public static final String PREF_DO_NOT_DISTURB_END = "do_not_disturb_end";
+ public static final String PREF_DO_NOT_DISTURB_LIFT_WRIST = "do_not_disturb_lift_wrist";
+ public static final String PREF_DO_NOT_DISTURB_NOT_WEAR = "do_not_disturb_not_wear";
+ public static final String PREF_DO_NOT_DISTURB_OFF = "off";
+ public static final String PREF_DO_NOT_DISTURB_AUTOMATIC = "automatic";
+ public static final String PREF_DO_NOT_DISTURB_ALWAYS = "always";
+ public static final String PREF_DO_NOT_DISTURB_SCHEDULED = "scheduled";
+ public static final String PREF_DO_NOT_DISTURB_MO = "pref_do_not_disturb_mo";
+ public static final String PREF_DO_NOT_DISTURB_TU = "pref_do_not_disturb_tu";
+ public static final String PREF_DO_NOT_DISTURB_WE = "pref_do_not_disturb_we";
+ public static final String PREF_DO_NOT_DISTURB_TH = "pref_do_not_disturb_th";
+ public static final String PREF_DO_NOT_DISTURB_FR = "pref_do_not_disturb_fr";
+ public static final String PREF_DO_NOT_DISTURB_SA = "pref_do_not_disturb_sa";
+ public static final String PREF_DO_NOT_DISTURB_SU = "pref_do_not_disturb_su";
+
+ public static final String PREF_CAMERA_REMOTE = "camera_remote";
+
+ public static final String PREF_WORKOUT_START_ON_PHONE = "workout_start_on_phone";
+ public static final String PREF_WORKOUT_SEND_GPS_TO_BAND = "workout_send_gps_to_band";
+ public static final String PREF_WORKOUT_DETECTION_CATEGORIES = "workout_detection_categories";
+ public static final String PREF_WORKOUT_DETECTION_ALERT = "workout_detection_alert";
+ public static final String PREF_WORKOUT_DETECTION_SENSITIVITY = "workout_detection_sensitivity";
+
+ public static final String PREF_GPS_MODE_PRESET = "pref_gps_mode_preset";
+ public static final String PREF_GPS_BAND = "pref_gps_band";
+ public static final String PREF_GPS_COMBINATION = "pref_gps_combination";
+ public static final String PREF_GPS_SATELLITE_SEARCH = "pref_gps_satellite_search";
+ public static final String PREF_AGPS_EXPIRY_REMINDER_ENABLED = "pref_agps_expiry_reminder_enabled";
+ public static final String PREF_AGPS_EXPIRY_REMINDER_TIME = "pref_agps_expiry_reminder_time";
+ public static final String PREF_AGPS_UPDATE_TIME = "pref_agps_update_time";
+ public static final String PREF_AGPS_EXPIRE_TIME = "pref_agps_expire_time";
+ public static final String PREF_AGPS_STATUS = "pref_agps_status";
+
+ public static final String PREF_FIND_PHONE = "prefs_find_phone";
+ public static final String PREF_FIND_PHONE_DURATION = "prefs_find_phone_duration";
+ public static final String PREF_AUTOLIGHT = "autolight";
+
+ public static final String PREF_PREVIEW_MESSAGE_IN_TITLE = "preview_message_in_title";
+
+ public static final String PREF_CASIO_ALERT_CALENDAR = "casio_alert_calendar";
+
+ public static final String PREF_CASIO_ALERT_CALL = "casio_alert_call";
+
+ public static final String PREF_CASIO_ALERT_EMAIL = "casio_alert_email";
+
+ public static final String PREF_CASIO_ALERT_OTHER = "casio_alert_other";
+
+ public static final String PREF_CASIO_ALERT_SMS = "casio_alert_sms";
+ public static final String PREF_LIGHT_DURATION_LONGER = "light_duration_longer";
+ public static final String PREF_AUTOREMOVE_MESSAGE = "autoremove_message";
+ public static final String PREF_SEND_APP_NOTIFICATIONS = "send_app_notifications";
+ public static final String PREF_NOTIFICATION_WAKE_ON_OPEN = "notification_wake_on_open";
+ public static final String PREF_AUTOREMOVE_NOTIFICATIONS = "autoremove_notifications";
+ public static final String PREF_SCREEN_ON_ON_NOTIFICATIONS = "screen_on_on_notifications";
+ public static final String PREF_WORKOUT_KEEP_SCREEN_ON = "workout_keep_screen_on";
+ public static final String PREF_OPERATING_SOUNDS = "operating_sounds";
+ public static final String PREF_KEY_VIBRATION = "key_vibration";
+ public static final String PREF_FAKE_RING_DURATION = "fake_ring_duration";
+
+ public static final String PREF_WORLD_CLOCKS = "pref_world_clocks";
+ public static final String PREF_CONTACTS = "pref_contacts";
+ public static final String PREF_WIDGETS = "pref_widgets";
+
+ public static final String PREF_MUSIC_MANAGEMENT = "pref_music_management";
+
+ public static final String PREF_ANTILOST_ENABLED = "pref_antilost_enabled";
+ public static final String PREF_HYDRATION_SWITCH = "pref_hydration_switch";
+ public static final String PREF_HYDRATION_PERIOD = "pref_hydration_period";
+ public static final String PREF_HYDRATION_DND = "pref_hydration_dnd";
+ public static final String PREF_HYDRATION_DND_START = "pref_hydration_dnd_start";
+ public static final String PREF_HYDRATION_DND_END = "pref_hydration_dnd_end";
+ public static final String PREF_AMPM_ENABLED = "pref_ampm_enabled";
+
+ public static final String PREF_SONYSWR12_LOW_VIBRATION = "vibration_preference";
+ public static final String PREF_SONYSWR12_STAMINA = "stamina_preference";
+ public static final String PREF_SONYSWR12_SMART_INTERVAL = "smart_alarm_interval_preference";
+
+ public static final String PREF_BT_CONNECTED_ADVERTISEMENT = "bt_connected_advertisement";
+ public static final String PREF_TRANSLITERATION_LANGUAGES = "pref_transliteration_languages";
+
+ public static final String PREF_BLUETOOTH_CALLS_PAIR = "bluetooth_calls_pair";
+ public static final String PREF_BLUETOOTH_CALLS_ENABLED = "bluetooth_calls_enabled";
+ public static final String PREF_DISPLAY_CALLER = "display_caller";
+ public static final String PREF_NOTIFICATION_DELAY_CALLS = "notification_delay_calls";
+ public static final String PREF_CALL_REJECT_METHOD = "call_reject_method";
+
+ public static final String WIFI_HOTSPOT_SSID = "wifi_hotspot_ssid";
+ public static final String WIFI_HOTSPOT_PASSWORD = "wifi_hotspot_password";
+ public static final String WIFI_HOTSPOT_START = "wifi_hotspot_start";
+ public static final String WIFI_HOTSPOT_STOP = "wifi_hotspot_stop";
+ public static final String WIFI_HOTSPOT_STATUS = "wifi_hotspot_status";
+
+ public static final String PREF_APP_LOGS_START = "pref_app_logs_start";
+ public static final String PREF_APP_LOGS_STOP = "pref_app_logs_stop";
+
+ public static final String MORNING_UPDATES_ENABLED = "morning_updates_enabled";
+ public static final String MORNING_UPDATES_CATEGORIES_SORTABLE = "morning_updates_categories";
+
+ public static final String SHORTCUT_CARDS_SORTABLE = "shortcut_cards_sortable";
+
+ public static final String PREF_WATCHFACE = "watchface";
+
+ public static final String FTP_SERVER_ROOT_DIR = "ftp_server_root_dir";
+ public static final String FTP_SERVER_ADDRESS = "ftp_server_address";
+ public static final String FTP_SERVER_USERNAME = "ftp_server_username";
+ public static final String FTP_SERVER_START = "ftp_server_start";
+ public static final String FTP_SERVER_STOP = "ftp_server_stop";
+ public static final String FTP_SERVER_STATUS = "ftp_server_status";
+
+ public static final String PREF_NOTHING_EAR1_INEAR = "pref_nothing_inear_detection";
+ public static final String PREF_NOTHING_EAR1_AUDIOMODE = "pref_nothing_audiomode";
+
+
+ public static final String PREF_HUAWEI_FREEBUDS_INEAR = "pref_freebuds_inear_detection";
+ public static final String PREF_HUAWEI_FREEBUDS_AUDIOMODE = "pref_freebuds_audiomode";
+
+ public static final String PREF_GALAXY_BUDS_AMBIENT_MODE = "pref_galaxy_buds_ambient_mode";
+ public static final String PREF_GALAXY_BUDS_AMBIENT_VOICE_FOCUS = "pref_galaxy_buds_ambient_voice_focus";
+ public static final String PREF_GALAXY_BUDS_AMBIENT_VOLUME = "pref_galaxy_buds_ambient_volume";
+ public static final String PREF_GALAXY_BUDS_LOCK_TOUCH = "pref_galaxy_buds_lock_touch";
+ public static final String PREF_GALAXY_BUDS_GAME_MODE = "pref_galaxy_buds_game_mode";
+ public static final String PREF_GALAXY_BUDS_EQUALIZER = "pref_galaxy_buds_equalizer";
+ public static final String PREF_GALAXY_BUDS_EQUALIZER_DOLBY = "pref_galaxy_buds_equalizer_dolby";
+ public static final String PREF_GALAXY_BUDS_EQUALIZER_MODE = "pref_galaxy_buds_equalizer_mode";
+ public static final String PREF_GALAXY_BUDS_TOUCH_LEFT = "pref_galaxy_buds_touch_left";
+ public static final String PREF_GALAXY_BUDS_TOUCH_RIGHT = "pref_galaxy_buds_touch_right";
+ public static final String PREF_GALAXY_BUDS_TOUCH_RIGHT_SWITCH = "pref_galaxy_buds_touch_right_switch";
+ public static final String PREF_GALAXY_BUDS_TOUCH_LEFT_SWITCH = "pref_galaxy_buds_touch_left_switch";
+ public static final String PREF_GALAXY_BUDS_LIVE_ANC = "pref_galaxy_buds_live_anc";
+ public static final String PREF_GALAXY_BUDS_PRESSURE_RELIEF = "pref_galaxy_buds_live_pressure_relief";
+ public static final String PREF_GALAXY_BUDS_AMBIENT_SOUND = "pref_galaxy_buds_ambient_sound";
+ public static final String PREF_GALAXY_BUDS_PRO_NOISE_CONTROL="pref_galaxy_buds_pro_noise_control";
+ public static final String PREF_GALAXY_BUDS_2_NOISE_CONTROL="pref_galaxy_buds_2_noise_control";
+ public static final String PREF_GALAXY_PRO_DOUBLE_TAP_EDGE ="pref_galaxy_pro_double_tap_edge";
+ public static final String PREF_GALAXY_BUDS_PRO_IN_EAR_DETECTION ="pref_galaxy_buds_pro_in_ear_detection";
+ public static final String PREF_GALAXY_BUDS_PRO_VOICE_DETECT ="pref_galaxy_buds_pro_voice_detect";
+ public static final String PREF_GALAXY_BUDS_PRO_VOICE_DETECT_DURATION ="pref_galaxy_buds_pro_voice_detect_duration";
+ public static final String PREF_GALAXY_BUDS_PRO_BALANCE="pref_galaxy_buds_pro_balance";
+ public static final String PREF_GALAXY_BUDS_PRO_READ_NOTIFICATIONS_OUTLOUD ="pref_galaxy_buds_pro_read_notifications_outloud";
+ public static final String PREF_GALAXY_BUDS_AMBIENT_MODE_DURING_CALL ="pref_galaxy_buds_ambient_mode_during_call";
+ public static final String PREF_GALAXY_BUDS_PRO_AMBIENT_VOLUME_RIGHT ="pref_galaxy_buds_pro_ambient_volume_right";
+ public static final String PREF_GALAXY_BUDS_PRO_AMBIENT_VOLUME_LEFT="pref_galaxy_buds_pro_ambient_volume_left";
+ public static final String PREF_GALAXY_BUDS_PRO_AMBIENT_SOUND_TONE ="pref_galaxy_buds_pro_ambient_sound_tone";
+ public static final String PREFS_NOISE_CONTROL_WITH_ONE_EARBUD ="pref_galaxy_buds_noise_controls_with_one_earbud";
+ public static final String PREF_GALAXY_BUDS_PRO_ANC_LEVEL="pref_galaxy_buds_pro_anc_level";
+ public static final String PREFS_GALAXY_BUDS_SEAMLESS_CONNECTION="prefs_galaxy_buds_seamless_connection";
+
+ public static final String PREF_REDMI_BUDS_5_PRO_AMBIENT_SOUND_CONTROL="pref_redmi_buds_5_pro_ambient_sound_control";
+ public static final String PREF_REDMI_BUDS_5_PRO_NOISE_CANCELLING_STRENGTH="pref_redmi_buds_5_pro_noise_cancelling_strength";
+ public static final String PREF_REDMI_BUDS_5_PRO_TRANSPARENCY_STRENGTH="pref_redmi_buds_5_pro_transparency_strength";
+ public static final String PREF_REDMI_BUDS_5_PRO_ADAPTIVE_NOISE_CANCELLING="pref_redmi_buds_5_pro_adaptive_noise_cancelling";
+// public static final String PREF_REDMI_BUDS_5_PRO_PERSONALIZED_NOISE_CANCELLING="pref_redmi_buds_5_pro_personalized_noise_cancelling";
+ public static final String PREF_REDMI_BUDS_5_PRO_CONTROL_SINGLE_TAP_LEFT="pref_redmi_buds_5_pro_control_single_tap_left";
+ public static final String PREF_REDMI_BUDS_5_PRO_CONTROL_SINGLE_TAP_RIGHT="pref_redmi_buds_5_pro_control_single_tap_right";
+ public static final String PREF_REDMI_BUDS_5_PRO_CONTROL_DOUBLE_TAP_LEFT="pref_redmi_buds_5_pro_control_double_tap_left";
+ public static final String PREF_REDMI_BUDS_5_PRO_CONTROL_DOUBLE_TAP_RIGHT="pref_redmi_buds_5_pro_control_double_tap_right";
+ public static final String PREF_REDMI_BUDS_5_PRO_CONTROL_TRIPLE_TAP_LEFT="pref_redmi_buds_5_pro_control_triple_tap_left";
+ public static final String PREF_REDMI_BUDS_5_PRO_CONTROL_TRIPLE_TAP_RIGHT="pref_redmi_buds_5_pro_control_triple_tap_right";
+ public static final String PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_MODE_LEFT ="pref_redmi_buds_5_pro_control_long_tap_mode_left";
+ public static final String PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_MODE_RIGHT ="pref_redmi_buds_5_pro_control_long_tap_mode_right";
+ public static final String PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_SETTINGS_LEFT ="pref_redmi_buds_5_pro_control_long_tap_settings_left";
+ public static final String PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_SETTINGS_RIGHT ="pref_redmi_buds_5_pro_control_long_tap_settings_right";
+ public static final String PREF_REDMI_BUDS_5_PRO_WEARING_DETECTION="pref_redmi_buds_5_pro_wearing_detection";
+ public static final String PREF_REDMI_BUDS_5_PRO_AUTO_REPLY_PHONECALL="pref_redmi_buds_5_pro_auto_reply_phonecall";
+ public static final String PREF_REDMI_BUDS_5_PRO_DOUBLE_CONNECTION="pref_redmi_buds_5_pro_double_connection";
+// public static final String PREF_REDMI_BUDS_5_PRO_SURROUND_SOUND="pref_redmi_buds_5_pro_surround_sound";
+// public static final String PREF_REDMI_BUDS_5_PRO_SURROUND_SOUND_MODE="pref_redmi_buds_5_pro_surround_sound_mode";
+ public static final String PREF_REDMI_BUDS_5_PRO_EQUALIZER_PRESET="pref_redmi_buds_5_pro_equalizer_preset";
+ public static final String PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_62="pref_redmi_buds_5_pro_equalizer_band_62";
+ public static final String PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_125="pref_redmi_buds_5_pro_equalizer_band_125";
+ public static final String PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_250="pref_redmi_buds_5_pro_equalizer_band_250";
+ public static final String PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_500="pref_redmi_buds_5_pro_equalizer_band_500";
+ public static final String PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_1k="pref_redmi_buds_5_pro_equalizer_band_1k";
+ public static final String PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_2k="pref_redmi_buds_5_pro_equalizer_band_2k";
+ public static final String PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_4k="pref_redmi_buds_5_pro_equalizer_band_4k";
+ public static final String PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_8k="pref_redmi_buds_5_pro_equalizer_band_8k";
+ public static final String PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_12k="pref_redmi_buds_5_pro_equalizer_band_12k";
+ public static final String PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_16k="pref_redmi_buds_5_pro_equalizer_band_16k";
+ public static final String PREF_REDMI_BUDS_5_PRO_ADAPTIVE_SOUND="pref_redmi_buds_5_pro_adaptive_sound";
+
+ public static final String PREF_SONY_AUDIO_CODEC = "pref_sony_audio_codec";
+ public static final String PREF_SONY_PROTOCOL_VERSION = "pref_protocol_version";
+ public static final String PREF_SONY_ACTUAL_PROTOCOL_VERSION = "pref_actual_protocol_version";
+ public static final String PREF_SONY_AMBIENT_SOUND_CONTROL = "pref_sony_ambient_sound_control";
+ public static final String PREF_SOUNDCORE_AMBIENT_SOUND_CONTROL = "pref_soundcore_ambient_sound_control";
+ public static final String PREF_SOUNDCORE_ADAPTIVE_NOISE_CANCELLING = "pref_adaptive_noise_cancelling";
+ public static final String PREF_SOUNDCORE_WIND_NOISE_REDUCTION= "pref_soundcore_wind_noise_reduction";
+ public static final String PREF_SOUNDCORE_TRANSPARENCY_VOCAL_MODE = "pref_soundcore_transparency_vocal_mode";
+ public static final String PREF_SOUNDCORE_WEARING_DETECTION = "pref_soundcore_wearing_detection";
+ public static final String PREF_SOUNDCORE_WEARING_TONE = "pref_soundcore_wearing_tone";
+ public static final String PREF_SOUNDCORE_TOUCH_TONE = "pref_soundcore_touch_tone";
+ public static final String PREF_SOUNDCORE_CONTROL_SINGLE_TAP_DISABLED = "pref_soundcore_control_single_tap_disabled";
+ public static final String PREF_SOUNDCORE_CONTROL_DOUBLE_TAP_DISABLED = "pref_soundcore_control_double_tap_disabled";
+ public static final String PREF_SOUNDCORE_CONTROL_TRIPLE_TAP_DISABLED = "pref_soundcore_control_triple_tap_disabled";
+ public static final String PREF_SOUNDCORE_CONTROL_LONG_PRESS_DISABLED = "pref_soundcore_control_long_press_disabled";
+ public static final String PREF_SOUNDCORE_CONTROL_SINGLE_TAP_ACTION_LEFT = "pref_soundcore_control_single_tap_action_left";
+ public static final String PREF_SOUNDCORE_CONTROL_DOUBLE_TAP_ACTION_LEFT = "pref_soundcore_control_double_tap_action_left";
+ public static final String PREF_SOUNDCORE_CONTROL_TRIPLE_TAP_ACTION_LEFT = "pref_soundcore_control_triple_tap_action_left";
+ public static final String PREF_SOUNDCORE_CONTROL_LONG_PRESS_ACTION_LEFT = "pref_soundcore_control_long_press_action_left";
+ public static final String PREF_SOUNDCORE_CONTROL_SINGLE_TAP_ACTION_RIGHT = "pref_soundcore_control_single_tap_action_right";
+ public static final String PREF_SOUNDCORE_CONTROL_DOUBLE_TAP_ACTION_RIGHT = "pref_soundcore_control_double_tap_action_right";
+ public static final String PREF_SOUNDCORE_CONTROL_TRIPLE_TAP_ACTION_RIGHT = "pref_soundcore_control_triple_tap_action_right";
+ public static final String PREF_SOUNDCORE_CONTROL_LONG_PRESS_ACTION_RIGHT = "pref_soundcore_control_long_press_action_right";
+ public static final String PREF_SOUNDCORE_VOICE_PROMPTS = "pref_soundcore_voice_prompts";
+ public static final String PREF_SOUNDCORE_BUTTON_BRIGHTNESS = "pref_soundcore_button_brightness";
+ public static final String PREF_SOUNDCORE_AUTO_POWER_OFF = "pref_soundcore_auto_power_off";
+ public static final String PREF_SOUNDCORE_LDAC_MODE = "pref_soundcore_ldac_mode";
+ public static final String PREF_SOUNDCORE_ADAPTIVE_DIRECTION = "pref_soundcore_adaptive_direction";
+ public static final String PREF_SOUNDCORE_EQUALIZER_PRESET = "pref_soundcore_equalizer_preset";
+ public static final String PREF_SOUNDCORE_EQUALIZER_CUSTOM = "pref_soundcore_equalizer_custom";
+ public static final String PREF_SOUNDCORE_EQUALIZER_DIRECTION = "pref_soundcore_equalizer_direction";
+ public static final String PREF_SOUNDCORE_EQUALIZER_RESET = "pref_soundcore_equalizer_reset";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND1_FREQ = "pref_soundcore_equalizer_band1_freq";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND1_VALUE = "pref_soundcore_equalizer_band1_value";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND2_FREQ = "pref_soundcore_equalizer_band2_freq";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND2_VALUE = "pref_soundcore_equalizer_band2_value";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND3_FREQ = "pref_soundcore_equalizer_band3_freq";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND3_VALUE = "pref_soundcore_equalizer_band3_value";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND4_FREQ = "pref_soundcore_equalizer_band4_freq";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND4_VALUE = "pref_soundcore_equalizer_band4_value";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND5_FREQ = "pref_soundcore_equalizer_band5_freq";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND5_VALUE = "pref_soundcore_equalizer_band5_value";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND6_FREQ = "pref_soundcore_equalizer_band6_freq";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND6_VALUE = "pref_soundcore_equalizer_band6_value";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND7_FREQ = "pref_soundcore_equalizer_band7_freq";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND7_VALUE = "pref_soundcore_equalizer_band7_value";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND8_FREQ = "pref_soundcore_equalizer_band8_freq";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND8_VALUE = "pref_soundcore_equalizer_band8_value";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND9_FREQ = "pref_soundcore_equalizer_band9_freq";
+ public static final String PREF_SOUNDCORE_EQUALIZER_BAND9_VALUE = "pref_soundcore_equalizer_band9_value";
+ public static final String PREF_SONY_AMBIENT_SOUND_CONTROL_BUTTON_MODE = "pref_sony_ambient_sound_control_button_mode";
+ public static final String PREF_SONY_FOCUS_VOICE = "pref_sony_focus_voice";
+ public static final String PREF_SONY_AMBIENT_SOUND_LEVEL = "pref_sony_ambient_sound_level";
+ public static final String PREF_SONY_NOISE_OPTIMIZER_START = "pref_sony_noise_optimizer_start";
+ public static final String PREF_SONY_NOISE_OPTIMIZER_CANCEL = "pref_sony_noise_optimizer_cancel";
+ public static final String PREF_SONY_NOISE_OPTIMIZER_STATUS = "pref_sony_noise_optimizer_status";
+ public static final String PREF_SONY_NOISE_OPTIMIZER_STATE_PRESSURE = "pref_sony_noise_optimizer_state_pressure";
+ public static final String PREF_SONY_SOUND_POSITION = "pref_sony_sound_position";
+ public static final String PREF_SONY_SURROUND_MODE = "pref_sony_surround_mode";
+ public static final String PREF_SONY_EQUALIZER = "pref_sony_equalizer";
+ public static final String PREF_SONY_EQUALIZER_MODE = "pref_sony_equalizer_mode";
+ public static final String PREF_SONY_AUDIO_UPSAMPLING = "pref_sony_audio_upsampling";
+ public static final String PREF_SONY_EQUALIZER_BAND_400 = "pref_sony_equalizer_band_400";
+ public static final String PREF_SONY_EQUALIZER_BAND_1000 = "pref_sony_equalizer_band_1000";
+ public static final String PREF_SONY_EQUALIZER_BAND_2500 = "pref_sony_equalizer_band_2500";
+ public static final String PREF_SONY_EQUALIZER_BAND_6300 = "pref_sony_equalizer_band_6300";
+ public static final String PREF_SONY_EQUALIZER_BAND_16000 = "pref_sony_equalizer_band_16000";
+ public static final String PREF_SONY_EQUALIZER_BASS = "pref_sony_equalizer_bass";
+ public static final String PREF_SONY_TOUCH_SENSOR = "pref_sony_touch_sensor";
+ public static final String PREF_SONY_PAUSE_WHEN_TAKEN_OFF = "pref_sony_pause_when_taken_off";
+ public static final String PREF_SONY_BUTTON_MODE_LEFT = "pref_sony_button_mode_left";
+ public static final String PREF_SONY_BUTTON_MODE_RIGHT = "pref_sony_button_mode_right";
+ public static final String PREF_SONY_QUICK_ACCESS_DOUBLE_TAP = "pref_sony_quick_access_double_tap";
+ public static final String PREF_SONY_QUICK_ACCESS_TRIPLE_TAP = "pref_sony_quick_access_triple_tap";
+ public static final String PREF_SONY_AUTOMATIC_POWER_OFF = "pref_sony_automatic_power_off";
+ public static final String PREF_SONY_NOTIFICATION_VOICE_GUIDE = "pref_sony_notification_voice_guide";
+ public static final String PREF_SONY_SPEAK_TO_CHAT = "pref_sony_speak_to_chat";
+ public static final String PREF_SONY_SPEAK_TO_CHAT_SENSITIVITY = "pref_sony_speak_to_chat_sensitivity";
+ public static final String PREF_SONY_SPEAK_TO_CHAT_FOCUS_ON_VOICE = "pref_sony_speak_to_chat_focus_on_voice";
+ public static final String PREF_SONY_SPEAK_TO_CHAT_TIMEOUT = "pref_sony_speak_to_chat_timeout";
+ public static final String PREF_SONY_CONNECT_TWO_DEVICES = "pref_sony_connect_two_devices";
+ public static final String PREF_SONY_ADAPTIVE_VOLUME_CONTROL = "pref_adaptive_volume_control";
+ public static final String PREF_SONY_WIDE_AREA_TAP = "pref_wide_area_tap";
+
+ public static final String PREF_MOONDROP_EQUALIZER_PRESET = "pref_moondrop_equalizer_preset";
+ public static final String PREF_MOONDROP_TOUCH_PLAY_PAUSE_EARBUD = "pref_moondrop_touch_play_pause_earbud";
+ public static final String PREF_MOONDROP_TOUCH_PLAY_PAUSE_TRIGGER = "pref_moondrop_touch_play_pause_trigger";
+ public static final String PREF_MOONDROP_TOUCH_MEDIA_PREV_EARBUD = "pref_moondrop_touch_media_prev_earbud";
+ public static final String PREF_MOONDROP_TOUCH_MEDIA_PREV_TRIGGER = "pref_moondrop_touch_media_prev_trigger";
+ public static final String PREF_MOONDROP_TOUCH_MEDIA_NEXT_EARBUD = "pref_moondrop_touch_media_next_earbud";
+ public static final String PREF_MOONDROP_TOUCH_MEDIA_NEXT_TRIGGER = "pref_moondrop_touch_media_next_trigger";
+ public static final String PREF_MOONDROP_TOUCH_CALL_PICK_HANG_EARBUD = "pref_moondrop_touch_call_pick_hang_earbud";
+ public static final String PREF_MOONDROP_TOUCH_CALL_PICK_HANG_TRIGGER = "pref_moondrop_touch_call_pick_hang_trigger";
+ public static final String PREF_MOONDROP_TOUCH_CALL_START_EARBUD = "pref_moondrop_touch_call_start_earbud";
+ public static final String PREF_MOONDROP_TOUCH_CALL_START_TRIGGER = "pref_moondrop_touch_call_start_trigger";
+ public static final String PREF_MOONDROP_TOUCH_ASSISTANT_EARBUD = "pref_moondrop_touch_assistant_earbud";
+ public static final String PREF_MOONDROP_TOUCH_ASSISTANT_TRIGGER = "pref_moondrop_touch_assistant_trigger";
+ public static final String PREF_MOONDROP_TOUCH_ANC_MODE_EARBUD = "pref_moondrop_touch_anc_mode_earbud";
+ public static final String PREF_MOONDROP_TOUCH_ANC_MODE_TRIGGER = "pref_moondrop_touch_anc_mode_trigger";
+
+ public static final String PREF_MISCALE_WEIGHT_UNIT = "pref_miscale_weight_unit";
+ public static final String PREF_MISCALE_SMALL_OBJECTS = "pref_miscale_small_objects";
+
+ public static final String PREF_MIJIA_LYWSD_COMFORT_CHARACTERISTIC_LENGTH = "pref_mijia_lywsd_comfort_characteristic_length";
+ public static final String PREF_MIJIA_LYWSD_COMFORT_LEVEL = "pref_mijia_lywsd_comfort_level";
+ public static final String PREF_MIJIA_LYWSD_COMFORT_TEMPERATURE_LOWER = "pref_mijia_lywsd_comfort_temperature_lower";
+ public static final String PREF_MIJIA_LYWSD_COMFORT_TEMPERATURE_UPPER = "pref_mijia_lywsd_comfort_temperature_upper";
+ public static final String PREF_MIJIA_LYWSD_COMFORT_HUMIDITY_LOWER = "pref_mijia_lywsd_comfort_humidity_lower";
+ public static final String PREF_MIJIA_LYWSD_COMFORT_HUMIDITY_UPPER = "pref_mijia_lywsd_comfort_humidity_upper";
+
+ public static final String PREF_QC35_NOISE_CANCELLING_LEVEL = "qc35_noise_cancelling_level";
+
+ public static final String PREFS_ACTIVITY_IN_DEVICE_CARD = "prefs_activity_in_device_card";
+ public static final String PREFS_ACTIVITY_IN_DEVICE_CARD_STEPS = "prefs_activity_in_device_card_steps";
+ public static final String PREFS_ACTIVITY_IN_DEVICE_CARD_SLEEP = "prefs_activity_in_device_card_sleep";
+ public static final String PREFS_ACTIVITY_IN_DEVICE_CARD_DISTANCE = "prefs_activity_in_device_card_distance";
+ public static final String PREFS_DEVICE_CHARTS_TABS = "charts_tabs";
+ public static final String PREFS_PER_APP_NOTIFICATION_SETTINGS = "pref_per_app_notification_settings";
+
+ public static final String PREF_UM25_SHOW_THRESHOLD_NOTIFICATION = "um25_current_threshold_notify";
+ public static final String PREF_UM25_SHOW_THRESHOLD = "um25_current_threshold";
+
+ public static final String PREF_VESC_MINIMUM_VOLTAGE = "vesc_minimum_battery_voltage";
+ public static final String PREF_VESC_MAXIMUM_VOLTAGE = "vesc_maximum_battery_voltage";
+
+ public static final String PREF_IDASEN_SIT_HEIGHT = "idasen_sit_height";
+ public static final String PREF_IDASEN_MID_HEIGHT = "idasen_mid_height";
+ public static final String PREF_IDASEN_STAND_HEIGHT = "idasen_stand_height";
+
+ public static final String PREF_SOUNDS = "sounds";
+ public static final String PREF_AUTH_KEY = "authkey";
+ public static final String PREF_USER_FITNESS_GOAL = "fitness_goal";
+ public static final String PREF_USER_FITNESS_GOAL_NOTIFICATION = "fitness_goal_notification";
+ public static final String PREF_USER_FITNESS_GOAL_SECONDARY = "fitness_goal_secondary";
+ public static final String PREF_VITALITY_SCORE_7_DAY = "pref_vitality_score_7_day";
+ public static final String PREF_VITALITY_SCORE_DAILY = "pref_vitality_score_daily";
+
+ public static final String PREF_PHONE_SILENT_MODE = "phone_silent_mode";
+
+ public static final String PREF_HOURLY_CHIME_ENABLE = "hourly_chime_enable";
+ public static final String PREF_HOURLY_CHIME_START = "hourly_chime_start";
+ public static final String PREF_HOURLY_CHIME_END = "hourly_chime_end";
+
+ public static final String PREF_VOICE_SERVICE_LANGUAGE = "voice_service_language";
+
+ public static final String PREF_TEMPERATURE_SCALE_CF = "temperature_scale_cf";
+
+ public static final String PREF_FAKE_ANDROID_ID = "fake_android_id";
+
+ public static final String PREF_HEARTRATE_AUTOMATIC_ENABLE = "heartrate_automatic_enable";
+ public static final String PREF_SPO_AUTOMATIC_ENABLE = "spo_automatic_enable";
+
+ public static final String PREF_FORCE_OPTIONS = "pref_force_options";
+ public static final String PREF_FORCE_ENABLE_SMART_ALARM = "pref_force_enable_smart_alarm";
+ public static final String PREF_FORCE_ENABLE_WEAR_LOCATION = "pref_force_enable_wear_location";
+ public static final String PREF_FORCE_DND_SUPPORT = "pref_force_dnd_support";
+ public static final String PREF_FORCE_ENABLE_HEARTRATE_SUPPORT = "pref_force_enable_heartrate_support";
+ public static final String PREF_FORCE_ENABLE_SPO2_SUPPORT = "pref_force_enable_spo2_support";
+ public static final String PREF_IGNORE_WAKEUP_STATUS_START = "pref_force_ignore_wakeup_status_start";
+ public static final String PREF_IGNORE_WAKEUP_STATUS_END = "pref_force_ignore_wakeup_status_end";
+
+ public static final String PREF_FEMOMETER_MEASUREMENT_MODE = "femometer_measurement_mode";
+
+ public static final String PREF_PREFIX_NOTIFICATION_WITH_APP = "pref_prefix_notification_with_app";
+ public static final String PREF_DEVICE_ACTION_SELECTION_BROADCAST = "BROADCAST";
+ public static final String PREF_DEVICE_ACTION_FELL_SLEEP_SELECTIONS = "events_forwarding_fellsleep_action_selections";
+ public static final String PREF_DEVICE_ACTION_FELL_SLEEP_BROADCAST = "prefs_events_forwarding_fellsleep_broadcast";
+ public static final String PREF_DEVICE_ACTION_WOKE_UP_SELECTIONS = "events_forwarding_wokeup_action_selections";
+ public static final String PREF_DEVICE_ACTION_WOKE_UP_BROADCAST = "prefs_events_forwarding_wokeup_broadcast";
+ public static final String PREF_DEVICE_ACTION_START_NON_WEAR_SELECTIONS = "events_forwarding_startnonwear_action_selections";
+ public static final String PREF_DEVICE_ACTION_START_NON_WEAR_BROADCAST = "prefs_events_forwarding_startnonwear_broadcast";
+ public static final String PREF_CLAP_HANDS_TO_WAKEUP_DEVICE = "pref_key_clap_hands_to_wakeup_device";
+ public static final String PREF_POWER_SAVING = "pref_key_power_saving";
+ public static final String PREF_FORCE_CONNECTION_TYPE = "pref_force_connection_type";
+
+ 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_SPEAK_NOTIFICATIONS_FOCUS_EXCLUSIVE = "pref_speak_notifications_focus_exclusive";
+
+ public static final String PREF_CYCLING_SENSOR_PERSISTENCE_INTERVAL = "pref_cycling_persistence_interval";
+ public static final String PREF_CYCLING_SENSOR_WHEEL_DIAMETER = "pref_cycling_wheel_diameter";
+
+ public static final String PREFS_KEY_DEVICE_BLE_API_DEVICE_STATE = "prefs_device_ble_api_state";
+ public static final String PREFS_KEY_DEVICE_BLE_API_DEVICE_READ_WRITE = "prefs_device_ble_api_characteristic_read_write";
+ public static final String PREFS_KEY_DEVICE_BLE_API_DEVICE_NOTIFY = "prefs_device_ble_api_characteristic_notify";
+ public static final String PREFS_KEY_DEVICE_BLE_API_PACKAGE = "prefs_device_ble_api_package";
+
+ public static final String PREF_BATTERY_DISCHARGE_INTERVAL1_WATT = "battery_discharge_interval1_watt";
+ public static final String PREF_BATTERY_DISCHARGE_INTERVAL2_WATT = "battery_discharge_interval2_watt";
+ public static final String PREF_BATTERY_DISCHARGE_INTERVAL3_WATT = "battery_discharge_interval3_watt";
+ public static final String PREF_BATTERY_DISCHARGE_INTERVAL4_WATT = "battery_discharge_interval4_watt";
+ public static final String PREF_BATTERY_DISCHARGE_INTERVAL5_WATT = "battery_discharge_interval5_watt";
+ public static final String PREF_BATTERY_DISCHARGE_INTERVALS_SET = "battery_discharge_intervals_set";
+ public static final String PREF_BATTERY_DISCHARGE_MANAUAL = "battery_discharge_manual";
+ public static final String PREF_BATTERY_MINIMUM_CHARGE = "battery_minimum_charge";
+ public static final String PREF_BATTERY_ALLOW_PASS_THOUGH = "battery_allow_pass_though";
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java
new file mode 100644
index 0000000..88dc40e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java
@@ -0,0 +1,212 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings;
+
+import android.text.InputFilter;
+import android.text.InputType;
+import android.text.Spanned;
+
+import androidx.preference.EditTextPreference;
+import androidx.preference.ListPreference;
+import androidx.preference.MultiSelectListPreference;
+import androidx.preference.Preference;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public final class DeviceSettingsUtils {
+ private static final Logger LOG = LoggerFactory.getLogger(DeviceSettingsUtils.class);
+
+ private DeviceSettingsUtils() {
+ // utility class
+ }
+
+ /**
+ * Returns the preference key where to save the list of possible value for a preference, comma-separated.
+ */
+ public static String getPrefPossibleValuesKey(final String key) {
+ return String.format(Locale.ROOT, "%s_possible_values", key);
+ }
+
+ /**
+ * Returns the preference key where to save the list of entry labels for a preference, comma-separated.
+ */
+ public static String getPrefPossibleValueLabelsKey(final String key) {
+ return String.format(Locale.ROOT, "%s_possible_value_labels", key);
+ }
+
+ /**
+ * Returns the preference key where to that a config was reported as supported (boolean).
+ */
+ public static String getPrefKnownConfig(final String key) {
+ return String.format(Locale.ROOT, "%s_is_known", key);
+ }
+
+ /**
+ * Populates a list preference, or hides it if no known supported values are known.
+ */
+ public static void populateOrHideListPreference(final String prefKey,
+ final DeviceSpecificSettingsHandler handler,
+ final Prefs prefs) {
+ final Preference pref = handler.findPreference(prefKey);
+ if (pref == null) {
+ return;
+ }
+
+ // Get the list of possible values for this preference, as reported by the band
+ final List<String> possibleValues = prefs.getList(getPrefPossibleValuesKey(prefKey), null);
+ if (possibleValues == null || possibleValues.isEmpty()) {
+ // The band hasn't reported this setting, so we don't know the possible values.
+ // Hide it
+ pref.setVisible(false);
+
+ return;
+ }
+
+ final CharSequence[] originalEntries;
+ final CharSequence[] originalValues;
+
+ if (pref instanceof ListPreference) {
+ originalEntries = ((ListPreference) pref).getEntries();
+ originalValues = ((ListPreference) pref).getEntryValues();
+ } else if (pref instanceof MultiSelectListPreference) {
+ originalEntries = ((MultiSelectListPreference) pref).getEntries();
+ originalValues = ((MultiSelectListPreference) pref).getEntryValues();
+ } else {
+ LOG.error("Unknown list pref class {}", pref.getClass().getName());
+ return;
+ }
+
+ final Map<CharSequence, CharSequence> entryNames = new HashMap<>();
+ final List<String> knownLabels = prefs.getList(getPrefPossibleValueLabelsKey(prefKey), null);
+ if (knownLabels != null) {
+ // We got some known labels from the watch
+ if (knownLabels.size() != possibleValues.size()) {
+ LOG.warn(
+ "Number of possible values ({}) and labels ({}) for {} differs - this should never happen",
+ possibleValues.size(),
+ knownLabels.size(),
+ prefKey
+ );
+
+ // Abort and hide preference - we can't safely recover from this
+ pref.setVisible(false);
+ return;
+ }
+
+ for (int i = 0; i < knownLabels.size(); i++) {
+ entryNames.put(possibleValues.get(i), knownLabels.get(i));
+ }
+ } else {
+ for (int i = 0; i < originalEntries.length; i++) {
+ entryNames.put(originalValues[i], originalEntries[i]);
+ }
+ }
+
+ final CharSequence[] entries = new CharSequence[possibleValues.size()];
+ final CharSequence[] values = new CharSequence[possibleValues.size()];
+ for (int i = 0; i < possibleValues.size(); i++) {
+ final String possibleValue = possibleValues.get(i);
+ final CharSequence knownLabel = entryNames.get(possibleValue);
+
+ if (knownLabel != null) {
+ entries[i] = knownLabel;
+ } else {
+ entries[i] = handler.getContext().getString(R.string.menuitem_unknown_app, possibleValue);
+ }
+ values[i] = possibleValue;
+ }
+
+ if (pref instanceof ListPreference) {
+ ((ListPreference) pref).setEntries(entries);
+ ((ListPreference) pref).setEntryValues(values);
+ } else if (pref instanceof MultiSelectListPreference) {
+ ((MultiSelectListPreference) pref).setEntries(entries);
+ ((MultiSelectListPreference) pref).setEntryValues(values);
+ }
+ }
+
+ /**
+ * Hides the the prefToHide preference if none of the preferences in the preferences list are
+ * visible.
+ */
+ public static void hidePrefIfNoneVisible(final DeviceSpecificSettingsHandler handler,
+ final String prefToHide,
+ final List<String> subPrefs) {
+ final Preference pref = handler.findPreference(prefToHide);
+ if (pref == null) {
+ return;
+ }
+
+ for (final String subPrefKey : subPrefs) {
+ final Preference subPref = handler.findPreference(subPrefKey);
+ if (subPref == null) {
+ continue;
+ }
+ if (subPref.isVisible()) {
+ // At least one preference is visible
+ return;
+ }
+ }
+
+ // No preference was visible, hide
+ pref.setVisible(false);
+ }
+
+ public static void enforceMinMax(final EditTextPreference pref, final int minValue, final int maxValue) {
+ if (minValue >= maxValue) {
+ LOG.warn("Invalid min/max values for {}: {}/{}", pref.getKey(), minValue, maxValue);
+ return;
+ }
+
+ pref.setOnBindEditTextListener(p -> {
+ p.setInputType(InputType.TYPE_CLASS_NUMBER);
+ p.setFilters(new InputFilter[]{new MinMaxInputFilter(minValue, maxValue)});
+ p.setSelection(p.getText().length());
+ });
+ }
+
+ public static final class MinMaxInputFilter implements InputFilter {
+ private final int min;
+ private final int max;
+
+ public MinMaxInputFilter(final int min, final int max) {
+ this.min = min;
+ this.max = max;
+ }
+
+ @Override
+ public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
+ try {
+ final int input = Integer.parseInt(dest.toString() + source.toString());
+ if (input >= min && input <= max) {
+ return null;
+ }
+ } catch (final NumberFormatException ignored) {
+ }
+ return "";
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java
new file mode 100644
index 0000000..5cd514f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettings.java
@@ -0,0 +1,193 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.XmlRes;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * A class that contains the device-specific settings screens for a device. All the integers in this
+ * class correspond to xml resources for preferences.
+ * <p>
+ * This class contains 2 types of screens:
+ * - Root screens - the ones that are displayed in the first page of the device settings activity. These can be
+ * normal preference screens / preferences, or dummy root screens (see {@link DeviceSpecificSettingsScreen}.
+ * - Sub-screens - a screen that is opened when one of the {@link DeviceSpecificSettingsScreen} is clicked.
+ * <p>
+ * There can be an arbitrary number of nested sub-screens, as long as they are all mapped by key in the
+ * subScreens map.
+ * <p>
+ * See the XiaomiCoordinator and ZeppOsCoordinator for example usages.
+ */
+public class DeviceSpecificSettings implements Parcelable {
+ private final List<Integer> rootScreens = new ArrayList<>();
+ private final Map<String, List<Integer>> subScreens = new LinkedHashMap<>();
+
+ public DeviceSpecificSettings() {
+ }
+
+ public DeviceSpecificSettings(final int[] rootScreens) {
+ for (final int setting : rootScreens) {
+ this.rootScreens.add(setting);
+ }
+ }
+
+ public void addRootScreen(@XmlRes final int screen) {
+ if (!rootScreens.contains(screen)) {
+ rootScreens.add(screen);
+ }
+ }
+
+ public List<Integer> addRootScreen(final DeviceSpecificSettingsScreen screen) {
+ if (!rootScreens.contains(screen.getXml())) {
+ rootScreens.add(screen.getXml());
+ }
+
+ return addSubScreen(screen, screen.getXml());
+ }
+
+ public List<Integer> addRootScreen(final DeviceSpecificSettingsScreen screen, final int... subScreens) {
+ final List<Integer> subScreenScreens = addRootScreen(screen);
+ for (final int subScreen : subScreens) {
+ subScreenScreens.add(subScreen);
+ }
+ return subScreenScreens;
+ }
+
+ public void addRootScreen(final int index, @XmlRes final int screen) {
+ rootScreens.add(index, screen);
+ }
+
+ public List<Integer> addSubScreen(final DeviceSpecificSettingsScreen rootScreen, final int... screens) {
+ return addSubScreen(rootScreen.getKey(), screens);
+ }
+
+ private List<Integer> addSubScreen(final String key, final int... screens) {
+ if (!subScreens.containsKey(key)) {
+ subScreens.put(key, new ArrayList<>());
+ }
+
+ final List<Integer> subscreenPages = Objects.requireNonNull(subScreens.get(key));
+
+ for (final int screen : screens) {
+ if (!subscreenPages.contains(screen)) {
+ subscreenPages.add(screen);
+ }
+ }
+
+ return subscreenPages;
+ }
+
+ public void mergeFrom(final DeviceSpecificSettings deviceSpecificSettings) {
+ for (final Integer rootScreen : deviceSpecificSettings.rootScreens) {
+ addRootScreen(rootScreen);
+ }
+ for (final Map.Entry<String, List<Integer>> e : deviceSpecificSettings.subScreens.entrySet()) {
+ if (!subScreens.containsKey(e.getKey())) {
+ subScreens.put(e.getKey(), new ArrayList<>());
+ }
+
+ for (final int screen : e.getValue()) {
+ Objects.requireNonNull(subScreens.get(e.getKey())).add(screen);
+ }
+ }
+ }
+
+ public List<Integer> getRootScreens() {
+ return rootScreens;
+ }
+
+ @Nullable
+ public List<Integer> getScreen(@NonNull final String key) {
+ return subScreens.get(key);
+ }
+
+ public List<Integer> getAllNonRootScreens() {
+ final List<Integer> allScreens = new ArrayList<>();
+ for (final List<Integer> screens : subScreens.values()) {
+ allScreens.addAll(screens);
+ }
+ allScreens.removeAll(rootScreens);
+ return allScreens;
+ }
+
+ public List<Integer> getAllScreens() {
+ final List<Integer> allScreens = new ArrayList<>(rootScreens);
+ for (final List<Integer> screens : subScreens.values()) {
+ allScreens.addAll(screens);
+ }
+ return allScreens;
+ }
+
+ public static final Creator<DeviceSpecificSettings> CREATOR = new Creator<DeviceSpecificSettings>() {
+ @Override
+ public DeviceSpecificSettings createFromParcel(final Parcel in) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+ final int numRootScreens = in.readInt();
+ for (int i = 0; i < numRootScreens; i++) {
+ deviceSpecificSettings.addRootScreen(in.readInt());
+ }
+ final int numSubScreens = in.readInt();
+ for (int i = 0; i < numSubScreens; i++) {
+ final String key = in.readString();
+ final int numScreens = in.readInt();
+ final int[] screens = new int[numScreens];
+ for (int j = 0; j < numScreens; j++) {
+ screens[j] = in.readInt();
+ }
+ deviceSpecificSettings.addSubScreen(key, screens);
+ }
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public DeviceSpecificSettings[] newArray(final int size) {
+ return new DeviceSpecificSettings[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull final Parcel dest, final int flags) {
+ dest.writeInt(rootScreens.size());
+ for (final Integer rootScreen : rootScreens) {
+ dest.writeInt(rootScreen);
+ }
+ dest.writeInt(subScreens.size());
+ for (final Map.Entry<String, List<Integer>> e : subScreens.entrySet()) {
+ dest.writeString(e.getKey());
+ dest.writeInt(e.getValue().size());
+ for (final Integer s : e.getValue()) {
+ dest.writeInt(s);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsCustomizer.java
new file mode 100644
index 0000000..a64abb0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsCustomizer.java
@@ -0,0 +1,52 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo, Petr Van臎k
+
+ 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.activities.devicesettings;
+
+import android.os.Parcelable;
+
+import androidx.preference.Preference;
+
+import java.util.Set;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+/**
+ * A device-specific preference handler, that allows for concrete implementations to customize the preferences in
+ * the {@link DeviceSpecificSettingsFragment}.
+ */
+public interface DeviceSpecificSettingsCustomizer extends Parcelable {
+ /**
+ * Called when a {@link Preference} changes, not caused by user input (so the preference change listener is not called).
+ *
+ * @param preference the {@link Preference} preference that changed
+ * @param handler the {@link DeviceSpecificSettingsHandler}
+ */
+ void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler);
+
+ /**
+ * Customize the settings on the {@link DeviceSpecificSettingsFragment}.
+ *
+ * @param handler the {@link DeviceSpecificSettingsHandler}
+ * @param prefs the {@link android.content.SharedPreferences}
+ * @param rootKey the root key of the current screen
+ */
+ void customizeSettings(final DeviceSpecificSettingsHandler handler, Prefs prefs, final String rootKey);
+
+ /**
+ * Keys of preferences which should print its values as a summary below the preference name.
+ */
+ Set<String> getPreferenceKeysWithSummary();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java
new file mode 100644
index 0000000..591d1e5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java
@@ -0,0 +1,1472 @@
+/* Copyright (C) 2019-2024 akasaka / Genjitsu Labs, Alicia Hormann, Andreas
+ B枚hler, Andreas Shimokawa, Arjan Schrijver, Cre3per, Damien Gaignon, Daniel
+ Dakhno, Daniele Gobbetti, Davis Mosenkovs, foxstidious, Jos茅 Rebelo, mamucho,
+ NekoBox, opavlov, Petr Van臎k, Yoran Vulker, Yukai Li, Zhong Jianxin
+
+ 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.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;
+import android.os.Bundle;
+import android.text.InputType;
+
+import androidx.annotation.NonNull;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.preference.EditTextPreference;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceScreen;
+import androidx.preference.SwitchPreferenceCompat;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.CalBlacklistActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureContacts;
+import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureWorldClocks;
+import nodomain.freeyourgadget.gadgetbridge.activities.app_specific_notifications.AppSpecificNotificationSettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.musicmanager.MusicManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.widgets.WidgetScreensListActivity;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import nodomain.freeyourgadget.gadgetbridge.util.preferences.GBSimpleSummaryProvider;
+import nodomain.freeyourgadget.gadgetbridge.util.preferences.MinMaxTextWatcher;
+import nodomain.freeyourgadget.gadgetbridge.util.preferences.PreferenceCategoryMultiline;
+
+public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment implements DeviceSpecificSettingsHandler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(DeviceSpecificSettingsFragment.class);
+
+ static final String FRAGMENT_TAG = "DEVICE_SPECIFIC_SETTINGS_FRAGMENT";
+
+ private DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer;
+
+ private GBDevice device;
+
+ private void setSettingsFileSuffix(String settingsFileSuffix) {
+ Bundle args = new Bundle();
+ args.putString("settingsFileSuffix", settingsFileSuffix);
+ setArguments(args);
+ }
+
+ private void setDeviceSpecificSettings(final DeviceSpecificSettings deviceSpecificSettings) {
+ final Bundle args = getArguments() != null ? getArguments() : new Bundle();
+ args.putParcelable("deviceSpecificSettings", deviceSpecificSettings);
+ setArguments(args);
+ }
+
+ private void setDeviceSpecificSettingsCustomizer(final DeviceSpecificSettingsCustomizer customizer) {
+ final Bundle args = getArguments() != null ? getArguments() : new Bundle();
+ args.putParcelable("deviceSpecificSettingsCustomizer", customizer);
+ setArguments(args);
+ }
+
+ private void setDevice(final GBDevice device) {
+ final Bundle args = getArguments() != null ? getArguments() : new Bundle();
+ args.putParcelable("device", device);
+ setArguments(args);
+ }
+
+ @Override
+ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
+ Bundle arguments = getArguments();
+ if (arguments == null) {
+ return;
+ }
+ String settingsFileSuffix = arguments.getString("settingsFileSuffix", null);
+ DeviceSpecificSettings deviceSpecificSettings = arguments.getParcelable("deviceSpecificSettings");
+ this.deviceSpecificSettingsCustomizer = arguments.getParcelable("deviceSpecificSettingsCustomizer");
+ this.device = arguments.getParcelable("device");
+
+ if (settingsFileSuffix == null || deviceSpecificSettings == null) {
+ return;
+ }
+
+ getPreferenceManager().setSharedPreferencesName("devicesettings_" + settingsFileSuffix);
+
+ LOG.debug("onCreatePreferences: {}", rootKey);
+
+ if (rootKey == null) {
+ // we are the main preference screen
+ boolean first = true;
+ for (int setting : deviceSpecificSettings.getRootScreens()) {
+ if (first) {
+ setPreferencesFromResource(setting, null);
+ first = false;
+ } else {
+ addPreferencesFromResource(setting);
+ }
+ }
+ } else {
+ // First attempt to find a known screen for this key
+ final List<Integer> screenSettings = deviceSpecificSettings.getScreen(rootKey);
+ if (screenSettings != null) {
+ boolean first = true;
+ for (int setting : screenSettings) {
+ if (first) {
+ // Use the root key here to set the root screen, so that the actionbar title gets updated
+ setPreferencesFromResource(setting, rootKey);
+ first = false;
+ addDynamicSettings(rootKey);
+ } else {
+ addPreferencesFromResource(setting);
+ }
+ }
+ } else {
+ // Now, this is ugly: search all the xml files for the rootKey
+ // This means that this device is using the deprecated getSupportedDeviceSpecificSettings,
+ // or that we're on a sub-screen
+ final List<Integer> allScreens = deviceSpecificSettings.getAllScreens();
+ for (int setting : allScreens) {
+ try {
+ setPreferencesFromResource(setting, rootKey);
+ } catch (Exception ignore) {
+ continue;
+ }
+ break;
+ }
+ }
+ }
+
+ // Since all root preference screens are empty, clicking them will not do anything
+ // add on-click listeners
+ for (final DeviceSpecificSettingsScreen value : DeviceSpecificSettingsScreen.values()) {
+ final PreferenceScreen prefScreen = findPreference(value.getKey());
+ if (prefScreen != null) {
+ prefScreen.setOnPreferenceClickListener(p -> {
+ onNavigateToScreen(prefScreen);
+ return true;
+ });
+ }
+ }
+
+ setChangeListener(rootKey);
+ }
+
+ private void addDynamicSettings(final String rootKey) {
+ if (rootKey.equals(DeviceSpecificSettingsScreen.BATTERY.getKey())) {
+ addBatterySettings();
+ }
+ }
+
+ private void addBatterySettings() {
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final PreferenceScreen batteryScreen = getPreferenceScreen();
+ if (batteryScreen == null) {
+ return;
+ }
+ final BatteryConfig[] batteryConfigs = coordinator.getBatteryConfig(device);
+ for (final BatteryConfig batteryConfig : batteryConfigs) {
+ if (batteryConfigs.length > 1 || coordinator.addBatteryPollingSettings()) {
+ final Preference prefHeader = new PreferenceCategory(requireContext());
+ prefHeader.setKey("pref_battery_header_" + batteryConfig.getBatteryIndex());
+ prefHeader.setIconSpaceReserved(false);
+ if (batteryConfig.getBatteryLabel() != GBDevice.BATTERY_LABEL_DEFAULT) {
+ prefHeader.setTitle(batteryConfig.getBatteryLabel());
+ } else {
+ prefHeader.setTitle(requireContext().getString(R.string.battery_i, batteryConfig.getBatteryIndex()));
+ }
+ batteryScreen.addPreference(prefHeader);
+ }
+
+ final SwitchPreferenceCompat showInNotification = new SwitchPreferenceCompat(requireContext());
+ showInNotification.setLayoutResource(R.layout.preference_checkbox);
+ showInNotification.setKey(PREF_BATTERY_SHOW_IN_NOTIFICATION + batteryConfig.getBatteryIndex());
+ showInNotification.setTitle(R.string.show_in_notification);
+ showInNotification.setIconSpaceReserved(false);
+ showInNotification.setDefaultValue(true);
+ batteryScreen.addPreference(showInNotification);
+
+ final SwitchPreferenceCompat notifyLowEnabled = new SwitchPreferenceCompat(requireContext());
+ notifyLowEnabled.setLayoutResource(R.layout.preference_checkbox);
+ notifyLowEnabled.setKey(PREF_BATTERY_NOTIFY_LOW_ENABLED + batteryConfig.getBatteryIndex());
+ notifyLowEnabled.setTitle(R.string.battery_low_notify_enabled);
+ notifyLowEnabled.setDefaultValue(true);
+ notifyLowEnabled.setIconSpaceReserved(false);
+ batteryScreen.addPreference(notifyLowEnabled);
+
+ final EditTextPreference notifyLowThreshold = new EditTextPreference(requireContext());
+ notifyLowThreshold.setKey(PREF_BATTERY_NOTIFY_LOW_THRESHOLD + batteryConfig.getBatteryIndex());
+ notifyLowThreshold.setTitle(R.string.battery_low_threshold);
+ notifyLowThreshold.setDialogTitle(R.string.battery_low_threshold);
+ notifyLowThreshold.setIconSpaceReserved(false);
+ notifyLowThreshold.setOnBindEditTextListener(editText -> {
+ editText.setInputType(InputType.TYPE_CLASS_NUMBER);
+ editText.addTextChangedListener(new MinMaxTextWatcher(editText, 0, 100, true));
+ editText.setSelection(editText.getText().length());
+ });
+ notifyLowThreshold.setSummaryProvider(new GBSimpleSummaryProvider(
+ requireContext().getString(R.string.default_percentage, batteryConfig.getDefaultLowThreshold()),
+ R.string.battery_percentage_str
+ ));
+
+ batteryScreen.addPreference(notifyLowThreshold);
+
+ final SwitchPreferenceCompat notifyFullEnabled = new SwitchPreferenceCompat(requireContext());
+ notifyFullEnabled.setLayoutResource(R.layout.preference_checkbox);
+ notifyFullEnabled.setKey(PREF_BATTERY_NOTIFY_FULL_ENABLED + batteryConfig.getBatteryIndex());
+ notifyFullEnabled.setTitle(R.string.battery_full_notify_enabled);
+ notifyFullEnabled.setDefaultValue(true);
+ notifyFullEnabled.setIconSpaceReserved(false);
+ batteryScreen.addPreference(notifyFullEnabled);
+
+ final EditTextPreference notifyFullThreshold = new EditTextPreference(requireContext());
+ notifyFullThreshold.setKey(PREF_BATTERY_NOTIFY_FULL_THRESHOLD + batteryConfig.getBatteryIndex());
+ notifyFullThreshold.setTitle(R.string.battery_full_threshold);
+ notifyFullThreshold.setDialogTitle(R.string.battery_full_threshold);
+ notifyFullThreshold.setIconSpaceReserved(false);
+ notifyFullThreshold.setOnBindEditTextListener(editText -> {
+ editText.setInputType(InputType.TYPE_CLASS_NUMBER);
+ editText.addTextChangedListener(new MinMaxTextWatcher(editText, 0, 100, true));
+ editText.setSelection(editText.getText().length());
+ });
+ notifyFullThreshold.setSummaryProvider(new GBSimpleSummaryProvider(
+ requireContext().getString(R.string.default_percentage, batteryConfig.getDefaultFullThreshold()),
+ R.string.battery_percentage_str
+ ));
+ batteryScreen.addPreference(notifyFullThreshold);
+ }
+
+ if (coordinator.addBatteryPollingSettings()) {
+ final Preference prefHeader = new PreferenceCategoryMultiline(requireContext());
+ prefHeader.setKey("pref_battery_polling_header");
+ prefHeader.setIconSpaceReserved(false);
+ prefHeader.setTitle(R.string.pref_battery_polling_configuration);
+ prefHeader.setSummary(R.string.pref_battery_polling_summary);
+ batteryScreen.addPreference(prefHeader);
+
+ final SwitchPreferenceCompat pollingToggle = new SwitchPreferenceCompat(requireContext());
+ pollingToggle.setLayoutResource(R.layout.preference_checkbox);
+ pollingToggle.setKey(PREF_BATTERY_POLLING_ENABLE);
+ pollingToggle.setTitle(R.string.pref_battery_polling_enable);
+ pollingToggle.setDefaultValue(true);
+ pollingToggle.setIconSpaceReserved(false);
+ batteryScreen.addPreference(pollingToggle);
+
+ final EditTextPreference pollingInterval = new EditTextPreference(requireContext());
+ pollingInterval.setKey(PREF_BATTERY_POLLING_INTERVAL);
+ pollingInterval.setTitle(R.string.pref_battery_polling_interval);
+ pollingInterval.setDialogTitle(R.string.pref_battery_polling_interval);
+ pollingInterval.setIconSpaceReserved(false);
+ pollingInterval.setOnBindEditTextListener(editText -> {
+ editText.setInputType(InputType.TYPE_CLASS_NUMBER);
+ // Max is set to 8 days, which should be more than enough
+ editText.addTextChangedListener(new MinMaxTextWatcher(editText, 0, 11520, true));
+ editText.setSelection(editText.getText().length());
+ });
+ pollingInterval.setSummaryProvider(new GBSimpleSummaryProvider(
+ getString(R.string.interval_fifteen_minutes),
+ R.string.pref_battery_polling_interval_format
+ ));
+ batteryScreen.addPreference(pollingInterval);
+ }
+ }
+
+ /*
+ * delayed execution so that the preferences are applied first
+ */
+ private void invokeLater(Runnable runnable) {
+ getListView().post(runnable);
+ }
+
+ /*
+ * delayed execution so that the preferences are applied first
+ */
+ @Override
+ public void notifyPreferenceChanged(final String preferenceKey) {
+ invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ GBApplication.deviceService(device).onSendConfiguration(preferenceKey);
+ }
+ });
+ }
+
+ private void setChangeListener(final String rootKey) {
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+
+ final Prefs prefs = new Prefs(getPreferenceManager().getSharedPreferences());
+
+ final ListPreference languageListPreference = findPreference("language");
+ if (languageListPreference != null) {
+ final String[] supportedLanguages = coordinator.getSupportedLanguageSettings(device);
+ CharSequence[] entries = languageListPreference.getEntries();
+ CharSequence[] values = languageListPreference.getEntryValues();
+ for (int i = entries.length - 1; i >= 0; i--) {
+ if (!ArrayUtils.contains(supportedLanguages, values[i])) {
+ entries = ArrayUtils.remove(entries, i);
+ values = ArrayUtils.remove(values, i);
+ }
+ }
+ languageListPreference.setEntries(entries);
+ languageListPreference.setEntryValues(values);
+ }
+
+ String disconnectNotificationState = prefs.getString(PREF_DISCONNECT_NOTIFICATION, PREF_DO_NOT_DISTURB_OFF);
+ boolean disconnectNotificationScheduled = disconnectNotificationState.equals(PREF_DO_NOT_DISTURB_SCHEDULED);
+
+ final Preference disconnectNotificationStart = findPreference(PREF_DISCONNECT_NOTIFICATION_START);
+ if (disconnectNotificationStart != null) {
+ disconnectNotificationStart.setEnabled(disconnectNotificationScheduled);
+ disconnectNotificationStart.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_DISCONNECT_NOTIFICATION_START);
+ return true;
+ }
+ });
+ }
+
+ final Preference disconnectNotificationEnd = findPreference(PREF_DISCONNECT_NOTIFICATION_END);
+ if (disconnectNotificationEnd != null) {
+ disconnectNotificationEnd.setEnabled(disconnectNotificationScheduled);
+ disconnectNotificationEnd.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_DISCONNECT_NOTIFICATION_END);
+ return true;
+ }
+ });
+ }
+
+ final Preference disconnectNotification = findPreference(PREF_DISCONNECT_NOTIFICATION);
+ if (disconnectNotification != null) {
+ disconnectNotification.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ final boolean scheduled = PREF_DO_NOT_DISTURB_SCHEDULED.equals(newVal.toString());
+
+ Objects.requireNonNull(disconnectNotificationStart).setEnabled(scheduled);
+ Objects.requireNonNull(disconnectNotificationEnd).setEnabled(scheduled);
+ notifyPreferenceChanged(PREF_DISCONNECT_NOTIFICATION);
+ return true;
+ }
+ });
+
+ }
+
+ String nightModeState = prefs.getString(MiBandConst.PREF_NIGHT_MODE, PREF_NIGHT_MODE_OFF);
+ boolean nightModeScheduled = nightModeState.equals(PREF_NIGHT_MODE_SCHEDULED);
+
+ final Preference nightModeStart = findPreference(PREF_NIGHT_MODE_START);
+ if (nightModeStart != null) {
+ nightModeStart.setEnabled(nightModeScheduled);
+ nightModeStart.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_NIGHT_MODE_START);
+ return true;
+ }
+ });
+ }
+
+ final Preference nightModeEnd = findPreference(PREF_NIGHT_MODE_END);
+ if (nightModeEnd != null) {
+ nightModeEnd.setEnabled(nightModeScheduled);
+ nightModeEnd.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_NIGHT_MODE_END);
+ return true;
+ }
+ });
+ }
+
+ final Preference nightMode = findPreference(PREF_NIGHT_MODE);
+ if (nightMode != null) {
+
+ nightMode.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ final boolean scheduled = PREF_NIGHT_MODE_SCHEDULED.equals(newVal.toString());
+
+ Objects.requireNonNull(nightModeStart).setEnabled(scheduled);
+ Objects.requireNonNull(nightModeEnd).setEnabled(scheduled);
+
+ notifyPreferenceChanged(PREF_NIGHT_MODE);
+ return true;
+ }
+ });
+ }
+
+
+ String doNotDisturbState = prefs.getString(PREF_DO_NOT_DISTURB, PREF_DO_NOT_DISTURB_OFF);
+ boolean doNotDisturbScheduled = doNotDisturbState.equals(PREF_DO_NOT_DISTURB_SCHEDULED);
+
+ final Preference doNotDisturbStart = findPreference(PREF_DO_NOT_DISTURB_START);
+ if (doNotDisturbStart != null) {
+ doNotDisturbStart.setEnabled(doNotDisturbScheduled);
+ doNotDisturbStart.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_DO_NOT_DISTURB_START);
+ return true;
+ }
+ });
+ }
+
+ final Preference doNotDisturbEnd = findPreference(PREF_DO_NOT_DISTURB_END);
+ if (doNotDisturbEnd != null) {
+ doNotDisturbEnd.setEnabled(doNotDisturbScheduled);
+ doNotDisturbEnd.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_DO_NOT_DISTURB_END);
+ return true;
+ }
+ });
+ }
+
+ final Preference doNotDisturb = findPreference(PREF_DO_NOT_DISTURB);
+ if (doNotDisturb != null) {
+ doNotDisturb.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ final boolean scheduled = PREF_DO_NOT_DISTURB_SCHEDULED.equals(newVal.toString());
+
+ Objects.requireNonNull(doNotDisturbStart).setEnabled(scheduled);
+ Objects.requireNonNull(doNotDisturbEnd).setEnabled(scheduled);
+
+ notifyPreferenceChanged(PREF_DO_NOT_DISTURB);
+ return true;
+ }
+ });
+ }
+
+ addPreferenceHandlerFor(PREF_SEND_APP_NOTIFICATIONS);
+ addPreferenceHandlerFor(PREF_SWIPE_UNLOCK);
+ addPreferenceHandlerFor(PREF_MI2_DATEFORMAT);
+ addPreferenceHandlerFor(PREF_DATEFORMAT);
+ addPreferenceHandlerFor(PREF_DISPLAY_ITEMS);
+ addPreferenceHandlerFor(PREF_DISPLAY_ITEMS_SORTABLE);
+ addPreferenceHandlerFor(PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE);
+ addPreferenceHandlerFor(PREF_SHORTCUTS);
+ addPreferenceHandlerFor(PREF_SHORTCUTS_SORTABLE);
+ addPreferenceHandlerFor(PREF_CONTROL_CENTER_SORTABLE);
+ addPreferenceHandlerFor(PREF_LANGUAGE);
+ addPreferenceHandlerFor(PREF_EXPOSE_HR_THIRDPARTY);
+ addPreferenceHandlerFor(PREF_BT_CONNECTED_ADVERTISEMENT);
+ addPreferenceHandlerFor(PREF_WEARLOCATION);
+ addPreferenceHandlerFor(PREF_VIBRATION_ENABLE);
+ addPreferenceHandlerFor(PREF_NOTIFICATION_ENABLE);
+ addPreferenceHandlerFor(PREF_DEVICE_NAME);
+ addPreferenceHandlerFor(PREF_SCREEN_BRIGHTNESS);
+ addPreferenceHandlerFor(PREF_SCREEN_AUTO_BRIGHTNESS);
+ addPreferenceHandlerFor(PREF_SCREEN_ORIENTATION);
+ addPreferenceHandlerFor(PREF_SCREEN_TIMEOUT);
+ addPreferenceHandlerFor(PREF_TIMEFORMAT);
+ addPreferenceHandlerFor(PREF_UPPER_BUTTON_LONG_PRESS);
+ addPreferenceHandlerFor(PREF_LOWER_BUTTON_SHORT_PRESS);
+ addPreferenceHandlerFor(PREF_BUTTON_1_FUNCTION_SHORT);
+ addPreferenceHandlerFor(PREF_BUTTON_2_FUNCTION_SHORT);
+ addPreferenceHandlerFor(PREF_BUTTON_3_FUNCTION_SHORT);
+ addPreferenceHandlerFor(PREF_BUTTON_1_FUNCTION_LONG);
+ addPreferenceHandlerFor(PREF_BUTTON_2_FUNCTION_LONG);
+ addPreferenceHandlerFor(PREF_BUTTON_3_FUNCTION_LONG);
+ addPreferenceHandlerFor(PREF_BUTTON_1_FUNCTION_DOUBLE);
+ addPreferenceHandlerFor(PREF_BUTTON_2_FUNCTION_DOUBLE);
+ addPreferenceHandlerFor(PREF_BUTTON_3_FUNCTION_DOUBLE);
+ addPreferenceHandlerFor(PREF_VIBRATION_STRENGH_PERCENTAGE);
+ addPreferenceHandlerFor(PREF_POWER_MODE);
+ addPreferenceHandlerFor(PREF_CONNECTION_DURATION);
+ addPreferenceHandlerFor(PREF_LIFTWRIST_NOSHED);
+ addPreferenceHandlerFor(PREF_DISCONNECTNOTIF_NOSHED);
+ addPreferenceHandlerFor(PREF_BUTTON_BP_CALIBRATE);
+ addPreferenceHandlerFor(PREF_ALTITUDE_CALIBRATE);
+ addPreferenceHandlerFor(PREF_INACTIVITY_ENABLE);
+ addPreferenceHandlerFor(PREF_INACTIVITY_START);
+ addPreferenceHandlerFor(PREF_INACTIVITY_END);
+ addPreferenceHandlerFor(PREF_INACTIVITY_THRESHOLD);
+ addPreferenceHandlerFor(PREF_INACTIVITY_THRESHOLD_EXTENDED);
+ addPreferenceHandlerFor(PREF_INACTIVITY_MO);
+ addPreferenceHandlerFor(PREF_INACTIVITY_TU);
+ addPreferenceHandlerFor(PREF_INACTIVITY_WE);
+ addPreferenceHandlerFor(PREF_INACTIVITY_TH);
+ addPreferenceHandlerFor(PREF_INACTIVITY_FR);
+ addPreferenceHandlerFor(PREF_INACTIVITY_SA);
+ addPreferenceHandlerFor(PREF_INACTIVITY_SU);
+ addPreferenceHandlerFor(PREF_INACTIVITY_DND);
+ addPreferenceHandlerFor(PREF_INACTIVITY_DND_START);
+ addPreferenceHandlerFor(PREF_INACTIVITY_DND_END);
+ addPreferenceHandlerFor(PREF_AUTOHEARTRATE_SWITCH);
+ addPreferenceHandlerFor(PREF_AUTOHEARTRATE_SLEEP);
+ addPreferenceHandlerFor(PREF_AUTOHEARTRATE_INTERVAL);
+ addPreferenceHandlerFor(PREF_AUTOHEARTRATE_START);
+ addPreferenceHandlerFor(PREF_AUTOHEARTRATE_END);
+ addPreferenceHandlerFor(PREF_HEARTRATE_ACTIVITY_MONITORING);
+ addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_ENABLED);
+ addPreferenceHandlerFor(PREF_HEARTRATE_STRESS_MONITORING);
+ addPreferenceHandlerFor(PREF_HEARTRATE_STRESS_RELAXATION_REMINDER);
+ addPreferenceHandlerFor(PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING);
+ addPreferenceHandlerFor(PREF_SPO2_ALL_DAY_MONITORING);
+ addPreferenceHandlerFor(PREF_SPO2_LOW_ALERT_THRESHOLD);
+ addPreferenceHandlerFor(PREF_HRV_ALL_DAY_MONITORING);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_NOAUTO);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_NOAUTO_START);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_NOAUTO_END);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_START);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_END);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_MO);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_TU);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_WE);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_TH);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_FR);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_SA);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_SU);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_LIFT_WRIST);
+ addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_NOT_WEAR);
+ addPreferenceHandlerFor(PREF_FIND_PHONE);
+ addPreferenceHandlerFor(PREF_FIND_PHONE_DURATION);
+ addPreferenceHandlerFor(PREF_AUTOLIGHT);
+ addPreferenceHandlerFor(PREF_LIGHT_DURATION_LONGER);
+ addPreferenceHandlerFor(PREF_AUTOREMOVE_MESSAGE);
+ addPreferenceHandlerFor(PREF_AUTOREMOVE_NOTIFICATIONS);
+ addPreferenceHandlerFor(PREF_PREVIEW_MESSAGE_IN_TITLE);
+ addPreferenceHandlerFor(PREF_CASIO_ALERT_EMAIL);
+ addPreferenceHandlerFor(PREF_CASIO_ALERT_SMS);
+ addPreferenceHandlerFor(PREF_CASIO_ALERT_CALL);
+ addPreferenceHandlerFor(PREF_CASIO_ALERT_CALENDAR);
+ addPreferenceHandlerFor(PREF_CASIO_ALERT_OTHER);
+ addPreferenceHandlerFor(PREF_SCREEN_ON_ON_NOTIFICATIONS);
+ addPreferenceHandlerFor(PREF_WORKOUT_KEEP_SCREEN_ON);
+ addPreferenceHandlerFor(PREF_KEY_VIBRATION);
+ addPreferenceHandlerFor(PREF_OPERATING_SOUNDS);
+ addPreferenceHandlerFor(PREF_FAKE_RING_DURATION);
+ addPreferenceHandlerFor(PREF_ANTILOST_ENABLED);
+ addPreferenceHandlerFor(PREF_HYDRATION_SWITCH);
+ addPreferenceHandlerFor(PREF_HYDRATION_PERIOD);
+ addPreferenceHandlerFor(PREF_HYDRATION_DND);
+ addPreferenceHandlerFor(PREF_HYDRATION_DND_START);
+ addPreferenceHandlerFor(PREF_HYDRATION_DND_END);
+ addPreferenceHandlerFor(PREF_AMPM_ENABLED);
+ addPreferenceHandlerFor(PREF_SOUNDS);
+ addPreferenceHandlerFor(PREF_CAMERA_REMOTE);
+ addPreferenceHandlerFor(PREF_SCREEN_LIFT_WRIST);
+ addPreferenceHandlerFor(PREF_SYNC_CALENDAR);
+ addPreferenceHandlerFor(PREF_CALENDAR_LOOKAHEAD_DAYS);
+
+ addPreferenceHandlerFor(PREF_BATTERY_POLLING_ENABLE);
+ addPreferenceHandlerFor(PREF_BATTERY_POLLING_INTERVAL);
+ addPreferenceHandlerFor(PREF_TIME_SYNC);
+
+ addPreferenceHandlerFor(PREF_BLUETOOTH_CALLS_ENABLED);
+ addPreferenceHandlerFor(PREF_DISPLAY_CALLER);
+ addPreferenceHandlerFor(PREF_NOTIFICATION_DELAY_CALLS);
+ addPreferenceHandlerFor(PREF_CALL_REJECT_METHOD);
+ addPreferenceHandlerFor(PREF_AUTO_REPLY_INCOMING_CALL);
+
+ addPreferenceHandlerFor(PREF_SLEEP_MODE_SLEEP_SCREEN);
+ addPreferenceHandlerFor(PREF_SLEEP_MODE_SMART_ENABLE);
+
+ addPreferenceHandlerFor(PREF_ACTIVE_NOISE_CANCELLING_TOGGLE);
+ addPreferenceHandlerFor(PREF_WEAR_SENSOR_TOGGLE);
+ addPreferenceHandlerFor(PREF_BANDW_PSERIES_GUI_VPT_LEVEL);
+
+ addPreferenceHandlerFor(PREF_HYBRID_HR_DRAW_WIDGET_CIRCLES);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_FORCE_WHITE_COLOR);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_SAVE_RAW_ACTIVITY_FILES);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_DANGEROUS_EXTERNAL_INTENTS);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_RUNNING_ENABLED);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_RUNNING_ASK_FIRST);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_RUNNING_MINUTES);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_BIKING_ENABLED);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_BIKING_ASK_FIRST);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_BIKING_MINUTES);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_WALKING_ENABLED);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_WALKING_ASK_FIRST);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_WALKING_MINUTES);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_ROWING_ENABLED);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_ROWING_ASK_FIRST);
+ addPreferenceHandlerFor(PREF_HYBRID_HR_ACTIVITY_RECOGNITION_ROWING_MINUTES);
+
+ addPreferenceHandlerFor(PREF_SONYSWR12_STAMINA);
+ addPreferenceHandlerFor(PREF_SONYSWR12_LOW_VIBRATION);
+ addPreferenceHandlerFor(PREF_SONYSWR12_SMART_INTERVAL);
+
+ addPreferenceHandlerFor(PREF_NOTHING_EAR1_INEAR);
+ addPreferenceHandlerFor(PREF_NOTHING_EAR1_AUDIOMODE);
+
+ addPreferenceHandlerFor(PREF_HUAWEI_FREEBUDS_INEAR);
+ addPreferenceHandlerFor(PREF_HUAWEI_FREEBUDS_AUDIOMODE);
+
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_AMBIENT_VOICE_FOCUS);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_AMBIENT_VOLUME);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_LOCK_TOUCH);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_GAME_MODE);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_EQUALIZER);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_EQUALIZER_DOLBY);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_EQUALIZER_MODE);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_TOUCH_LEFT);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_TOUCH_RIGHT);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_LIVE_ANC);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_PRESSURE_RELIEF);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_PRO_ANC_LEVEL);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_AMBIENT_SOUND);
+ addPreferenceHandlerFor(PREF_GALAXY_PRO_DOUBLE_TAP_EDGE);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_PRO_IN_EAR_DETECTION);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_PRO_VOICE_DETECT);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_PRO_VOICE_DETECT_DURATION);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_PRO_BALANCE);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_PRO_READ_NOTIFICATIONS_OUTLOUD);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_AMBIENT_MODE_DURING_CALL);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_PRO_AMBIENT_VOLUME_RIGHT);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_PRO_AMBIENT_VOLUME_LEFT);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_PRO_AMBIENT_SOUND_TONE);
+ addPreferenceHandlerFor(PREFS_NOISE_CONTROL_WITH_ONE_EARBUD);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_AMBIENT_MODE);
+ addPreferenceHandlerFor(PREFS_GALAXY_BUDS_SEAMLESS_CONNECTION);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_TOUCH_LEFT_SWITCH);
+ addPreferenceHandlerFor(PREF_GALAXY_BUDS_TOUCH_RIGHT_SWITCH);
+
+ addPreferenceHandlerFor(MORNING_UPDATES_ENABLED);
+ addPreferenceHandlerFor(MORNING_UPDATES_CATEGORIES_SORTABLE);
+
+ addPreferenceHandlerFor(SHORTCUT_CARDS_SORTABLE);
+
+ addPreferenceHandlerFor(PREF_WATCHFACE);
+
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_AMBIENT_SOUND_CONTROL);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_NOISE_CANCELLING_STRENGTH);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_TRANSPARENCY_STRENGTH);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_ADAPTIVE_NOISE_CANCELLING);
+// addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_PERSONALIZED_NOISE_CANCELLING);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_CONTROL_SINGLE_TAP_LEFT);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_CONTROL_SINGLE_TAP_RIGHT);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_CONTROL_DOUBLE_TAP_LEFT);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_CONTROL_DOUBLE_TAP_RIGHT);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_CONTROL_TRIPLE_TAP_LEFT);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_CONTROL_TRIPLE_TAP_RIGHT);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_MODE_LEFT);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_MODE_RIGHT);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_SETTINGS_LEFT);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_SETTINGS_RIGHT);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_WEARING_DETECTION);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_AUTO_REPLY_PHONECALL);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_DOUBLE_CONNECTION);
+// addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_SURROUND_SOUND);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_ADAPTIVE_SOUND);
+// addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_SURROUND_SOUND_MODE);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_EQUALIZER_PRESET);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_62);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_125);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_250);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_500);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_1k);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_2k);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_4k);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_8k);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_12k);
+ addPreferenceHandlerFor(PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_16k);
+
+ addPreferenceHandlerFor(PREF_SONY_AMBIENT_SOUND_CONTROL);
+ addPreferenceHandlerFor(PREF_SONY_AMBIENT_SOUND_CONTROL_BUTTON_MODE);
+ addPreferenceHandlerFor(PREF_SONY_FOCUS_VOICE);
+ addPreferenceHandlerFor(PREF_SONY_AMBIENT_SOUND_LEVEL);
+ addPreferenceHandlerFor(PREF_SONY_SOUND_POSITION);
+ addPreferenceHandlerFor(PREF_SONY_SURROUND_MODE);
+ addPreferenceHandlerFor(PREF_SONY_EQUALIZER_MODE);
+ addPreferenceHandlerFor(PREF_SONY_EQUALIZER_BAND_400);
+ addPreferenceHandlerFor(PREF_SONY_EQUALIZER_BAND_1000);
+ addPreferenceHandlerFor(PREF_SONY_EQUALIZER_BAND_2500);
+ addPreferenceHandlerFor(PREF_SONY_EQUALIZER_BAND_6300);
+ addPreferenceHandlerFor(PREF_SONY_EQUALIZER_BAND_16000);
+ addPreferenceHandlerFor(PREF_SONY_EQUALIZER_BASS);
+ addPreferenceHandlerFor(PREF_SONY_AUDIO_UPSAMPLING);
+ addPreferenceHandlerFor(PREF_SONY_TOUCH_SENSOR);
+ addPreferenceHandlerFor(PREF_SONY_PAUSE_WHEN_TAKEN_OFF);
+ addPreferenceHandlerFor(PREF_SONY_BUTTON_MODE_LEFT);
+ addPreferenceHandlerFor(PREF_SONY_BUTTON_MODE_RIGHT);
+ addPreferenceHandlerFor(PREF_SONY_QUICK_ACCESS_DOUBLE_TAP);
+ addPreferenceHandlerFor(PREF_SONY_QUICK_ACCESS_TRIPLE_TAP);
+ addPreferenceHandlerFor(PREF_SONY_AUTOMATIC_POWER_OFF);
+ addPreferenceHandlerFor(PREF_SONY_NOTIFICATION_VOICE_GUIDE);
+ addPreferenceHandlerFor(PREF_SONY_SPEAK_TO_CHAT);
+ addPreferenceHandlerFor(PREF_SONY_SPEAK_TO_CHAT_SENSITIVITY);
+ addPreferenceHandlerFor(PREF_SONY_SPEAK_TO_CHAT_FOCUS_ON_VOICE);
+ addPreferenceHandlerFor(PREF_SONY_SPEAK_TO_CHAT_TIMEOUT);
+ addPreferenceHandlerFor(PREF_SONY_CONNECT_TWO_DEVICES);
+ addPreferenceHandlerFor(PREF_SONY_ADAPTIVE_VOLUME_CONTROL);
+ addPreferenceHandlerFor(PREF_SONY_WIDE_AREA_TAP);
+
+ addPreferenceHandlerFor(PREF_SOUNDCORE_AMBIENT_SOUND_CONTROL);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_WIND_NOISE_REDUCTION);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_TRANSPARENCY_VOCAL_MODE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_ADAPTIVE_NOISE_CANCELLING);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_TOUCH_TONE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_WEARING_TONE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_WEARING_DETECTION);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_SINGLE_TAP_DISABLED);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_DOUBLE_TAP_DISABLED);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_TRIPLE_TAP_DISABLED);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_LONG_PRESS_DISABLED);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_SINGLE_TAP_ACTION_LEFT);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_SINGLE_TAP_ACTION_RIGHT);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_DOUBLE_TAP_ACTION_LEFT);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_DOUBLE_TAP_ACTION_RIGHT);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_TRIPLE_TAP_ACTION_LEFT);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_TRIPLE_TAP_ACTION_RIGHT);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_LONG_PRESS_ACTION_LEFT);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_CONTROL_LONG_PRESS_ACTION_RIGHT);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_VOICE_PROMPTS);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_BUTTON_BRIGHTNESS);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_AUTO_POWER_OFF);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_LDAC_MODE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_ADAPTIVE_DIRECTION);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_PRESET);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_DIRECTION);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND1_FREQ);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND1_VALUE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND2_FREQ);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND2_VALUE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND3_FREQ);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND3_VALUE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND4_FREQ);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND4_VALUE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND5_FREQ);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND5_VALUE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND6_FREQ);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND6_VALUE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND7_FREQ);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND7_VALUE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND8_FREQ);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND8_VALUE);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND9_FREQ);
+ addPreferenceHandlerFor(PREF_SOUNDCORE_EQUALIZER_BAND9_VALUE);
+
+ addPreferenceHandlerFor(PREF_MOONDROP_EQUALIZER_PRESET);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_PLAY_PAUSE_EARBUD);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_PLAY_PAUSE_TRIGGER);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_MEDIA_PREV_EARBUD);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_MEDIA_PREV_TRIGGER);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_MEDIA_NEXT_EARBUD);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_MEDIA_NEXT_TRIGGER);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_CALL_PICK_HANG_EARBUD);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_CALL_PICK_HANG_TRIGGER);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_CALL_START_EARBUD);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_CALL_START_TRIGGER);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_ASSISTANT_EARBUD);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_ASSISTANT_TRIGGER);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_ANC_MODE_EARBUD);
+ addPreferenceHandlerFor(PREF_MOONDROP_TOUCH_ANC_MODE_TRIGGER);
+
+ addPreferenceHandlerFor(PREF_MISCALE_WEIGHT_UNIT);
+ addPreferenceHandlerFor(PREF_MISCALE_SMALL_OBJECTS);
+
+ addPreferenceHandlerFor(PREF_MIJIA_LYWSD_COMFORT_TEMPERATURE_LOWER);
+ addPreferenceHandlerFor(PREF_MIJIA_LYWSD_COMFORT_TEMPERATURE_UPPER);
+ addPreferenceHandlerFor(PREF_MIJIA_LYWSD_COMFORT_HUMIDITY_LOWER);
+ addPreferenceHandlerFor(PREF_MIJIA_LYWSD_COMFORT_HUMIDITY_UPPER);
+
+ addPreferenceHandlerFor(PREF_FEMOMETER_MEASUREMENT_MODE);
+
+ addPreferenceHandlerFor(PREF_QC35_NOISE_CANCELLING_LEVEL);
+ addPreferenceHandlerFor(PREF_USER_FITNESS_GOAL);
+ addPreferenceHandlerFor(PREF_USER_FITNESS_GOAL_NOTIFICATION);
+ addPreferenceHandlerFor(PREF_USER_FITNESS_GOAL_SECONDARY);
+
+ addPreferenceHandlerFor(PREF_VITALITY_SCORE_7_DAY);
+ addPreferenceHandlerFor(PREF_VITALITY_SCORE_DAILY);
+
+ addPreferenceHandlerFor(PREF_UM25_SHOW_THRESHOLD_NOTIFICATION);
+ addPreferenceHandlerFor(PREF_UM25_SHOW_THRESHOLD);
+ addPreferenceHandlerFor(PREF_HOURLY_CHIME_ENABLE);
+ addPreferenceHandlerFor(PREF_HOURLY_CHIME_START);
+ addPreferenceHandlerFor(PREF_HOURLY_CHIME_END);
+
+ addPreferenceHandlerFor(PREF_WORKOUT_DETECTION_CATEGORIES);
+ addPreferenceHandlerFor(PREF_WORKOUT_DETECTION_ALERT);
+ addPreferenceHandlerFor(PREF_WORKOUT_DETECTION_SENSITIVITY);
+
+ addPreferenceHandlerFor(PREF_GPS_MODE_PRESET);
+ addPreferenceHandlerFor(PREF_GPS_BAND);
+ addPreferenceHandlerFor(PREF_GPS_COMBINATION);
+ addPreferenceHandlerFor(PREF_GPS_SATELLITE_SEARCH);
+ addPreferenceHandlerFor(PREF_AGPS_EXPIRY_REMINDER_ENABLED);
+ addPreferenceHandlerFor(PREF_AGPS_EXPIRY_REMINDER_TIME);
+ addPreferenceHandlerFor(PREF_ALWAYS_ON_DISPLAY_FOLLOW_WATCHFACE);
+ addPreferenceHandlerFor(PREF_ALWAYS_ON_DISPLAY_STYLE);
+ addPreferenceHandlerFor(PREF_WEARDIRECTION);
+ addPreferenceHandlerFor(PREF_WEARMODE);
+
+ addPreferenceHandlerFor(PREF_VOLUME);
+ addPreferenceHandlerFor(PREF_CROWN_VIBRATION);
+ addPreferenceHandlerFor(PREF_ALERT_TONE);
+ addPreferenceHandlerFor(PREF_COVER_TO_MUTE);
+ addPreferenceHandlerFor(PREF_VIBRATE_FOR_ALERT);
+ addPreferenceHandlerFor(PREF_TEXT_TO_SPEECH);
+
+ addPreferenceHandlerFor(PREF_OFFLINE_VOICE_RESPOND_TURN_WRIST);
+ addPreferenceHandlerFor(PREF_OFFLINE_VOICE_RESPOND_SCREEN_ON);
+ addPreferenceHandlerFor(PREF_OFFLINE_VOICE_RESPONSE_DURING_SCREEN_LIGHTING);
+ addPreferenceHandlerFor(PREF_OFFLINE_VOICE_LANGUAGE);
+
+ addPreferenceHandlerFor(PREF_VOICE_SERVICE_LANGUAGE);
+
+ addPreferenceHandlerFor(PREF_TEMPERATURE_SCALE_CF);
+
+ addPreferenceHandlerFor(PREF_PREFIX_NOTIFICATION_WITH_APP);
+
+ addPreferenceHandlerFor(PREF_SLEEP_MODE_SCHEDULE_ENABLED);
+ addPreferenceHandlerFor(PREF_SLEEP_MODE_SCHEDULE_START);
+ addPreferenceHandlerFor(PREF_SLEEP_MODE_SCHEDULE_END);
+
+ addPreferenceHandlerFor(PREF_CLAP_HANDS_TO_WAKEUP_DEVICE);
+ addPreferenceHandlerFor(PREF_POWER_SAVING);
+
+ addPreferenceHandlerFor(PREF_HEARTRATE_AUTOMATIC_ENABLE);
+ addPreferenceHandlerFor(PREF_SPO_AUTOMATIC_ENABLE);
+
+ addPreferenceHandlerFor(PREF_CYCLING_SENSOR_PERSISTENCE_INTERVAL);
+ addPreferenceHandlerFor(PREF_CYCLING_SENSOR_WHEEL_DIAMETER);
+
+ addPreferenceHandlerFor(PREF_SPEAK_NOTIFICATIONS_FOCUS_EXCLUSIVE);
+
+ addPreferenceHandlerFor(PREFS_KEY_DEVICE_BLE_API_DEVICE_STATE);
+ addPreferenceHandlerFor(PREFS_KEY_DEVICE_BLE_API_DEVICE_READ_WRITE);
+ addPreferenceHandlerFor(PREFS_KEY_DEVICE_BLE_API_DEVICE_NOTIFY);
+ addPreferenceHandlerFor(PREFS_KEY_DEVICE_BLE_API_PACKAGE);
+
+ addPreferenceHandlerFor("lock");
+
+ addPreferenceHandlerFor(PREF_BATTERY_MINIMUM_CHARGE);
+ addPreferenceHandlerFor(PREF_BATTERY_ALLOW_PASS_THOUGH);
+
+ final Preference dischargeIntervalsSet = findPreference(PREF_BATTERY_DISCHARGE_INTERVALS_SET);
+ if (dischargeIntervalsSet != null) {
+ dischargeIntervalsSet.setOnPreferenceClickListener(preference -> {
+ notifyPreferenceChanged(PREF_BATTERY_DISCHARGE_INTERVALS_SET);
+ return true;
+ });
+ }
+
+ String sleepTimeState = prefs.getString(PREF_SLEEP_TIME, PREF_DO_NOT_DISTURB_OFF);
+ boolean sleepTimeScheduled = sleepTimeState.equals(PREF_DO_NOT_DISTURB_SCHEDULED);
+
+ final Preference sleepTimeInfo = findPreference(PREF_SLEEP_TIME);
+ if (sleepTimeInfo != null) {
+ //sleepTimeInfo.setEnabled(!PREF_DO_NOT_DISTURB_OFF.equals(sleepTimeInfo));
+ sleepTimeInfo.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_SLEEP_TIME);
+ return true;
+ }
+ });
+ }
+
+ final Preference sleepTimeStart = findPreference(PREF_SLEEP_TIME_START);
+ if (sleepTimeStart != null) {
+ sleepTimeStart.setEnabled(sleepTimeScheduled);
+ sleepTimeStart.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_SLEEP_TIME_START);
+ return true;
+ }
+ });
+ }
+
+ final Preference sleepTimeEnd = findPreference(PREF_SLEEP_TIME_END);
+ if (sleepTimeEnd != null) {
+ sleepTimeEnd.setEnabled(sleepTimeScheduled);
+ sleepTimeEnd.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_SLEEP_TIME_END);
+ return true;
+ }
+ });
+ }
+
+ final Preference sleepTime = findPreference(PREF_SLEEP_TIME);
+ if (sleepTime != null) {
+ sleepTime.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ final boolean scheduled = PREF_DO_NOT_DISTURB_SCHEDULED.equals(newVal.toString());
+ Objects.requireNonNull(sleepTimeStart).setEnabled(scheduled);
+ Objects.requireNonNull(sleepTimeEnd).setEnabled(scheduled);
+ if (sleepTimeInfo != null) {
+ //sleepTimeInfo.setEnabled(!PREF_DO_NOT_DISTURB_OFF.equals(newVal.toString()));
+ }
+ notifyPreferenceChanged(PREF_SLEEP_TIME);
+ return true;
+ }
+ });
+ }
+ String displayOnLiftState = prefs.getString(PREF_ACTIVATE_DISPLAY_ON_LIFT, PREF_DO_NOT_DISTURB_OFF);
+ boolean displayOnLiftScheduled = displayOnLiftState.equals(PREF_DO_NOT_DISTURB_SCHEDULED);
+ boolean displayOnLiftOff = displayOnLiftState.equals(PREF_DO_NOT_DISTURB_OFF);
+
+ final Preference rotateWristCycleInfo = findPreference(PREF_MI2_ROTATE_WRIST_TO_SWITCH_INFO);
+ if (rotateWristCycleInfo != null) {
+ rotateWristCycleInfo.setEnabled(!PREF_DO_NOT_DISTURB_OFF.equals(displayOnLiftState));
+ rotateWristCycleInfo.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_MI2_ROTATE_WRIST_TO_SWITCH_INFO);
+ return true;
+ }
+ });
+ }
+
+ final Preference phoneSilentMode = findPreference(PREF_PHONE_SILENT_MODE);
+ if (phoneSilentMode != null) {
+ phoneSilentMode.setOnPreferenceChangeListener((preference, newVal) -> {
+ final AudioManager audioManager = (AudioManager) requireContext().getSystemService(Context.AUDIO_SERVICE);
+ GBApplication.deviceService(device).onChangePhoneSilentMode(audioManager.getRingerMode());
+ return true;
+ });
+ }
+
+ final String alwaysOnDisplayState = prefs.getString(PREF_ALWAYS_ON_DISPLAY_MODE, PREF_ALWAYS_ON_DISPLAY_OFF);
+ boolean alwaysOnDisplayScheduled = alwaysOnDisplayState.equals(PREF_ALWAYS_ON_DISPLAY_SCHEDULED);
+ boolean alwaysOnDisplayOff = alwaysOnDisplayState.equals(PREF_ALWAYS_ON_DISPLAY_OFF);
+
+ final Preference alwaysOnDisplayStart = findPreference(PREF_ALWAYS_ON_DISPLAY_START);
+ if (alwaysOnDisplayStart != null) {
+ alwaysOnDisplayStart.setEnabled(alwaysOnDisplayScheduled);
+ alwaysOnDisplayStart.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_ALWAYS_ON_DISPLAY_START);
+ return true;
+ }
+ });
+ }
+
+ final Preference alwaysOnDisplayEnd = findPreference(PREF_ALWAYS_ON_DISPLAY_END);
+ if (alwaysOnDisplayEnd != null) {
+ alwaysOnDisplayEnd.setEnabled(alwaysOnDisplayScheduled);
+ alwaysOnDisplayEnd.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_ALWAYS_ON_DISPLAY_END);
+ return true;
+ }
+ });
+ }
+
+ final Preference alwaysOnDisplayMode = findPreference(PREF_ALWAYS_ON_DISPLAY_MODE);
+ if (alwaysOnDisplayMode != null) {
+ alwaysOnDisplayMode.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ final boolean scheduled = PREF_ALWAYS_ON_DISPLAY_SCHEDULED.equals(newVal.toString());
+ final boolean off = PREF_ALWAYS_ON_DISPLAY_OFF.equals(newVal.toString());
+ alwaysOnDisplayStart.setEnabled(scheduled);
+ alwaysOnDisplayEnd.setEnabled(scheduled);
+ notifyPreferenceChanged(PREF_ALWAYS_ON_DISPLAY_MODE);
+ return true;
+ }
+ });
+ }
+
+ final Preference displayOnLiftStart = findPreference(PREF_DISPLAY_ON_LIFT_START);
+ if (displayOnLiftStart != null) {
+ displayOnLiftStart.setEnabled(displayOnLiftScheduled);
+ displayOnLiftStart.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_DISPLAY_ON_LIFT_START);
+ return true;
+ }
+ });
+ }
+
+ final Preference displayOnLiftEnd = findPreference(PREF_DISPLAY_ON_LIFT_END);
+ if (displayOnLiftEnd != null) {
+ displayOnLiftEnd.setEnabled(displayOnLiftScheduled);
+ displayOnLiftEnd.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_DISPLAY_ON_LIFT_END);
+ return true;
+ }
+ });
+ }
+
+ final Preference displayOnLiftSensitivity = findPreference(PREF_DISPLAY_ON_LIFT_SENSITIVITY);
+ if (displayOnLiftSensitivity != null) {
+ displayOnLiftSensitivity.setEnabled(!displayOnLiftOff);
+ displayOnLiftSensitivity.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(PREF_DISPLAY_ON_LIFT_SENSITIVITY);
+ return true;
+ }
+ });
+ }
+
+ final Preference displayOnLift = findPreference(PREF_ACTIVATE_DISPLAY_ON_LIFT);
+ if (displayOnLift != null) {
+ displayOnLift.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ final boolean scheduled = PREF_DO_NOT_DISTURB_SCHEDULED.equals(newVal.toString());
+ final boolean off = PREF_DO_NOT_DISTURB_OFF.equals(newVal.toString());
+ Objects.requireNonNull(displayOnLiftStart).setEnabled(scheduled);
+ Objects.requireNonNull(displayOnLiftEnd).setEnabled(scheduled);
+ if (rotateWristCycleInfo != null) {
+ rotateWristCycleInfo.setEnabled(!off);
+ }
+ if (displayOnLiftSensitivity != null) {
+ displayOnLiftSensitivity.setEnabled(!off);
+ }
+ notifyPreferenceChanged(PREF_ACTIVATE_DISPLAY_ON_LIFT);
+ return true;
+ }
+ });
+ }
+
+ final Preference worldClocks = findPreference(PREF_WORLD_CLOCKS);
+ if (worldClocks != null) {
+ worldClocks.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ final Intent intent = new Intent(getContext(), ConfigureWorldClocks.class);
+ intent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ startActivity(intent);
+ return true;
+ }
+ });
+ }
+
+ final Preference contacts = findPreference(PREF_CONTACTS);
+ if (contacts != null) {
+ contacts.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ final Intent intent = new Intent(getContext(), ConfigureContacts.class);
+ intent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ startActivity(intent);
+ return true;
+ }
+ });
+ }
+
+ final Preference music_management = findPreference(PREF_MUSIC_MANAGEMENT);
+ if (music_management != null) {
+ music_management.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ final Intent intent = new Intent(getContext(), MusicManagerActivity.class);
+ intent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ startActivity(intent);
+ return true;
+ }
+ });
+ }
+
+ final Preference widgets = findPreference(PREF_WIDGETS);
+ if (widgets != null) {
+ widgets.setOnPreferenceClickListener(preference -> {
+ final Intent intent = new Intent(getContext(), WidgetScreensListActivity.class);
+ intent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ startActivity(intent);
+ return true;
+ });
+ }
+
+ final Preference calendarBlacklist = findPreference("blacklist_calendars");
+ if (calendarBlacklist != null) {
+ calendarBlacklist.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ public boolean onPreferenceClick(Preference preference) {
+ Intent intent = new Intent(getContext(), CalBlacklistActivity.class);
+ intent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ startActivity(intent);
+ return true;
+ }
+ });
+ }
+
+ final int cannedRepliesSlotCount = coordinator.getCannedRepliesSlotCount(device);
+
+ final Preference cannedMessagesDismissCall = findPreference("canned_messages_dismisscall_send");
+ if (cannedMessagesDismissCall != null) {
+ cannedMessagesDismissCall.setOnPreferenceClickListener(new androidx.preference.Preference.OnPreferenceClickListener() {
+ public boolean onPreferenceClick(androidx.preference.Preference preference) {
+ ArrayList<String> messages = new ArrayList<>();
+ for (int i = 1; i <= cannedRepliesSlotCount; i++) {
+ String message = prefs.getString("canned_message_dismisscall_" + i, null);
+ if (message != null && !message.equals("")) {
+ messages.add(message);
+ }
+ }
+ CannedMessagesSpec cannedMessagesSpec = new CannedMessagesSpec();
+ cannedMessagesSpec.type = CannedMessagesSpec.TYPE_REJECTEDCALLS;
+ cannedMessagesSpec.cannedMessages = messages.toArray(new String[0]);
+ GBApplication.deviceService(device).onSetCannedMessages(cannedMessagesSpec);
+ return true;
+ }
+ });
+
+ // TODO we could use this to auto-create preferences for watches with > 16 slots
+ for (int i = cannedRepliesSlotCount + 1; i <= 16; i++) {
+ final Preference cannedReplyPref = findPreference("canned_message_dismisscall_" + i);
+ if (cannedReplyPref != null) {
+ cannedReplyPref.setVisible(false);
+ }
+ }
+ }
+
+ final Preference cannedMessagesGeneric = findPreference("canned_messages_generic_send");
+ if (cannedMessagesGeneric != null) {
+
+ cannedMessagesGeneric.setOnPreferenceClickListener(new androidx.preference.Preference.OnPreferenceClickListener() {
+ public boolean onPreferenceClick(androidx.preference.Preference preference) {
+ final ArrayList<String> messages = new ArrayList<>();
+ for (int i = 1; i <= cannedRepliesSlotCount; i++) {
+ String message = prefs.getString("canned_reply_" + i, null);
+ if (message != null && !message.equals("")) {
+ messages.add(message);
+ }
+ }
+ final CannedMessagesSpec cannedMessagesSpec = new CannedMessagesSpec();
+ cannedMessagesSpec.type = CannedMessagesSpec.TYPE_GENERIC;
+ cannedMessagesSpec.cannedMessages = messages.toArray(new String[0]);
+ GBApplication.deviceService().onSetCannedMessages(cannedMessagesSpec);
+ return true;
+ }
+ });
+
+ // TODO we could use this to auto-create preferences for watches with > 16 slots
+ for (int i = cannedRepliesSlotCount + 1; i <= 16; i++) {
+ final Preference cannedReplyPref = findPreference("canned_reply_" + i);
+ if (cannedReplyPref != null) {
+ cannedReplyPref.setVisible(false);
+ }
+ }
+ }
+
+ final EditTextPreference calendarLookahead = findPreference(DeviceSettingsPreferenceConst.PREF_CALENDAR_LOOKAHEAD_DAYS);
+ if (calendarLookahead != null) {
+ setInputTypeFor(DeviceSettingsPreferenceConst.PREF_CALENDAR_LOOKAHEAD_DAYS, InputType.TYPE_CLASS_NUMBER);
+ calendarLookahead.setSummaryProvider(new GBSimpleSummaryProvider(
+ requireContext().getString(R.string.pref_summary_calendar_lookahead, "7"),
+ R.string.pref_summary_calendar_lookahead
+ ));
+ }
+
+ setInputTypeFor(HuamiConst.PREF_BUTTON_ACTION_BROADCAST_DELAY, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(HuamiConst.PREF_BUTTON_ACTION_PRESS_MAX_INTERVAL, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(HuamiConst.PREF_BUTTON_ACTION_PRESS_COUNT, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(MiBandConst.PREF_MIBAND_DEVICE_TIME_OFFSET_HOURS, InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_SIGNED);
+ setInputTypeFor(DeviceSettingsPreferenceConst.PREF_FIND_PHONE_DURATION, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(DeviceSettingsPreferenceConst.PREF_RESERVER_ALARMS_CALENDAR, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(DeviceSettingsPreferenceConst.PREF_RESERVE_REMINDERS_CALENDAR, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(DeviceSettingsPreferenceConst.PREF_INACTIVITY_THRESHOLD, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(DeviceSettingsPreferenceConst.PREF_DEVICE_GPS_UPDATE_INTERVAL, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(DeviceSettingsPreferenceConst.PREF_BANGLEJS_TEXT_BITMAP_SIZE, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor(DeviceSettingsPreferenceConst.PREF_AUTO_REPLY_INCOMING_CALL_DELAY, InputType.TYPE_CLASS_NUMBER);
+ setInputTypeFor("hplus_screentime", InputType.TYPE_CLASS_NUMBER);
+ setNumericInputTypeWithRangeFor(DeviceSettingsPreferenceConst.PREF_BATTERY_DISCHARGE_INTERVAL1_WATT, 80, 800, false);
+ setNumericInputTypeWithRangeFor(DeviceSettingsPreferenceConst.PREF_BATTERY_DISCHARGE_INTERVAL2_WATT, 80, 800, false);
+ setNumericInputTypeWithRangeFor(DeviceSettingsPreferenceConst.PREF_BATTERY_DISCHARGE_INTERVAL3_WATT, 80, 800, false);
+ setNumericInputTypeWithRangeFor(DeviceSettingsPreferenceConst.PREF_BATTERY_DISCHARGE_INTERVAL4_WATT, 80, 800, false);
+ setNumericInputTypeWithRangeFor(DeviceSettingsPreferenceConst.PREF_BATTERY_DISCHARGE_INTERVAL5_WATT, 80, 800, false);
+ setNumericInputTypeWithRangeFor(PREF_BATTERY_MINIMUM_CHARGE, 0, 100, false);
+
+ new PasswordCapabilityImpl().registerPreferences(getContext(), coordinator.getPasswordCapability(), this);
+ new HeartRateCapability().registerPreferences(getContext(), coordinator.getHeartRateMeasurementIntervals(), this);
+
+ Set<String> deviceActionsFellSleepSelection = prefs.getStringSet(PREF_DEVICE_ACTION_FELL_SLEEP_SELECTIONS, Collections.emptySet());
+ final Preference deviceActionsFellSleep = findPreference(PREF_DEVICE_ACTION_FELL_SLEEP_SELECTIONS);
+ final Preference deviceActionsFellSleepBroadcast = findPreference(PREF_DEVICE_ACTION_FELL_SLEEP_BROADCAST);
+ boolean deviceActionsFellSleepSelectionBroadcast = deviceActionsFellSleepSelection.contains(PREF_DEVICE_ACTION_SELECTION_BROADCAST);
+ if (deviceActionsFellSleep != null) {
+ deviceActionsFellSleep.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ final Set<String> newValSet = (Set<String>) newVal;
+ final boolean broadcast = newValSet.contains(PREF_DEVICE_ACTION_SELECTION_BROADCAST);
+ Objects.requireNonNull(deviceActionsFellSleepBroadcast).setEnabled(broadcast);
+ return true;
+ }
+ });
+ }
+ if (deviceActionsFellSleepBroadcast != null) {
+ deviceActionsFellSleepBroadcast.setEnabled(deviceActionsFellSleepSelectionBroadcast);
+ }
+
+ Set<String> deviceActionsWokeUpSelection = prefs.getStringSet(PREF_DEVICE_ACTION_WOKE_UP_SELECTIONS, Collections.emptySet());
+ final Preference deviceActionsWokeUp = findPreference(PREF_DEVICE_ACTION_WOKE_UP_SELECTIONS);
+ final Preference deviceActionsWokeUpBroadcast = findPreference(PREF_DEVICE_ACTION_WOKE_UP_BROADCAST);
+ boolean deviceActionsWokeUpSelectionBroadcast = deviceActionsWokeUpSelection.contains(PREF_DEVICE_ACTION_SELECTION_BROADCAST);
+ if (deviceActionsWokeUp != null) {
+ deviceActionsWokeUp.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ final Set<String> newValSet = (Set<String>) newVal;
+ final boolean broadcast = newValSet.contains(PREF_DEVICE_ACTION_SELECTION_BROADCAST);
+ Objects.requireNonNull(deviceActionsWokeUpBroadcast).setEnabled(broadcast);
+ return true;
+ }
+ });
+ }
+ if (deviceActionsWokeUpBroadcast != null) {
+ deviceActionsWokeUpBroadcast.setEnabled(deviceActionsWokeUpSelectionBroadcast);
+ }
+
+ Set<String> deviceActionsStartNonWearSelection = prefs.getStringSet(PREF_DEVICE_ACTION_START_NON_WEAR_SELECTIONS, Collections.emptySet());
+ final Preference deviceActionsStartNonWear = findPreference(PREF_DEVICE_ACTION_START_NON_WEAR_SELECTIONS);
+ final Preference deviceActionsStartNonWearBroadcast = findPreference(PREF_DEVICE_ACTION_START_NON_WEAR_BROADCAST);
+ boolean deviceActionsStartNonWearSelectionBroadcast = deviceActionsStartNonWearSelection.contains(PREF_DEVICE_ACTION_SELECTION_BROADCAST);
+ if (deviceActionsStartNonWear != null) {
+ deviceActionsStartNonWear.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ final Set<String> newValSet = (Set<String>) newVal;
+ final boolean broadcast = newValSet.contains(PREF_DEVICE_ACTION_SELECTION_BROADCAST);
+ Objects.requireNonNull(deviceActionsStartNonWearBroadcast).setEnabled(broadcast);
+ return true;
+ }
+ });
+ }
+ if (deviceActionsStartNonWearBroadcast != null) {
+ deviceActionsStartNonWearBroadcast.setEnabled(deviceActionsStartNonWearSelectionBroadcast);
+ }
+
+ // this is to ensure that Control Center device cards are refreshed on preference changes
+ final Preference activityInDeviceCard = findPreference(PREFS_ACTIVITY_IN_DEVICE_CARD);
+ final Preference activityInDeviceSteps = findPreference(PREFS_ACTIVITY_IN_DEVICE_CARD_STEPS);
+ final Preference activityInDeviceSleep = findPreference(PREFS_ACTIVITY_IN_DEVICE_CARD_SLEEP);
+ final Preference activityInDeviceDistance = findPreference(PREFS_ACTIVITY_IN_DEVICE_CARD_DISTANCE);
+ final Preference chartsTabsOrderSelection = findPreference(PREFS_DEVICE_CHARTS_TABS);
+
+ Preference.OnPreferenceClickListener sendIntentRefreshDeviceListListener = new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference arg0) {
+ Intent refreshIntent = new Intent(DeviceManager.ACTION_REFRESH_DEVICELIST);
+ LocalBroadcastManager.getInstance(getContext()).sendBroadcast(refreshIntent);
+ return true;
+ }
+ };
+
+ Preference[] preferencesInControlCenter = {
+ activityInDeviceCard,
+ activityInDeviceSteps,
+ activityInDeviceSleep,
+ activityInDeviceDistance,
+ chartsTabsOrderSelection,
+ };
+
+ for (Preference preferenceInControlCenter : preferencesInControlCenter) {
+ if (preferenceInControlCenter != null) {
+ preferenceInControlCenter.setOnPreferenceClickListener(sendIntentRefreshDeviceListListener);
+ }
+ }
+
+ // Replace the PAI with the device-specific name
+ if (chartsTabsOrderSelection != null) {
+ final ListPreference chartsTabsListPref = (ListPreference) chartsTabsOrderSelection;
+ final CharSequence[] entries = chartsTabsListPref.getEntries();
+ final CharSequence[] entryValues = chartsTabsListPref.getEntryValues();
+ for (int i = 0; i < entries.length; i++) {
+ if ("pai".equals(entryValues[i].toString())) {
+ entries[i] = getString(coordinator.getPaiName());
+ break;
+ }
+ }
+ chartsTabsListPref.setEntries(entries);
+ }
+
+ final Preference loyaltyCards = findPreference(LoyaltyCardsSettingsConst.PREF_KEY_LOYALTY_CARDS);
+ if (loyaltyCards != null) {
+ loyaltyCards.setOnPreferenceClickListener(preference -> {
+ final Intent intent = new Intent(getContext(), LoyaltyCardsSettingsActivity.class);
+ intent.putExtra(GBDevice.EXTRA_DEVICE, getDevice());
+ startActivity(intent);
+ return true;
+ });
+ }
+
+ final Preference notificationSettings = findPreference(PREFS_PER_APP_NOTIFICATION_SETTINGS);
+ if (notificationSettings != null) {
+ notificationSettings.setOnPreferenceClickListener(preference -> {
+ final Intent intent = new Intent(getContext(), AppSpecificNotificationSettingsActivity.class);
+ intent.putExtra(GBDevice.EXTRA_DEVICE, getDevice());
+ startActivity(intent);
+ return true;
+ });
+ }
+
+ if (deviceSpecificSettingsCustomizer != null) {
+ deviceSpecificSettingsCustomizer.customizeSettings(this, prefs, rootKey);
+ }
+ }
+
+ static DeviceSpecificSettingsFragment newInstance(GBDevice device, DeviceSettingsActivity.MENU_ENTRY_POINTS applicationSpecificSettings) {
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ if (applicationSpecificSettings.equals(DeviceSettingsActivity.MENU_ENTRY_POINTS.AUTH_SETTINGS)) { //auth settings screen
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_pairingkey_explanation);
+ for (final int s : coordinator.getSupportedDeviceSpecificAuthenticationSettings()) {
+ deviceSpecificSettings.addRootScreen(s);
+ }
+ } else { //device/application settings
+ if (coordinator.getSupportedLanguageSettings(device) != null) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_language_generic);
+ }
+ DeviceSpecificSettings coordinatorDeviceSettings = coordinator.getDeviceSpecificSettings(device);
+ if (coordinatorDeviceSettings != null) {
+ deviceSpecificSettings.mergeFrom(coordinatorDeviceSettings);
+ }
+ final int[] supportedAuthSettings = coordinator.getSupportedDeviceSpecificAuthenticationSettings();
+ if (supportedAuthSettings != null && supportedAuthSettings.length > 0) {
+ deviceSpecificSettings.addRootScreen(
+ DeviceSpecificSettingsScreen.AUTHENTICATION,
+ supportedAuthSettings
+ );
+ }
+
+ deviceSpecificSettings.addRootScreen(
+ DeviceSpecificSettingsScreen.CONNECTION,
+ coordinator.getSupportedDeviceSpecificConnectionSettings()
+ );
+
+ if (coordinator.getBatteryCount() > 0) {
+ deviceSpecificSettings.addRootScreen(
+ DeviceSpecificSettingsScreen.BATTERY
+ );
+ }
+
+ if (coordinator.supportsActivityTracking()) {
+ deviceSpecificSettings.addRootScreen(
+ DeviceSpecificSettingsScreen.ACTIVITY_INFO,
+ R.xml.devicesettings_chartstabs,
+ R.xml.devicesettings_device_card_activity_card_preferences
+ );
+ }
+
+ deviceSpecificSettings.addRootScreen(
+ DeviceSpecificSettingsScreen.DEVELOPER,
+ R.xml.devicesettings_settings_third_party_apps
+ );
+ if (coordinator.getConnectionType().usesBluetoothLE()) {
+ deviceSpecificSettings.addRootScreen(
+ DeviceSpecificSettingsScreen.DEVELOPER,
+ R.xml.devicesettings_ble_api
+ );
+ }
+ }
+
+ final DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer = coordinator.getDeviceSpecificSettingsCustomizer(device);
+ final String settingsFileSuffix = device.getAddress();
+ final DeviceSpecificSettingsFragment fragment = new DeviceSpecificSettingsFragment();
+ fragment.setSettingsFileSuffix(settingsFileSuffix);
+ fragment.setDeviceSpecificSettings(deviceSpecificSettings);
+ fragment.setDeviceSpecificSettingsCustomizer(deviceSpecificSettingsCustomizer);
+ fragment.setDevice(device);
+
+ return fragment;
+ }
+
+ @Override
+ public void addPreferenceHandlerFor(final String preferenceKey) {
+ addPreferenceHandlerFor(preferenceKey, null);
+ }
+
+ @Override
+ public void addPreferenceHandlerFor(final String preferenceKey, final Preference.OnPreferenceChangeListener extraListener) {
+ Preference pref = findPreference(preferenceKey);
+ if (pref != null) {
+ pref.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ notifyPreferenceChanged(preferenceKey);
+
+ if (extraListener != null) {
+ return extraListener.onPreferenceChange(preference, newVal);
+ }
+
+ return true;
+ }
+ });
+ }
+ }
+
+ @Override
+ public GBDevice getDevice() {
+ return device;
+ }
+
+ @Override
+ protected Set<String> getPreferenceKeysWithSummary() {
+ final Set<String> keysWithSummary = new HashSet<>();
+
+ keysWithSummary.add(PREF_INACTIVITY_THRESHOLD);
+ keysWithSummary.add(PREF_DEVICE_ACTION_FELL_SLEEP_SELECTIONS);
+ keysWithSummary.add(PREF_DEVICE_ACTION_START_NON_WEAR_SELECTIONS);
+ keysWithSummary.add(PREF_DEVICE_ACTION_WOKE_UP_SELECTIONS);
+
+ if (deviceSpecificSettingsCustomizer != null) {
+ keysWithSummary.addAll(deviceSpecificSettingsCustomizer.getPreferenceKeysWithSummary());
+ }
+
+ return keysWithSummary;
+ }
+
+ @Override
+ protected void onSharedPreferenceChanged(Preference preference) {
+ if (deviceSpecificSettingsCustomizer != null) {
+ deviceSpecificSettingsCustomizer.onPreferenceChange(preference, DeviceSpecificSettingsFragment.this);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsHandler.java
new file mode 100644
index 0000000..ae6d31a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsHandler.java
@@ -0,0 +1,83 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings;
+
+import android.content.Context;
+
+import androidx.activity.result.ActivityResultCaller;
+import androidx.annotation.NonNull;
+import androidx.preference.Preference;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+/**
+ * A device-specific preference handler, that allows for {@link nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator}s to register
+ * their own preferences dynamically.
+ */
+public interface DeviceSpecificSettingsHandler extends ActivityResultCaller {
+ /**
+ * Finds a preference with the given key. Returns null if the preference is not found.
+ *
+ * @param preferenceKey the preference key.
+ * @return the preference, if found.
+ */
+ <T extends Preference> T findPreference(@NonNull CharSequence preferenceKey);
+
+ /**
+ * Adds a preference handler for a preference key. This handler sends the preference to the device on change.
+ *
+ * @param preferenceKey the preference key.
+ */
+ void addPreferenceHandlerFor(final String preferenceKey);
+
+ /**
+ * Notify the device that a preference changed.
+ *
+ * @param preferenceKey the preference key.
+ */
+ void notifyPreferenceChanged(final String preferenceKey);
+
+ /**
+ * Adds a preference handler for a preference key. On change, this handler calls the provided extra listener, and then sends the preference to the device.
+ *
+ * @param preferenceKey the preference key.
+ * @param extraListener the extra listener.
+ */
+ void addPreferenceHandlerFor(final String preferenceKey, Preference.OnPreferenceChangeListener extraListener);
+
+ /**
+ * Sets the input type flags for an EditText preference.
+ *
+ * @param preferenceKey the preference key.
+ * @param editTypeFlags the edit type {@link android.text.InputType} flags.
+ */
+ void setInputTypeFor(final String preferenceKey, final int editTypeFlags);
+
+ /**
+ * Get the device associated with this {@link DeviceSpecificSettingsHandler}.
+ *
+ * @return the {@link GBDevice}.
+ */
+ GBDevice getDevice();
+
+ /**
+ * Get the current {@link Context}.
+ *
+ * @return the {@link Context}.
+ */
+ Context getContext();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java
new file mode 100644
index 0000000..bd041bf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsScreen.java
@@ -0,0 +1,59 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings;
+
+import androidx.annotation.XmlRes;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public enum DeviceSpecificSettingsScreen {
+ ACTIVITY_INFO("pref_screen_activity_info", R.xml.devicesettings_root_activity_info),
+ AUDIO("pref_screen_audio", R.xml.devicesettings_root_audio),
+ AUTHENTICATION("pref_screen_authentication", R.xml.devicesettings_root_authentication),
+ BATTERY("pref_screen_battery", R.xml.devicesettings_root_battery),
+ CALENDAR("pref_screen_calendar", R.xml.devicesettings_root_calendar),
+ CALLS_AND_NOTIFICATIONS("pref_screen_calls_and_notifications", R.xml.devicesettings_root_calls_and_notifications),
+ CONNECTION("pref_screen_connection", R.xml.devicesettings_root_connection),
+ 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),
+ HEALTH("pref_screen_health", R.xml.devicesettings_root_health),
+ TOUCH_OPTIONS("pref_screen_touch_options", R.xml.devicesettings_root_touch_options),
+ SOUND("pref_screen_sound", R.xml.devicesettings_root_touch_options),
+ ;
+
+ private final String key;
+ @XmlRes
+ private final int xml;
+
+ DeviceSpecificSettingsScreen(final String key, final int xml) {
+ this.key = key;
+ this.xml = xml;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public int getXml() {
+ return xml;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java
new file mode 100644
index 0000000..fc44df6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryActivityV2.java
@@ -0,0 +1,932 @@
+/* Copyright (C) 2023-2024 Andreas B枚hler, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.discovery;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.GB.toast;
+
+import android.Manifest;
+import android.app.Activity;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanRecord;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.content.BroadcastReceiver;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.location.LocationManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.os.ParcelUuid;
+import android.os.Parcelable;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.util.Pair;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.ProgressBar;
+import android.widget.Spinner;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RequiresPermission;
+import androidx.annotation.StringRes;
+import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
+import androidx.core.view.MenuProvider;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.DebugActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.adapter.DeviceCandidateAdapter;
+import nodomain.freeyourgadget.gadgetbridge.adapter.SpinnerWithIconAdapter;
+import nodomain.freeyourgadget.gadgetbridge.adapter.SpinnerWithIconItem;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.BondingInterface;
+import nodomain.freeyourgadget.gadgetbridge.util.BondingUtil;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+
+public class DiscoveryActivityV2 extends AbstractGBActivity implements AdapterView.OnItemClickListener,
+ AdapterView.OnItemLongClickListener,
+ BondingInterface,
+ GBScanEventProcessor.Callback,
+ MenuProvider {
+ private static final Logger LOG = LoggerFactory.getLogger(DiscoveryActivityV2.class);
+
+ private final Handler handler = new Handler();
+
+ private static final long SCAN_DURATION = 30000; // 30s
+ private static final long LIST_REFRESH_THRESHOLD_MS = 1000L;
+ private long lastListRefresh = System.currentTimeMillis();
+
+ private final ScanCallback bleScanCallback = new BleScanCallback();
+
+ private ProgressBar bluetoothProgress;
+
+ private DeviceCandidateAdapter deviceCandidateAdapter;
+ private GBDeviceCandidate deviceTarget;
+ private BluetoothAdapter adapter;
+
+ private Button startButton;
+ private boolean scanning;
+
+ private long selectedUnsupportedDeviceKey = DebugActivity.SELECT_DEVICE;
+
+ private final Runnable stopRunnable = () -> {
+ stopDiscovery();
+ LOG.info("Discovery stopped by thread timeout.");
+ };
+
+ private final BroadcastReceiver bluetoothReceiver = new BluetoothReceiver();
+
+ private final GBScanEventProcessor deviceFoundProcessor = new GBScanEventProcessor(this);
+
+ // Array to back the adapter for the UI
+ private final ArrayList<GBDeviceCandidate> deviceCandidates = new ArrayList<>();
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ BondingUtil.handleActivityResult(this, requestCode, resultCode, data);
+ }
+
+ @Nullable
+ private GBDeviceCandidate getCandidateFromMAC(final BluetoothDevice device) {
+ for (final GBDeviceCandidate candidate : deviceCandidates) {
+ if (candidate.getMacAddress().equals(device.getAddress())) {
+ return candidate;
+ }
+ }
+ LOG.warn("This shouldn't happen unless the list somehow emptied itself, device MAC: {}", device.getAddress());
+ return null;
+ }
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ loadSettings();
+
+ setContentView(R.layout.activity_discovery);
+
+ addMenuProvider(this);
+
+ startButton = findViewById(R.id.discovery_start);
+ startButton.setOnClickListener(v -> toggleDiscovery());
+
+ bluetoothProgress = findViewById(R.id.discovery_progressbar);
+ bluetoothProgress.setProgress(0);
+ bluetoothProgress.setIndeterminate(true);
+ bluetoothProgress.setVisibility(View.GONE);
+
+ deviceCandidateAdapter = new DeviceCandidateAdapter(this, deviceCandidates);
+
+ final ListView deviceCandidatesView = findViewById(R.id.discovery_device_candidates_list);
+ deviceCandidatesView.setAdapter(deviceCandidateAdapter);
+ deviceCandidatesView.setOnItemClickListener(this);
+ deviceCandidatesView.setOnItemLongClickListener(this);
+
+ registerBroadcastReceivers();
+
+ checkAndRequestLocationPermission();
+
+ if (!startDiscovery()) {
+ /* if we couldn't start scanning, go back to the main page.
+ A toast will have been shown explaining what's wrong */
+ finish();
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull final Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelableArrayList("deviceCandidates", deviceCandidates);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ final List<Parcelable> restoredCandidates = savedInstanceState.getParcelableArrayList("deviceCandidates");
+ if (restoredCandidates != null) {
+ deviceCandidates.clear();
+ for (final Parcelable p : restoredCandidates) {
+ final GBDeviceCandidate candidate = (GBDeviceCandidate) p;
+ deviceCandidates.add(candidate);
+ }
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ unregisterBroadcastReceivers();
+ stopDiscovery();
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onStop() {
+ unregisterBroadcastReceivers();
+ stopDiscovery();
+ super.onStop();
+ }
+
+ @Override
+ protected void onPause() {
+ unregisterBroadcastReceivers();
+ stopDiscovery();
+ super.onPause();
+ }
+
+ @Override
+ protected void onResume() {
+ loadSettings();
+ registerBroadcastReceivers();
+ super.onResume();
+ }
+
+ private void refreshDeviceList(final boolean throttle) {
+ handler.post(() -> {
+ if (throttle && System.currentTimeMillis() - lastListRefresh < LIST_REFRESH_THRESHOLD_MS) {
+ return;
+ }
+
+ LOG.debug("Refreshing device list");
+
+ // Clear and re-populate the list. deviceFoundProcessor keeps insertion order, so newer devices
+ // will still be at the end
+ deviceCandidates.clear();
+ deviceCandidates.addAll(deviceFoundProcessor.getDevices());
+
+ deviceCandidateAdapter.notifyDataSetChanged();
+
+ lastListRefresh = System.currentTimeMillis();
+ });
+ }
+
+ private void toggleDiscovery() {
+ if (scanning) {
+ stopDiscovery();
+ } else {
+ startDiscovery();
+ }
+ }
+
+ private boolean startDiscovery() {
+ if (scanning) {
+ LOG.warn("Not starting discovery, because already scanning.");
+ return false;
+ }
+
+ LOG.info("Starting discovery");
+ startButton.setText(getString(R.string.discovery_stop_scanning));
+
+ deviceFoundProcessor.clear();
+ deviceFoundProcessor.start();
+
+ refreshDeviceList(false);
+
+ // Pre-add currently connected devices, as those will not trigger discovery events
+ // Paired devices that are not connected do not need to be added, as those will be discovered
+ try {
+ final Set<BluetoothDevice> pairedDevices = BluetoothAdapter.getDefaultAdapter().getBondedDevices();
+ for (final BluetoothDevice device : pairedDevices) {
+ try {
+ final Method isConnectedMethod = device.getClass().getMethod("isConnected");
+ final Boolean isConnected = (Boolean) isConnectedMethod.invoke(device);
+ if (isConnected!= null && isConnected) {
+ LOG.debug("Pre-adding already bonded device {}", device.getAddress());
+ deviceFoundProcessor.scheduleProcessing(new GBScanEvent(device, (short) -1, null));
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to check whether {} is connected", device.getAddress());
+ }
+ }
+ } catch (final SecurityException e) {
+ LOG.error("Failed to pre-add paired devices", e);
+ }
+
+ try {
+ if (!ensureBluetoothReady()) {
+ toast(DiscoveryActivityV2.this, getString(R.string.discovery_enable_bluetooth), Toast.LENGTH_SHORT, GB.ERROR);
+ return false;
+ }
+
+ if (GB.supportsBluetoothLE()) {
+ startBTLEDiscovery();
+ }
+ startBTDiscovery();
+
+ bluetoothProgress.setVisibility(View.VISIBLE);
+ } catch (final SecurityException e) {
+ LOG.error("SecurityException on startDiscovery");
+ deviceFoundProcessor.stop();
+ return false;
+ }
+
+ setScanning(true);
+
+ return true;
+ }
+
+ private void stopDiscovery() {
+ LOG.info("Stopping discovery");
+ try {
+ stopBTDiscovery();
+ stopBLEDiscovery();
+ } catch (final SecurityException e) {
+ LOG.error("SecurityException on stopDiscovery");
+ }
+ setScanning(false);
+ deviceFoundProcessor.stop();
+ handler.removeMessages(0, stopRunnable);
+
+ // Refresh the device list one last time when finishing
+ refreshDeviceList(false);
+ }
+
+ public void setScanning(final boolean scanning) {
+ this.scanning = scanning;
+ if (scanning) {
+ startButton.setText(getString(R.string.discovery_stop_scanning));
+ } else {
+ startButton.setText(getString(R.string.discovery_start_scanning));
+ bluetoothProgress.setVisibility(View.GONE);
+ }
+ }
+
+ @RequiresPermission("android.permission.BLUETOOTH_SCAN")
+ private void startBTLEDiscovery() {
+ LOG.info("Starting BLE discovery");
+
+ handler.removeMessages(0, stopRunnable);
+ handler.sendMessageDelayed(getPostMessage(stopRunnable), SCAN_DURATION);
+
+ // Filters being non-null would be a very good idea with background scan, but in this case,
+ // not really required.
+ // TODO getScanFilters maybe
+ adapter.getBluetoothLeScanner().startScan(null, getScanSettings(), bleScanCallback);
+
+ LOG.debug("Bluetooth LE discovery started successfully");
+ }
+
+ @RequiresPermission("android.permission.BLUETOOTH_SCAN")
+ private void stopBLEDiscovery() {
+ if (adapter == null) {
+ return;
+ }
+
+ final BluetoothLeScanner bluetoothLeScanner = adapter.getBluetoothLeScanner();
+ if (bluetoothLeScanner == null) {
+ LOG.warn("Could not get BluetoothLeScanner()!");
+ return;
+ }
+
+ if (bleScanCallback == null) {
+ LOG.warn("newLeScanCallback == null!");
+ return;
+ }
+
+ try {
+ bluetoothLeScanner.stopScan(bleScanCallback);
+ } catch (final NullPointerException e) {
+ LOG.warn("Internal NullPointerException when stopping the scan!");
+ return;
+ }
+
+ LOG.debug("Stopped BLE discovery");
+ }
+
+ /**
+ * Starts a regular Bluetooth scan
+ */
+ @RequiresPermission("android.permission.BLUETOOTH_SCAN")
+ private void startBTDiscovery() {
+ LOG.info("Starting BT discovery");
+ try {
+ // LineageOS quirk, can't stop scan properly,
+ // if scan has been started by something else
+ stopBTDiscovery();
+ } catch (final Exception ignored) {
+ }
+ handler.removeMessages(0, stopRunnable);
+ handler.sendMessageDelayed(getPostMessage(stopRunnable), SCAN_DURATION);
+
+ if (adapter.startDiscovery()) {
+ LOG.debug("Discovery started successfully");
+ } else {
+ LOG.error("Discovery starting failed");
+ }
+ }
+
+ @RequiresPermission("android.permission.BLUETOOTH_SCAN")
+ private void stopBTDiscovery() {
+ if (adapter == null) return;
+ adapter.cancelDiscovery();
+ LOG.info("Stopped BT discovery");
+ }
+
+ private void bluetoothStateChanged(final int newState) {
+ if (newState == BluetoothAdapter.STATE_ON) {
+ this.adapter = BluetoothAdapter.getDefaultAdapter();
+ startButton.setEnabled(true);
+ } else {
+ this.adapter = null;
+ startButton.setEnabled(false);
+ bluetoothProgress.setVisibility(View.GONE);
+ }
+ }
+
+ private boolean checkBluetoothAvailable() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
+ LOG.warn("No BLUETOOTH_SCAN permission");
+ this.adapter = null;
+ return false;
+ }
+ if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
+ LOG.warn("No BLUETOOTH_CONNECT permission");
+ this.adapter = null;
+ return false;
+ }
+ }
+
+ final BluetoothManager bluetoothService = (BluetoothManager) getSystemService(BLUETOOTH_SERVICE);
+ if (bluetoothService == null) {
+ LOG.warn("No bluetooth service available");
+ this.adapter = null;
+ return false;
+ }
+
+ final BluetoothAdapter adapter = bluetoothService.getAdapter();
+ if (adapter == null) {
+ LOG.warn("No bluetooth adapter available");
+ this.adapter = null;
+ return false;
+ }
+
+ if (!adapter.isEnabled()) {
+ LOG.warn("Bluetooth not enabled");
+ final Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
+ startActivity(enableBtIntent);
+ this.adapter = null;
+ return false;
+ }
+
+ this.adapter = adapter;
+ return true;
+ }
+
+ @RequiresPermission("android.permission.BLUETOOTH_SCAN")
+ private boolean ensureBluetoothReady() {
+ final boolean available = checkBluetoothAvailable();
+ startButton.setEnabled(available);
+
+ if (available) {
+ adapter.cancelDiscovery();
+ // must not return the result of cancelDiscovery()
+ // appears to return false when currently not scanning
+ return true;
+ }
+ return false;
+ }
+
+ private static ScanSettings getScanSettings() {
+ final ScanSettings.Builder builder = new ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ builder.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES);
+ builder.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE);
+ builder.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT);
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ builder.setPhy(ScanSettings.PHY_LE_ALL_SUPPORTED);
+ }
+
+ return builder.build();
+ }
+
+ private List<ScanFilter> getScanFilters() {
+ final List<ScanFilter> allFilters = new ArrayList<>();
+ for (DeviceType deviceType : DeviceType.values()) {
+ allFilters.addAll(deviceType.getDeviceCoordinator().createBLEScanFilters());
+ }
+ return allFilters;
+ }
+
+ private Message getPostMessage(final Runnable runnable) {
+ final Message message = Message.obtain(handler, runnable);
+ message.obj = runnable;
+ return message;
+ }
+
+ private void showWarnDialog(@StringRes final int message) {
+ new MaterialAlertDialogBuilder(getContext())
+ .setMessage(message)
+ .setPositiveButton(R.string.ok, (dialog, whichButton) -> {})
+ .show();
+ }
+
+ private void checkAndRequestLocationPermission() {
+ /* This is more or less a copy of what's in ControlCenterv2, but
+ we do this in case the permissions weren't requested since there
+ is no way we can scan without this stuff */
+ List<String> wantedPermissions = new ArrayList<>();
+ if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
+ LOG.error("No permission to access coarse location!");
+ wantedPermissions.add(Manifest.permission.ACCESS_COARSE_LOCATION);
+ }
+ if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
+ LOG.error("No permission to access fine location!");
+ wantedPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION);
+ }
+ // if we need location permissions, request both together to avoid a bunch of dialogs
+ if (wantedPermissions.size() > 0) {
+ toast(DiscoveryActivityV2.this, getString(R.string.error_no_location_access), Toast.LENGTH_SHORT, GB.ERROR);
+ ActivityCompat.requestPermissions(this, wantedPermissions.toArray(new String[0]), 0);
+ wantedPermissions.clear();
+ }
+ // Now we have to request background location separately!
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED) {
+ LOG.error("No permission to access background location!");
+ toast(DiscoveryActivityV2.this, getString(R.string.error_no_location_access), Toast.LENGTH_SHORT, GB.ERROR);
+ ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION}, 0);
+ }
+ }
+ // Now, we can request Bluetooth permissions....
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
+ LOG.error("No permission to access Bluetooth scanning!");
+ toast(DiscoveryActivityV2.this, getString(R.string.error_no_bluetooth_scan), Toast.LENGTH_SHORT, GB.ERROR);
+ wantedPermissions.add(Manifest.permission.BLUETOOTH_SCAN);
+ }
+ if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
+ LOG.error("No permission to access Bluetooth connection!");
+ toast(DiscoveryActivityV2.this, getString(R.string.error_no_bluetooth_connect), Toast.LENGTH_SHORT, GB.ERROR);
+ wantedPermissions.add(Manifest.permission.BLUETOOTH_CONNECT);
+ }
+ }
+ if (wantedPermissions.size() > 0) {
+ GB.toast(this, getString(R.string.permission_granting_mandatory), Toast.LENGTH_LONG, GB.ERROR);
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ ActivityCompat.requestPermissions(this, wantedPermissions.toArray(new String[0]), 0);
+ } else {
+ ActivityResultLauncher<String[]> requestMultiplePermissionsLauncher =
+ registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), isGranted -> {
+ if (!isGranted.containsValue(false)) {
+ // Permission is granted. Continue the action or workflow in your app.
+ // should we do startDiscovery here??
+ } else {
+ // Explain to the user that the feature is unavailable because the feature requires a permission that the user has denied.
+ GB.toast(this, getString(R.string.permission_granting_mandatory), Toast.LENGTH_LONG, GB.ERROR);
+ }
+ });
+ requestMultiplePermissionsLauncher.launch(wantedPermissions.toArray(new String[0]));
+ }
+ }
+
+ LocationManager locationManager = (LocationManager) DiscoveryActivityV2.this.getSystemService(Context.LOCATION_SERVICE);
+ try {
+ if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
+ // Do nothing
+ LOG.debug("Some location provider is enabled, assuming location is enabled");
+ } else {
+ toast(DiscoveryActivityV2.this, getString(R.string.require_location_provider), Toast.LENGTH_LONG, GB.ERROR);
+ DiscoveryActivityV2.this.startActivity(new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS));
+ // We can't be sure location was enabled, cancel scan start and wait for new user action
+ toast(DiscoveryActivityV2.this, getString(R.string.error_location_enabled_mandatory), Toast.LENGTH_SHORT, GB.ERROR);
+ return;
+ }
+ } catch (final Exception ex) {
+ LOG.error("Exception when checking location status", ex);
+ }
+ LOG.info("Permissions seems to be fine for scanning");
+ }
+
+ @Override
+ public void onItemClick(final AdapterView<?> parent, final View view, final int position, final long id) {
+ final GBDeviceCandidate deviceCandidate = deviceCandidates.get(position);
+ if (deviceCandidate == null) {
+ LOG.error("Device candidate clicked, but item not found");
+ return;
+ }
+
+ DeviceType deviceType = DeviceHelper.getInstance().resolveDeviceType(deviceCandidate);
+
+ if (!deviceType.isSupported()) {
+ LOG.warn("Unsupported device candidate {}", deviceCandidate);
+ copyDetailsToClipboard(deviceCandidate);
+ return;
+ }
+
+ stopDiscovery();
+
+ final DeviceCoordinator coordinator = deviceType.getDeviceCoordinator();
+ LOG.info("Using device candidate {} with coordinator {}", deviceCandidate, coordinator.getClass());
+
+ if (coordinator.getBondingStyle() == DeviceCoordinator.BONDING_STYLE_REQUIRE_KEY) {
+ final SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(deviceCandidate.getMacAddress());
+
+ final String authKey = sharedPrefs.getString("authkey", null);
+ if (authKey == null || authKey.isEmpty()) {
+ showWarnDialog(R.string.discovery_need_to_enter_authkey);
+ return;
+ } else if (!coordinator.validateAuthKey(authKey)) {
+ showWarnDialog(R.string.discovery_entered_invalid_authkey);
+ return;
+ }
+ }
+
+ if (coordinator.suggestUnbindBeforePair() && deviceCandidate.isBonded()) {
+ new MaterialAlertDialogBuilder(getContext())
+ .setTitle(R.string.unbind_before_pair_title)
+ .setMessage(R.string.unbind_before_pair_message)
+ .setIcon(R.drawable.ic_warning_gray)
+ .setPositiveButton(R.string.ok, (dialog, whichButton) -> {
+ startPair(deviceCandidate, coordinator);
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ } else {
+ startPair(deviceCandidate, coordinator);
+ }
+ }
+
+ private void startPair(final GBDeviceCandidate deviceCandidate, final DeviceCoordinator coordinator) {
+ final Class<? extends Activity> pairingActivity = coordinator.getPairingActivity();
+ if (pairingActivity != null) {
+ final Intent intent = new Intent(this, pairingActivity);
+ intent.putExtra(DeviceCoordinator.EXTRA_DEVICE_CANDIDATE, deviceCandidate);
+ startActivity(intent);
+ } else {
+ if (coordinator.getBondingStyle() == DeviceCoordinator.BONDING_STYLE_NONE ||
+ coordinator.getBondingStyle() == DeviceCoordinator.BONDING_STYLE_LAZY) {
+ LOG.info("No bonding needed, according to coordinator, so connecting right away");
+ BondingUtil.connectThenComplete(this, deviceCandidate);
+ return;
+ }
+
+ try {
+ this.deviceTarget = deviceCandidate;
+ BondingUtil.initiateCorrectBonding(this, deviceCandidate, coordinator);
+ } catch (final Exception e) {
+ LOG.error("Error pairing device {}", deviceCandidate.getMacAddress(), e);
+ }
+ }
+ }
+
+ private void copyDetailsToClipboard(final GBDeviceCandidate deviceCandidate) {
+ final List<String> deviceDetails = new ArrayList<>();
+ deviceDetails.add(deviceCandidate.getName());
+ deviceDetails.add(deviceCandidate.getMacAddress());
+ try {
+ for (final ParcelUuid uuid : deviceCandidate.getServiceUuids()) {
+ deviceDetails.add(uuid.getUuid().toString());
+ }
+ } catch (final Exception e) {
+ LOG.error("Error collecting device uuids", e);
+ }
+ final String clipboardData = TextUtils.join(", ", deviceDetails);
+ final ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
+ final ClipData clip = ClipData.newPlainText(deviceCandidate.getName(), clipboardData);
+ clipboard.setPrimaryClip(clip);
+ toast(this, "Device details copied to clipboard", Toast.LENGTH_SHORT, GB.INFO);
+ }
+
+ @Override
+ public boolean onItemLongClick(final AdapterView<?> adapterView, final View view, final int position, final long id) {
+ stopDiscovery();
+
+ final GBDeviceCandidate deviceCandidate = deviceCandidates.get(position);
+ if (deviceCandidate == null) {
+ LOG.error("Device candidate clicked, but item not found");
+ return true;
+ }
+
+ DeviceType deviceType = DeviceHelper.getInstance().resolveDeviceType(deviceCandidate);
+
+ if (!deviceType.isSupported()) {
+ showUnsupportedDeviceDialog(deviceCandidate);
+ return true;
+ }
+
+ final DeviceCoordinator coordinator = deviceType.getDeviceCoordinator();
+ final GBDevice device = DeviceHelper.getInstance().toSupportedDevice(deviceCandidate);
+ if (coordinator.getSupportedDeviceSpecificSettings(device) == null) {
+ return true;
+ }
+
+ final Intent startIntent;
+ startIntent = new Intent(this, DeviceSettingsActivity.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ if (coordinator.getBondingStyle() == DeviceCoordinator.BONDING_STYLE_REQUIRE_KEY) {
+ startIntent.putExtra(DeviceSettingsActivity.MENU_ENTRY_POINT, DeviceSettingsActivity.MENU_ENTRY_POINTS.AUTH_SETTINGS);
+ } else {
+ startIntent.putExtra(DeviceSettingsActivity.MENU_ENTRY_POINT, DeviceSettingsActivity.MENU_ENTRY_POINTS.DEVICE_SETTINGS);
+ }
+ startActivity(startIntent);
+ return true;
+ }
+
+ private void showUnsupportedDeviceDialog(final GBDeviceCandidate deviceCandidate) {
+ LOG.info("Unsupported device candidate selected: {}", deviceCandidate);
+
+ final Map<String, Pair<Long, Integer>> allDevices = DebugActivity.getAllSupportedDevices(getApplicationContext());
+
+ final LinearLayout linearLayout = new LinearLayout(DiscoveryActivityV2.this);
+ linearLayout.setOrientation(LinearLayout.VERTICAL);
+
+ final ArrayList<SpinnerWithIconItem> deviceListArray = new ArrayList<>();
+ for (Map.Entry<String, Pair<Long, Integer>> item : allDevices.entrySet()) {
+ deviceListArray.add(new SpinnerWithIconItem(item.getKey(), item.getValue().first, item.getValue().second));
+ }
+ final SpinnerWithIconAdapter deviceListAdapter = new SpinnerWithIconAdapter(
+ DiscoveryActivityV2.this,
+ R.layout.spinner_with_image_layout,
+ R.id.spinner_item_text,
+ deviceListArray
+ );
+
+ final Spinner deviceListSpinner = new Spinner(DiscoveryActivityV2.this);
+ deviceListSpinner.setAdapter(deviceListAdapter);
+ deviceListSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(final AdapterView<?> parent, final View view, final int pos, final long id) {
+ final SpinnerWithIconItem selectedItem = (SpinnerWithIconItem) parent.getItemAtPosition(pos);
+ selectedUnsupportedDeviceKey = selectedItem.getId();
+ }
+
+ @Override
+ public void onNothingSelected(final AdapterView<?> arg0) {
+ }
+ });
+ linearLayout.addView(deviceListSpinner);
+
+ final LinearLayout macLayout = new LinearLayout(DiscoveryActivityV2.this);
+ macLayout.setOrientation(LinearLayout.HORIZONTAL);
+ macLayout.setPadding(20, 0, 20, 0);
+ linearLayout.addView(macLayout);
+
+ new MaterialAlertDialogBuilder(DiscoveryActivityV2.this)
+ .setCancelable(true)
+ .setTitle(R.string.add_test_device)
+ .setView(linearLayout)
+ .setPositiveButton(R.string.ok, (dialog, which) -> {
+ if (selectedUnsupportedDeviceKey != DebugActivity.SELECT_DEVICE) {
+ DebugActivity.createTestDevice(
+ DiscoveryActivityV2.this,
+ selectedUnsupportedDeviceKey,
+ deviceCandidate.getMacAddress(),
+ deviceCandidate.getName()
+ );
+ finish();
+ }
+ })
+ .setNegativeButton(R.string.Cancel, (dialog, which) -> {
+ })
+ .show();
+ }
+
+ @Override
+ public void onBondingComplete(final boolean success) {
+ finish();
+ }
+
+ @Override
+ public GBDeviceCandidate getCurrentTarget() {
+ return this.deviceTarget;
+ }
+
+ @Override
+ public String getMacAddress() {
+ return deviceTarget.getDevice().getAddress();
+ }
+
+ @Override
+ public boolean getAttemptToConnect() {
+ return true;
+ }
+
+ @Override
+ public void registerBroadcastReceivers() {
+ final IntentFilter bluetoothIntents = new IntentFilter();
+ bluetoothIntents.addAction(BluetoothDevice.ACTION_FOUND);
+ bluetoothIntents.addAction(BluetoothDevice.ACTION_UUID);
+ bluetoothIntents.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
+ bluetoothIntents.addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED);
+ bluetoothIntents.addAction(BluetoothAdapter.ACTION_STATE_CHANGED);
+
+ ContextCompat.registerReceiver(this, bluetoothReceiver, bluetoothIntents, ContextCompat.RECEIVER_EXPORTED);
+ }
+
+ @Override
+ public void unregisterBroadcastReceivers() {
+ AndroidUtils.safeUnregisterBroadcastReceiver(this, bluetoothReceiver);
+ }
+
+ @Override
+ public Context getContext() {
+ return this;
+ }
+
+ private void loadSettings() {
+ final Prefs prefs = GBApplication.getPrefs();
+ deviceFoundProcessor.setDiscoverUnsupported(prefs.getBoolean("discover_unsupported_devices", false));
+ }
+
+ @Override
+ public void onDeviceChanged() {
+ refreshDeviceList(true);
+ }
+
+ @Override
+ public void onCreateMenu(@NonNull final Menu menu, @NonNull final MenuInflater menuInflater) {
+ menuInflater.inflate(R.menu.menu_discovery, menu);
+ }
+
+ @Override
+ public boolean onMenuItemSelected(@NonNull final MenuItem menuItem) {
+ final int itemId = menuItem.getItemId();
+ if (itemId == R.id.prefs_discovery_pairing) {
+ final Intent intent = new Intent(DiscoveryActivityV2.this, DiscoveryPairingPreferenceActivity.class);
+ startActivity(intent);
+ return true;
+ }
+ return false;
+ }
+
+ private final class BluetoothReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ switch (Objects.requireNonNull(intent.getAction())) {
+ case BluetoothAdapter.ACTION_DISCOVERY_STARTED: {
+ LOG.debug("ACTION_DISCOVERY_STARTED");
+ break;
+ }
+ case BluetoothAdapter.ACTION_STATE_CHANGED: {
+ LOG.debug("ACTION_STATE_CHANGED ");
+ bluetoothStateChanged(intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.STATE_OFF));
+ break;
+ }
+ case BluetoothDevice.ACTION_FOUND: {
+ final BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ if (device == null) {
+ LOG.warn("ACTION_FOUND with null device");
+ return;
+ }
+ LOG.debug("ACTION_FOUND {}", device.getAddress());
+ final short rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, GBDevice.RSSI_UNKNOWN);
+ deviceFoundProcessor.scheduleProcessing(new GBScanEvent(device, rssi, null));
+ break;
+ }
+ case BluetoothDevice.ACTION_UUID: {
+ final BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ if (device == null) {
+ LOG.warn("ACTION_UUID with null device");
+ return;
+ }
+ LOG.debug("ACTION_UUID {}", device.getAddress());
+ final short rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, GBDevice.RSSI_UNKNOWN);
+ final Parcelable[] uuids = intent.getParcelableArrayExtra(BluetoothDevice.EXTRA_UUID);
+ final ParcelUuid[] uuids2 = AndroidUtils.toParcelUuids(uuids);
+ deviceFoundProcessor.scheduleProcessing(new GBScanEvent(device, rssi, uuids2));
+ break;
+ }
+ case BluetoothDevice.ACTION_BOND_STATE_CHANGED: {
+ LOG.debug("ACTION_BOND_STATE_CHANGED");
+ final BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ if (device != null) {
+ final int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE);
+ LOG.debug("Bond state: {}", bondState);
+
+ if (bondState == BluetoothDevice.BOND_BONDED) {
+ BondingUtil.handleDeviceBonded((BondingInterface) context, getCandidateFromMAC(device));
+ }
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ private final class BleScanCallback extends ScanCallback {
+ @Override
+ public void onScanResult(final int callbackType, final ScanResult result) {
+ super.onScanResult(callbackType, result);
+ try {
+ final ScanRecord scanRecord = result.getScanRecord();
+ ParcelUuid[] uuids = null;
+ if (scanRecord != null) {
+ final List<ParcelUuid> serviceUuids = scanRecord.getServiceUuids();
+ if (serviceUuids != null) {
+ uuids = serviceUuids.toArray(new ParcelUuid[0]);
+ }
+ }
+ final BluetoothDevice device = result.getDevice();
+ final short rssi = (short) result.getRssi();
+ LOG.debug("BLE result: {}, {}, {}", device.getAddress(), ((scanRecord != null) ? scanRecord.getBytes().length : -1), rssi);
+ deviceFoundProcessor.scheduleProcessing(new GBScanEvent(device, rssi, uuids));
+ } catch (final Exception e) {
+ LOG.warn("Error handling BLE scan result", e);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryPairingPreferenceActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryPairingPreferenceActivity.java
new file mode 100644
index 0000000..11c933f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/DiscoveryPairingPreferenceActivity.java
@@ -0,0 +1,54 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.discovery;
+
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.preference.PreferenceFragmentCompat;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class DiscoveryPairingPreferenceActivity extends AbstractSettingsActivityV2 {
+ @Override
+ protected String fragmentTag() {
+ return DiscoveryPairingPreferenceFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return new DiscoveryPairingPreferenceFragment();
+ }
+
+ public static class DiscoveryPairingPreferenceFragment extends AbstractPreferenceFragment {
+ static final String FRAGMENT_TAG = "DISCOVERY_PAIRING_PREFERENCES_FRAGMENT";
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ setPreferencesFromResource(R.xml.discovery_pairing_preferences, rootKey);
+
+ findPreference("prefs_general_key_auto_reconnect_scan").setOnPreferenceChangeListener((preference, newValue) -> {
+ GB.toast(GBApplication.getContext().getString(R.string.prompt_restart_gadgetbridge), Toast.LENGTH_LONG, GB.INFO);
+ return true;
+ });
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEvent.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEvent.java
new file mode 100644
index 0000000..c77e55e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEvent.java
@@ -0,0 +1,53 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.discovery;
+
+import android.bluetooth.BluetoothDevice;
+import android.os.ParcelUuid;
+
+import androidx.annotation.Nullable;
+
+/**
+ * A scan event originating from either BT or BLE scan. References the BluetoothDevice, rssi,
+ * and service UUIDs, if any.
+ */
+public class GBScanEvent {
+ private final BluetoothDevice device;
+ private final short rssi;
+
+ @Nullable
+ private final ParcelUuid[] serviceUuids;
+
+ public GBScanEvent(final BluetoothDevice device, final short rssi, @Nullable final ParcelUuid[] serviceUuids) {
+ this.device = device;
+ this.rssi = rssi;
+ this.serviceUuids = serviceUuids;
+ }
+
+ public BluetoothDevice getDevice() {
+ return device;
+ }
+
+ public short getRssi() {
+ return rssi;
+ }
+
+ @Nullable
+ public ParcelUuid[] getServiceUuids() {
+ return serviceUuids;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEventProcessor.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEventProcessor.java
new file mode 100644
index 0000000..220c03b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/discovery/GBScanEventProcessor.java
@@ -0,0 +1,262 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.discovery;
+
+import android.os.ParcelUuid;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+
+/**
+ * A dedicated thread to process {@link GBScanEvent}s. This class keeps a map from mac address to
+ * GBDeviceCandidate, with the current known state of each candidate.
+ * <p>
+ * Processing works as follows:
+ * - The processor consumes mac addresses from the eventsToProcessQueue
+ * - Mac addresses are placed on the queue when there are one or more new GBScanEvents to process in
+ * the eventsToProcessMap map
+ * - The eventsToProcessMap contains a list of events per device, so that they can be processed in batch
+ * - The GBDeviceEvent for the corresponding mac address in candidatesByAddress gets updated with the new
+ * information, and matched against the coordinators.
+ */
+public final class GBScanEventProcessor implements Runnable {
+ private static final Logger LOG = LoggerFactory.getLogger(GBScanEventProcessor.class);
+
+ private static final ParcelUuid ZERO_UUID = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000");
+
+ private final Map<String, GBDeviceCandidate> candidatesByAddress = new LinkedHashMap<>();
+
+ private final BlockingQueue<String> eventsToProcessQueue = new LinkedBlockingQueue<>();
+ private final Map<String, List<GBScanEvent>> eventsToProcessMap = new HashMap<>();
+
+ private boolean discoverUnsupported = false;
+
+ private volatile boolean running = false;
+ private Thread thread = null;
+
+ private final Callback callback;
+
+ public GBScanEventProcessor(final Callback callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public void run() {
+ LOG.info("Device Found Processor Thread started.");
+
+ while (running) {
+ try {
+ LOG.debug("Polling found devices queue, current size = {}", eventsToProcessQueue.size());
+ final String candidateAddress = eventsToProcessQueue.take();
+ if (candidateAddress != null) {
+ if (processAllScanEvents(candidateAddress)) {
+ callback.onDeviceChanged();
+ }
+ }
+ } catch (final InterruptedException e) {
+ LOG.warn("Processing thread interrupted");
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ }
+
+ public void start() {
+ if (running) {
+ LOG.warn("Already running!");
+ return;
+ }
+
+ running = true;
+ thread = new Thread("Gadgetbridge Device Found Processor Thread") {
+ @Override
+ public void run() {
+ GBScanEventProcessor.this.run();
+ }
+ };
+ thread.start();
+ }
+
+ public void stop() {
+ running = false;
+
+ if (thread != null) {
+ thread.interrupt();
+ thread = null;
+ }
+ }
+
+ public void clear() {
+ candidatesByAddress.clear();
+ eventsToProcessMap.clear();
+ eventsToProcessQueue.clear();
+ }
+
+ public void setDiscoverUnsupported(boolean discoverUnsupported) {
+ this.discoverUnsupported = discoverUnsupported;
+ }
+
+ /**
+ * Returns the current list of GBDeviceCandidates. The candidates are cloned, since they can be
+ * modified concurrently by the processor.
+ */
+ public List<GBDeviceCandidate> getDevices() {
+ final List<GBDeviceCandidate> ret = new ArrayList<>();
+ // candidatesByAddress keeps insertion order, so newer devices will be at the end
+ synchronized (candidatesByAddress) {
+ for (final Map.Entry<String, GBDeviceCandidate> entry : candidatesByAddress.entrySet()) {
+ ret.add(entry.getValue().clone());
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Schedule a {@link GBScanEvent} to be processed asynchronously.
+ */
+ public void scheduleProcessing(final GBScanEvent event) {
+ LOG.debug("Scheduling {} for processing ({})", event.getDevice().getAddress(), event.getServiceUuids());
+
+ final String address = event.getDevice().getAddress();
+ synchronized (eventsToProcessMap) {
+ if (!eventsToProcessMap.containsKey(address)) {
+ eventsToProcessMap.put(address, new LinkedList<>());
+ }
+ Objects.requireNonNull(eventsToProcessMap.get(address)).add(event);
+ }
+
+ try {
+ eventsToProcessQueue.put(address);
+ } catch (final InterruptedException e) {
+ LOG.error("Failed to put device on processing queue", e);
+ }
+ }
+
+ private boolean processCandidate(final GBDeviceCandidate candidate) {
+ LOG.debug("found device: {}, {}", candidate.getName(), candidate.getMacAddress());
+ if (LOG.isDebugEnabled()) {
+ final ParcelUuid[] uuids = candidate.getServiceUuids();
+ if (uuids != null && uuids.length > 0) {
+ for (ParcelUuid uuid : uuids) {
+ LOG.debug(" supports uuid: " + uuid.toString());
+ }
+ }
+ }
+
+ final DeviceType deviceType = DeviceHelper.getInstance().resolveDeviceType(candidate, false);
+
+ if (deviceType.isSupported() || discoverUnsupported) {
+ synchronized (candidatesByAddress) {
+ candidatesByAddress.put(candidate.getMacAddress(), candidate);
+ }
+ }
+
+ return deviceType.isSupported();
+ }
+
+ private boolean processAllScanEvents(final String address) {
+ final List<GBScanEvent> events;
+ synchronized (eventsToProcessMap) {
+ events = eventsToProcessMap.remove(address);
+ }
+ if (events == null || events.isEmpty()) {
+ LOG.warn("Attempted to process {}, but found no events", address);
+ return false;
+ }
+
+ LOG.debug("Processing {} events for {}", events.size(), address);
+
+ GBDeviceCandidate candidate = candidatesByAddress.get(address);
+
+ String previousName = null;
+ ParcelUuid[] previousUuids = null;
+ boolean firstTime = false;
+
+ if (candidate == null) {
+ // First time we see this device
+ LOG.debug("Found {} for the first time", address);
+ firstTime = true;
+ final GBScanEvent firstEvent = events.get(0);
+ events.remove(0);
+ candidate = new GBDeviceCandidate(firstEvent.getDevice(), firstEvent.getRssi(), firstEvent.getServiceUuids());
+ } else {
+ previousName = candidate.getName();
+ previousUuids = candidate.getServiceUuids();
+ }
+
+ // Update the device with the remaining events
+ for (final GBScanEvent event : events) {
+ candidate.setRssi(event.getRssi());
+ candidate.addUuids(event.getServiceUuids());
+ }
+
+ candidate.refreshNameIfUnknown();
+ try {
+ candidate.addUuids(candidate.getDevice().getUuids());
+ } catch (final SecurityException e) {
+ LOG.error("SecurityException on candidate.getDevice().getUuids()");
+ }
+
+ if (!firstTime) {
+ if (Objects.equals(candidate.getName(), previousName) && Arrays.equals(candidate.getServiceUuids(), previousUuids)) {
+ // Neither name nor uuids changed, do not reprocess
+ LOG.trace("Not reprocessing {} due to no changes", address);
+ return false;
+ }
+ }
+
+ if (processCandidate(candidate)) {
+ LOG.info(
+ "Device {} ({}) is supported as '{}' without scanning services",
+ candidate.getDevice(),
+ candidate.getName(),
+ DeviceHelper.getInstance().resolveDeviceType(candidate, false)
+ );
+ return true;
+ }
+
+ if (candidate.getServiceUuids().length == 0 || (candidate.getServiceUuids().length == 1 && candidate.getServiceUuids()[0].equals(ZERO_UUID))) {
+ LOG.debug("Fetching uuids for {} with sdp", candidate.getDevice().getAddress());
+ try {
+ candidate.getDevice().fetchUuidsWithSdp();
+ } catch (final SecurityException e) {
+ LOG.error("SecurityException on candidate.getDevice().fetchUuidsWithSdp()");
+ }
+ }
+
+ return true;
+ }
+
+ public interface Callback {
+ void onDeviceChanged();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/files/FileManagerActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/files/FileManagerActivity.java
new file mode 100644
index 0000000..517dfd3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/files/FileManagerActivity.java
@@ -0,0 +1,135 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.files;
+
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.SearchView;
+import androidx.core.view.MenuProvider;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class FileManagerActivity extends AbstractGBActivity implements MenuProvider {
+ private static final Logger LOG = LoggerFactory.getLogger(FileManagerActivity.class);
+
+ public static final String EXTRA_PATH = "path";
+
+ private SearchView searchView;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_file_manager);
+ addMenuProvider(this);
+
+ final RecyclerView fileListView = findViewById(R.id.fileListView);
+ fileListView.setLayoutManager(new LinearLayoutManager(this));
+
+ final File directory;
+ if (getIntent().hasExtra(EXTRA_PATH)) {
+ directory = new File(getIntent().getStringExtra(EXTRA_PATH));
+ } else {
+ try {
+ directory = FileUtils.getExternalFilesDir();
+ } catch (final IOException e) {
+ GB.toast("Failed to list external files dir", Toast.LENGTH_LONG, GB.ERROR);
+ LOG.error("Failed to list external files dir", e);
+ finish();
+ return;
+ }
+ }
+
+ if (!directory.isDirectory()) {
+ GB.toast("Not a directory", Toast.LENGTH_LONG, GB.ERROR);
+ finish();
+ return;
+ }
+
+ final ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(directory.getName());
+ }
+
+ final FileManagerAdapter appListAdapter = new FileManagerAdapter(this, directory);
+
+ fileListView.setAdapter(appListAdapter);
+
+ searchView = findViewById(R.id.fileListSearchView);
+ searchView.setIconifiedByDefault(false);
+ searchView.setVisibility(View.GONE);
+ searchView.setIconified(false);
+ searchView.setQuery("", false);
+ searchView.clearFocus();
+ searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(final String query) {
+ return false;
+ }
+
+ @Override
+ public boolean onQueryTextChange(final String newText) {
+ appListAdapter.getFilter().filter(newText);
+ return true;
+ }
+ });
+ }
+
+ @Override
+ public void onCreateMenu(@NonNull final Menu menu, @NonNull final MenuInflater menuInflater) {
+ menuInflater.inflate(R.menu.menu_file_manager, menu);
+ }
+
+ @Override
+ public boolean onMenuItemSelected(@NonNull final MenuItem menuItem) {
+ final int itemId = menuItem.getItemId();
+ if (itemId == R.id.file_manager_search) {
+ searchView.setVisibility(View.VISIBLE);
+ searchView.requestFocus();
+ searchView.setIconified(true);
+ searchView.setIconified(false);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ onBackPressed();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/files/FileManagerAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/files/FileManagerAdapter.java
new file mode 100644
index 0000000..1f78997
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/files/FileManagerAdapter.java
@@ -0,0 +1,206 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.activities.files;
+
+import android.content.Context;
+import android.content.Intent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Filter;
+import android.widget.ImageView;
+import android.widget.PopupMenu;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.content.res.AppCompatResources;
+import androidx.recyclerview.widget.RecyclerView;
+
+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.Arrays;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class FileManagerAdapter extends RecyclerView.Adapter<FileManagerAdapter.FileManagerViewHolder> {
+ protected static final Logger LOG = LoggerFactory.getLogger(FileManagerAdapter.class);
+
+ private final List<File> fileList;
+ private final Context mContext;
+
+ private final FileFilter fileFilter;
+
+ public FileManagerAdapter(final Context context, final File directory) {
+ mContext = context;
+
+ // FIXME: This can be slow, make it async
+ fileList = new ArrayList<>(Arrays.asList(directory.listFiles()));
+ fileList.sort((f1, f2) -> {
+ if (f1.isDirectory() && f2.isFile())
+ return -1;
+ if (f1.isFile() && f2.isDirectory())
+ return 1;
+
+ return String.CASE_INSENSITIVE_ORDER.compare(f1.getName(), f2.getName());
+ });
+
+ fileFilter = new FileFilter(this, fileList);
+ }
+
+ @NonNull
+ @Override
+ public FileManagerViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
+ final View view = LayoutInflater.from(mContext).inflate(R.layout.item_file_manager, parent, false);
+ return new FileManagerViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(final FileManagerViewHolder holder, int position) {
+ final File file = fileList.get(position);
+
+ holder.name.setText(file.getName());
+ if (file.isDirectory()) {
+ holder.icon.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_folder));
+ holder.description.setVisibility(View.GONE);
+ holder.menu.setVisibility(View.GONE);
+ } else {
+ holder.icon.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_file_open));
+ holder.description.setText(formatFileSize(file.length()));
+ holder.description.setVisibility(View.VISIBLE);
+ holder.menu.setVisibility(View.VISIBLE);
+ holder.menu.setOnClickListener(view -> {
+ final PopupMenu menu = new PopupMenu(mContext, holder.menu);
+ menu.inflate(R.menu.file_manager_file);
+ menu.setOnMenuItemClickListener(item -> {
+ final int itemId = item.getItemId();
+ if (itemId == R.id.file_manager_file_menu_share) {
+ try {
+ AndroidUtils.shareFile(mContext, file, "*/*");
+ } catch (final IOException e) {
+ GB.toast("Failed to share file", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ return true;
+ }
+
+ return false;
+ });
+ menu.show();
+ });
+ }
+
+ holder.itemView.setOnClickListener(v -> {
+ if (file.isDirectory()) {
+ final Intent fileManagerIntent = new Intent(mContext, FileManagerActivity.class);
+ fileManagerIntent.putExtra(FileManagerActivity.EXTRA_PATH, file.getPath());
+ mContext.startActivity(fileManagerIntent);
+ } else {
+ try {
+ AndroidUtils.viewFile(file.getAbsolutePath(), "*/*", mContext);
+ } catch (final IOException e) {
+ GB.toast("Failed to open file", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+ });
+ }
+
+ @Override
+ public int getItemCount() {
+ return fileList.size();
+ }
+
+ public Filter getFilter() {
+ return fileFilter;
+ }
+
+ public static class FileManagerViewHolder extends RecyclerView.ViewHolder {
+ final ImageView icon;
+ final TextView name;
+ final TextView description;
+ final ImageView menu;
+
+ FileManagerViewHolder(View itemView) {
+ super(itemView);
+
+ icon = itemView.findViewById(R.id.file_icon);
+ name = itemView.findViewById(R.id.file_name);
+ description = itemView.findViewById(R.id.file_description);
+ menu = itemView.findViewById(R.id.file_menu);
+ }
+ }
+
+ private static final DecimalFormat SIZE_FORMAT = new DecimalFormat("#,##0.#");
+
+ public static String formatFileSize(final long size) {
+ if (size <= 0) return "0";
+ final String[] units = new String[]{"B", "kB", "MB", "GB", "TB", "PB", "EB"};
+ int digitGroups = (int) (Math.log10(size) / Math.log10(1024));
+ return SIZE_FORMAT.format(size / Math.pow(1024, digitGroups)) + " " + units[digitGroups];
+ }
+
+ private class FileFilter extends Filter {
+ private final FileManagerAdapter adapter;
+ private final List<File> originalList;
+ private final List<File> filteredList;
+
+ private FileFilter(final FileManagerAdapter adapter, final List<File> originalList) {
+ super();
+ this.originalList = new ArrayList<>(originalList);
+ this.filteredList = new ArrayList<>();
+ this.adapter = adapter;
+ }
+
+
+ @Override
+ protected FilterResults performFiltering(final CharSequence filter) {
+ filteredList.clear();
+ final Filter.FilterResults results = new Filter.FilterResults();
+
+ if (filter == null || filter.length() == 0)
+ filteredList.addAll(originalList);
+ else {
+ final String filterPattern = filter.toString().toLowerCase().trim();
+
+ for (File f : originalList) {
+ final CharSequence name = f.getName();
+ if (name.toString().toLowerCase().contains(filterPattern)) {
+ filteredList.add(f);
+ }
+ }
+ }
+
+ results.values = filteredList;
+ results.count = filteredList.size();
+ return results;
+ }
+
+ @Override
+ protected void publishResults(final CharSequence constraint, final FilterResults filterResults) {
+ adapter.fileList.clear();
+ adapter.fileList.addAll((List<File>) filterResults.values);
+ adapter.notifyDataSetChanged();
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsActivity.java
new file mode 100644
index 0000000..4b84a45
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsActivity.java
@@ -0,0 +1,87 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards;
+
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.ActivityCompat;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.PreferenceScreen;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+
+public class LoyaltyCardsSettingsActivity extends AbstractSettingsActivityV2 implements
+ PreferenceFragmentCompat.OnPreferenceStartScreenCallback,
+ ActivityCompat.OnRequestPermissionsResultCallback {
+
+ public static final int PERMISSION_REQUEST_CODE = 0;
+
+ private GBDevice device;
+
+ @Override
+ protected String fragmentTag() {
+ return LoyaltyCardsSettingsFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return LoyaltyCardsSettingsFragment.newInstance(device);
+ }
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(final int requestCode,
+ @NonNull final String[] permissions,
+ @NonNull final int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+
+ if (requestCode != PERMISSION_REQUEST_CODE) {
+ return;
+ }
+
+ if (grantResults.length == 0) {
+ return;
+ }
+
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ final FragmentManager fragmentManager = getSupportFragmentManager();
+ final Fragment fragment = fragmentManager.findFragmentByTag(LoyaltyCardsSettingsFragment.FRAGMENT_TAG);
+ if (fragment == null) {
+ return;
+ }
+
+ if (fragment instanceof LoyaltyCardsSettingsFragment) {
+ ((LoyaltyCardsSettingsFragment) fragment).reloadPreferences(null);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsConst.java
new file mode 100644
index 0000000..42bd0e2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsConst.java
@@ -0,0 +1,37 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards;
+
+public final class LoyaltyCardsSettingsConst {
+ public static final String PREF_KEY_LOYALTY_CARDS = "pref_key_loyalty_cards";
+
+ public static final String PREF_KEY_HEADER_LOYALTY_CARDS_CATIMA = "pref_key_header_loyalty_cards_catima";
+ public static final String PREF_KEY_HEADER_LOYALTY_CARDS_SYNC = "pref_key_header_loyalty_cards_sync";
+ public static final String PREF_KEY_HEADER_LOYALTY_CARDS_SYNC_OPTIONS = "pref_key_header_loyalty_cards_sync_options";
+
+ public static final String LOYALTY_CARDS_CATIMA_PACKAGE = "loyalty_cards_catima_package";
+ public static final String LOYALTY_CARDS_OPEN_CATIMA = "loyalty_cards_open_catima";
+ public static final String LOYALTY_CARDS_CATIMA_NOT_INSTALLED = "loyalty_cards_catima_not_installed";
+ public static final String LOYALTY_CARDS_CATIMA_NOT_COMPATIBLE = "loyalty_cards_catima_not_compatible";
+ public static final String LOYALTY_CARDS_INSTALL_CATIMA = "loyalty_cards_install_catima";
+ public static final String LOYALTY_CARDS_CATIMA_PERMISSIONS = "loyalty_cards_catima_permissions";
+ public static final String LOYALTY_CARDS_SYNC = "loyalty_cards_sync";
+ public static final String LOYALTY_CARDS_SYNC_GROUPS_ONLY = "loyalty_cards_sync_groups_only";
+ public static final String LOYALTY_CARDS_SYNC_GROUPS = "loyalty_cards_sync_groups";
+ public static final String LOYALTY_CARDS_SYNC_ARCHIVED = "loyalty_cards_sync_archived";
+ public static final String LOYALTY_CARDS_SYNC_STARRED = "loyalty_cards_sync_starred";
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsFragment.java
new file mode 100644
index 0000000..87659ae
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/loyaltycards/LoyaltyCardsSettingsFragment.java
@@ -0,0 +1,260 @@
+/* Copyright (C) 2023-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_CATIMA_NOT_COMPATIBLE;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_CATIMA_NOT_INSTALLED;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_CATIMA_PACKAGE;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_CATIMA_PERMISSIONS;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_INSTALL_CATIMA;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_OPEN_CATIMA;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_GROUPS;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.PREF_KEY_HEADER_LOYALTY_CARDS_CATIMA;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.PREF_KEY_HEADER_LOYALTY_CARDS_SYNC;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.PREF_KEY_HEADER_LOYALTY_CARDS_SYNC_OPTIONS;
+
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.widget.Toast;
+
+import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
+import androidx.preference.ListPreference;
+import androidx.preference.MultiSelectListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.CatimaContentProvider;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.CatimaManager;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+public class LoyaltyCardsSettingsFragment extends AbstractPreferenceFragment {
+ private static final Logger LOG = LoggerFactory.getLogger(LoyaltyCardsSettingsFragment.class);
+
+ static final String FRAGMENT_TAG = "LOYALTY_CARDS_SETTINGS_FRAGMENT";
+
+ private GBDevice device;
+
+ private void setSettingsFileSuffix(final String settingsFileSuffix) {
+ final Bundle args = new Bundle();
+ args.putString("settingsFileSuffix", settingsFileSuffix);
+ setArguments(args);
+ }
+
+ private void setDevice(final GBDevice device) {
+ final Bundle args = getArguments() != null ? getArguments() : new Bundle();
+ args.putParcelable("device", device);
+ setArguments(args);
+ }
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ final Bundle arguments = getArguments();
+ if (arguments == null) {
+ return;
+ }
+ final String settingsFileSuffix = arguments.getString("settingsFileSuffix", null);
+ this.device = arguments.getParcelable("device");
+
+ if (settingsFileSuffix == null) {
+ return;
+ }
+
+ getPreferenceManager().setSharedPreferencesName("devicesettings_" + settingsFileSuffix);
+ setPreferencesFromResource(R.xml.loyalty_cards, rootKey);
+
+ reloadPreferences(null);
+ }
+
+ static LoyaltyCardsSettingsFragment newInstance(GBDevice device) {
+ final String settingsFileSuffix = device.getAddress();
+ final LoyaltyCardsSettingsFragment fragment = new LoyaltyCardsSettingsFragment();
+ fragment.setSettingsFileSuffix(settingsFileSuffix);
+ fragment.setDevice(device);
+
+ return fragment;
+ }
+
+ protected void reloadPreferences(final String catimaPackageName) {
+ final CatimaManager catimaManager = new CatimaManager(requireContext());
+
+ final List<CharSequence> installedCatimaPackages = catimaManager.findInstalledCatimaPackages();
+ final boolean catimaInstalled = !installedCatimaPackages.isEmpty();
+
+ final ListPreference catimaPackagePreference = findPreference(LOYALTY_CARDS_CATIMA_PACKAGE);
+
+ if (catimaPackagePreference != null) {
+ catimaPackagePreference.setEntries(installedCatimaPackages.toArray(new CharSequence[0]));
+ catimaPackagePreference.setEntryValues(installedCatimaPackages.toArray(new CharSequence[0]));
+ catimaPackagePreference.setVisible(installedCatimaPackages.size() > 1);
+ catimaPackagePreference.setOnPreferenceChangeListener((preference, newValue) -> {
+ LOG.info("Catima package preference changed to {}", newValue);
+ reloadPreferences((String) newValue);
+ return true;
+ });
+
+ if (catimaInstalled) {
+ // Ensure the currently selected value is actually an installed Catima package
+ if (StringUtils.isNullOrEmpty(catimaPackagePreference.getValue()) || !installedCatimaPackages.contains(catimaPackagePreference.getValue())) {
+ catimaPackagePreference.setValue(installedCatimaPackages.get(0).toString());
+ }
+ }
+ }
+
+ final String finalCatimaPackageName;
+
+ if (catimaPackageName != null) {
+ finalCatimaPackageName = catimaPackageName;
+ } else if (catimaPackagePreference != null) {
+ finalCatimaPackageName = catimaPackagePreference.getValue();
+ } else {
+ LOG.warn("This should never happen - catima package not found");
+ finalCatimaPackageName = "this.should.never.happen";
+ }
+
+ final CatimaContentProvider catima = new CatimaContentProvider(requireContext(), finalCatimaPackageName);
+
+ final Preference openCatimaPreference = findPreference(LOYALTY_CARDS_OPEN_CATIMA);
+ if (openCatimaPreference != null) {
+ openCatimaPreference.setVisible(catimaInstalled);
+ openCatimaPreference.setOnPreferenceClickListener(preference -> {
+ if (catimaPackagePreference != null) {
+ final PackageManager packageManager = requireContext().getPackageManager();
+ final Intent launchIntent = packageManager.getLaunchIntentForPackage(finalCatimaPackageName);
+ if (launchIntent != null) {
+ startActivity(launchIntent);
+ }
+ }
+ return true;
+ });
+ }
+
+ final Preference catimaNotInstalledPreference = findPreference(LOYALTY_CARDS_CATIMA_NOT_INSTALLED);
+ if (catimaNotInstalledPreference != null) {
+ catimaNotInstalledPreference.setVisible(!catimaInstalled);
+ }
+
+ final Preference installCatimaPreference = findPreference(LOYALTY_CARDS_INSTALL_CATIMA);
+ if (installCatimaPreference != null) {
+ installCatimaPreference.setVisible(!catimaInstalled);
+ installCatimaPreference.setOnPreferenceClickListener(preference -> {
+ installCatima();
+ return true;
+ });
+ }
+
+ final boolean permissionGranted = ContextCompat.checkSelfPermission(requireContext(), catima.getReadPermission()) == PackageManager.PERMISSION_GRANTED;
+ final Preference catimaPermissionsPreference = findPreference(LOYALTY_CARDS_CATIMA_PERMISSIONS);
+ if (catimaPermissionsPreference != null) {
+ catimaPermissionsPreference.setVisible(catimaInstalled && !permissionGranted);
+ catimaPermissionsPreference.setOnPreferenceClickListener(preference -> {
+ ActivityCompat.requestPermissions(
+ requireActivity(),
+ new String[]{catima.getReadPermission()},
+ LoyaltyCardsSettingsActivity.PERMISSION_REQUEST_CODE
+ );
+ return true;
+ });
+ }
+
+ final boolean catimaCompatible = catima.isCatimaCompatible();
+ final Preference catimaNotCompatiblePreference = findPreference(LOYALTY_CARDS_CATIMA_NOT_COMPATIBLE);
+ if (catimaNotCompatiblePreference != null) {
+ catimaNotCompatiblePreference.setVisible(catimaInstalled && permissionGranted && !catimaCompatible);
+ }
+
+ final Preference syncPreference = findPreference(LOYALTY_CARDS_SYNC);
+ if (syncPreference != null) {
+ syncPreference.setEnabled(catimaInstalled && catimaCompatible && permissionGranted);
+ syncPreference.setOnPreferenceClickListener(preference -> {
+ catimaManager.sync(device);
+ return true;
+ });
+ }
+
+ final PreferenceCategory headerCatima = findPreference(PREF_KEY_HEADER_LOYALTY_CARDS_CATIMA);
+ if (headerCatima != null) {
+ boolean allHidden = true;
+ for (int i = 0; i < headerCatima.getPreferenceCount(); i++) {
+ if (headerCatima.getPreference(i).isVisible()) {
+ allHidden = false;
+ break;
+ }
+ }
+ headerCatima.setVisible(!allHidden);
+ }
+
+ if (catimaInstalled && catimaCompatible && permissionGranted) {
+ final MultiSelectListPreference syncGroups = findPreference(LOYALTY_CARDS_SYNC_GROUPS);
+ if (syncGroups != null) {
+ final List<String> groups = catima.getGroups();
+ final CharSequence[] entries = groups.toArray(new CharSequence[0]);
+ syncGroups.setEntries(entries);
+ syncGroups.setEntryValues(entries);
+
+ // Remove groups that do not exist anymore from the summary
+ final Set<String> values = new HashSet<>(syncGroups.getValues());
+ final Set<String> toRemove = new HashSet<>();
+ for (final String group : values) {
+ if (!groups.contains(group)) {
+ toRemove.add(group);
+ }
+ }
+ values.removeAll(toRemove);
+ syncGroups.setSummary(TextUtils.join(", ", values));
+ }
+ }
+
+ final boolean allowSync = catimaInstalled && permissionGranted && catimaCompatible;
+ final PreferenceCategory syncCategory = findPreference(PREF_KEY_HEADER_LOYALTY_CARDS_SYNC);
+ if (syncCategory != null) {
+ for (int i = 0; i < syncCategory.getPreferenceCount(); i++) {
+ syncCategory.getPreference(i).setEnabled(allowSync);
+ }
+ }
+ final PreferenceCategory syncOptionsCategory = findPreference(PREF_KEY_HEADER_LOYALTY_CARDS_SYNC_OPTIONS);
+ if (syncOptionsCategory != null) {
+ for (int i = 0; i < syncOptionsCategory.getPreferenceCount(); i++) {
+ syncOptionsCategory.getPreference(i).setEnabled(allowSync);
+ }
+ }
+ }
+
+ private void installCatima() {
+ try {
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=me.hackerchick.catima")));
+ } catch (final ActivityNotFoundException e) {
+ GB.toast(requireContext(), requireContext().getString(R.string.loyalty_cards_install_catima_fail), Toast.LENGTH_LONG, GB.WARN);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/musicmanager/MusicManagerActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/musicmanager/MusicManagerActivity.java
new file mode 100644
index 0000000..b6999f9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/musicmanager/MusicManagerActivity.java
@@ -0,0 +1,720 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.musicmanager;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.os.Handler;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.ImageButton;
+import android.widget.PopupMenu;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResult;
+import androidx.activity.result.ActivityResultCallback;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.FwAppInstallerActivity;
+import nodomain.freeyourgadget.gadgetbridge.adapter.MusicListAdapter;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceMusic;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceMusicPlaylist;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GridAutoFitLayoutManager;
+
+public class MusicManagerActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(MusicManagerActivity.class);
+
+ public static final String ACTION_MUSIC_DATA
+ = "nodomain.freeyourgadget.gadgetbridge.musicmanager.action.music_data";
+ public static final String ACTION_MUSIC_UPDATE
+ = "nodomain.freeyourgadget.gadgetbridge.musicmanager.action.music_update";
+
+ protected GBDevice mGBDevice = null;
+
+ private View loadingView = null;
+ private TextView musicDeviceInfo = null;
+
+ private final List<GBDeviceMusic> allMusic = new ArrayList<>();
+
+ private final List<GBDeviceMusic> musicList = new ArrayList<>();
+ private MusicListAdapter musicAdapter;
+
+ private final List<GBDeviceMusicPlaylist> playlists = new ArrayList<>();
+ private ArrayAdapter<GBDeviceMusicPlaylist> playlistAdapter;
+
+ private View playlistSpinnerLayout;
+ private Spinner playlistsSpinner;
+
+ private FloatingActionButton fabMusicUpload;
+ private FloatingActionButton fabMusicPlaylistAdd;
+
+
+ private MaterialToolbar bottomToolbar;
+ private TextView selectionInfo;
+ private ImageButton selectionAddPlaylist;
+ private ImageButton selectionDelete;
+
+ private int maxMusicCount = 0;
+ private int maxPlaylistCount = 0;
+
+ public GBDevice getGBDevice() {
+ return mGBDevice;
+ }
+
+ Handler loadingTimeout = new Handler();
+ Runnable loadingRunnable = new Runnable() {
+ @Override
+ public void run() {
+ GB.toast(getString(R.string.music_error), Toast.LENGTH_SHORT, GB.ERROR);
+ stopLoading();
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_musicmanager);
+
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ mGBDevice = extras.getParcelable(GBDevice.EXTRA_DEVICE);
+ }
+ if (mGBDevice == null) {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+
+ fabMusicUpload = findViewById(R.id.fab_music_upload);
+ assert fabMusicUpload != null;
+ fabMusicUpload.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("audio/*");
+ openAudioActivityResultLauncher.launch(intent);
+ }
+ });
+
+ fabMusicPlaylistAdd = findViewById(R.id.fab_music_playlist_add);
+ assert fabMusicPlaylistAdd != null;
+ fabMusicPlaylistAdd.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ addMusicPlaylist();
+ }
+ });
+
+ hideActionButtons();
+
+ bottomToolbar = findViewById(R.id.bottom_toolbar);
+
+ ImageButton cancelSelection = findViewById(R.id.music_cancel_selection);
+ selectionInfo = findViewById(R.id.music_selection_info);
+ selectionAddPlaylist = findViewById(R.id.music_multiselect_add_to_playlist);
+ selectionDelete = findViewById(R.id.music_multiselect_delete);
+
+
+ RecyclerView musicListView = findViewById(R.id.music_songs_list);
+ loadingView = findViewById(R.id.music_loading);
+
+ musicDeviceInfo = findViewById(R.id.music_device_info);
+
+ musicListView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+ @Override
+ public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
+ if (dy > 0) {
+ hideActionButtons();
+ } else if (dy < 0) {
+ showActionButtons();
+ }
+ }
+ });
+ musicListView.setLayoutManager(new GridAutoFitLayoutManager(this, 300));
+
+ musicAdapter = new MusicListAdapter(
+ musicList,
+ new MusicListAdapter.onDataAction() {
+ @Override
+ public void onItemClick(View view, GBDeviceMusic music) {
+ openPopupMenu(view, music);
+ }
+
+ @Override
+ public void onMultiSelect(int count, boolean limit) {
+ multiSelect(count, limit);
+ }
+
+ }
+ );
+ musicListView.setAdapter(musicAdapter);
+
+ cancelSelection.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ musicAdapter.clearSelectedItems();
+ }
+ });
+
+ selectionAddPlaylist.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ List<GBDeviceMusic> musics = musicAdapter.getSelectedItems();
+ if(!musics.isEmpty()) {
+ ArrayList<Integer> list = new ArrayList<>();
+ for(GBDeviceMusic music: musics) {
+ list.add(music.getId());
+ }
+ addMusicSongToPlaylist(list);
+ }
+ }
+ });
+
+ selectionDelete.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ List<GBDeviceMusic> musics = musicAdapter.getSelectedItems();
+ if(!musics.isEmpty()) {
+ ArrayList<Integer> list = new ArrayList<>();
+ for(GBDeviceMusic music: musics) {
+ list.add(music.getId());
+ }
+ deleteMusicMultipleConfirm(list);
+ }
+ }
+ });
+
+ playlistSpinnerLayout = findViewById(R.id.music_playlists_layout);
+
+ playlistsSpinner = findViewById(R.id.music_playlists);
+
+ ImageButton renamePlaylist = findViewById(R.id.music_playlist_rename);
+ assert renamePlaylist != null;
+ renamePlaylist.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ renameMusicPlaylist((GBDeviceMusicPlaylist) playlistsSpinner.getSelectedItem());
+ }
+ });
+
+ ImageButton deletePlaylist = findViewById(R.id.music_playlist_delete);
+ assert deletePlaylist != null;
+ deletePlaylist.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ deleteMusicPlaylist((GBDeviceMusicPlaylist) playlistsSpinner.getSelectedItem());
+ }
+ });
+
+
+ playlistsSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) {
+ GBDeviceMusicPlaylist item = (GBDeviceMusicPlaylist) adapterView.getItemAtPosition(i);
+ musicAdapter.clearSelectedItems();
+ if (item.getId() == 0) {
+ deletePlaylist.setVisibility(View.GONE);
+ renamePlaylist.setVisibility(View.GONE);
+
+ } else {
+ deletePlaylist.setVisibility(View.VISIBLE);
+ renamePlaylist.setVisibility(View.VISIBLE);
+ }
+ updateCurrentMusicList();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> adapterView) {
+
+ }
+ });
+
+ playlistAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, playlists);
+ initPlaylists();
+
+ playlistAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ playlistsSpinner.setAdapter(playlistAdapter);
+ }
+
+ private void multiSelect(int count, boolean limit) {
+ if(limit) {
+ GB.toast(this, R.string.music_multiselect_limit, Toast.LENGTH_LONG, GB.WARN);
+ }
+ if(count > 0) {
+ hideActionButtons();
+ bottomToolbar.setVisibility(View.VISIBLE);
+ selectionInfo.setText("" + count);
+ if(playlists.size() <= 1) {
+ selectionAddPlaylist.setVisibility(View.GONE);
+ } else {
+ selectionAddPlaylist.setVisibility(View.VISIBLE);
+ }
+ } else {
+ bottomToolbar.setVisibility(View.GONE);
+ showActionButtons();
+ }
+ }
+
+ private void hideActionButtons() {
+ fabMusicUpload.hide();
+ fabMusicPlaylistAdd.hide();
+
+ }
+
+ private void showActionButtons() {
+ fabMusicUpload.show();
+ if(maxPlaylistCount > 0) {
+ fabMusicPlaylistAdd.show();
+ }
+ }
+
+
+ private void startLoading(long timeout) {
+ hideActionButtons();
+ loadingView.setVisibility(View.VISIBLE);
+ if(timeout > 0) {
+ loadingTimeout.postDelayed(loadingRunnable, timeout);
+ }
+ }
+ private void startLoading() {
+ startLoading(4000);
+ }
+
+ private void stopLoading() {
+ loadingTimeout.removeCallbacks(loadingRunnable);
+ loadingView.setVisibility(View.GONE);
+ musicAdapter.clearSelectedItems();
+ showActionButtons();
+ }
+
+ private void updateCurrentMusicList() {
+ GBDeviceMusicPlaylist current = (GBDeviceMusicPlaylist) playlistsSpinner.getSelectedItem();
+ musicList.clear();
+ if (current.getId() == 0) {
+ musicList.addAll(allMusic);
+ } else {
+ List<GBDeviceMusic> filtered = allMusic.stream().filter(m -> current.getMusicIds().contains(m.getId())).collect(Collectors.toList());
+ musicList.addAll(filtered);
+ }
+ musicAdapter.notifyDataSetChanged();
+ }
+
+ private void initPlaylists() {
+ playlists.clear();
+ playlists.add(new GBDeviceMusicPlaylist(0,this.getString(R.string.music_all_songs),null));
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(ACTION_MUSIC_DATA);
+ filter.addAction(ACTION_MUSIC_UPDATE);
+
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filter);
+
+ // Load music data without timeout
+ startLoading(0);
+ GBApplication.deviceService(mGBDevice).onMusicListReq();
+ }
+
+ @Override
+ public void onStop() {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
+ super.onStop();
+ }
+
+ ActivityResultLauncher<Intent> openAudioActivityResultLauncher = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ new ActivityResultCallback<ActivityResult>() {
+ @Override
+ public void onActivityResult(ActivityResult result) {
+ if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+ Intent startIntent = new Intent(MusicManagerActivity.this, FwAppInstallerActivity.class);
+ startIntent.setAction(Intent.ACTION_VIEW);
+ startIntent.setDataAndType(result.getData().getData(), null);
+ startActivity(startIntent);
+ }
+ }
+ });
+
+
+ public boolean openPopupMenu(View view, GBDeviceMusic music) {
+ PopupMenu popupMenu = new PopupMenu(this, view);
+ popupMenu.getMenuInflater().inflate(R.menu.musicmanager_context, popupMenu.getMenu());
+ Menu menu = popupMenu.getMenu();
+
+ if (playlists.size() <= 1) {
+ menu.removeItem(R.id.musicmanager_add_to_playlist);
+ }
+
+ GBDeviceMusicPlaylist current = (GBDeviceMusicPlaylist) playlistsSpinner.getSelectedItem();
+ if (current.getId() == 0) {
+ menu.removeItem(R.id.musicmanager_delete_from_playlist);
+ } else {
+ menu.removeItem(R.id.musicmanager_delete);
+ }
+
+ popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
+ public boolean onMenuItemClick(MenuItem item) {
+ return onPopupItemSelected(item, music);
+ }
+ }
+ );
+
+ popupMenu.show();
+ return true;
+ }
+
+ private boolean onPopupItemSelected(final MenuItem item, final GBDeviceMusic music) {
+ final int itemId = item.getItemId();
+ if (itemId == R.id.musicmanager_delete || itemId == R.id.musicmanager_delete_from_playlist) {
+ deleteMusicConfirm(music);
+ return true;
+ } else if (itemId == R.id.musicmanager_add_to_playlist) {
+ ArrayList<Integer> list = new ArrayList<>();
+ list.add(music.getId());
+ addMusicSongToPlaylist(list);
+ return true;
+ }
+ return false;
+ }
+
+ private void deleteMusicConfirm(final GBDeviceMusic music) {
+ ArrayList<Integer> list = new ArrayList<>();
+ list.add(music.getId());
+
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.Delete)
+ .setMessage(this.getString(R.string.music_delete_confirm_description, music.getTitle()))
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
+ deleteMusicFromDevice((GBDeviceMusicPlaylist) playlistsSpinner.getSelectedItem(), list);
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+ }
+
+ private void deleteMusicMultipleConfirm(final ArrayList<Integer> list) {
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.Delete)
+ .setMessage(this.getString(R.string.music_delete_multiple_confirm_description, list.size()))
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
+ deleteMusicFromDevice((GBDeviceMusicPlaylist) playlistsSpinner.getSelectedItem(), list);
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+ }
+
+ private void addPlaylistToDevice(final String playlistName) {
+ startLoading();
+ GBApplication.deviceService(mGBDevice).onMusicOperation(0, -1, playlistName, null);
+ }
+
+ private void deletePlaylistFromDevice(final GBDeviceMusicPlaylist playlist) {
+ startLoading();
+ GBApplication.deviceService(mGBDevice).onMusicOperation(1, playlist.getId(), null, null);
+ }
+
+ private void renamePlaylistOnDevice(final GBDeviceMusicPlaylist playlist, String newPlaylistName) {
+ startLoading();
+ GBApplication.deviceService(mGBDevice).onMusicOperation(2, playlist.getId(), newPlaylistName, null);
+ }
+
+ private void addMusicToDevicePlaylist(GBDeviceMusicPlaylist playlist, final ArrayList<Integer> list) {
+ startLoading();
+ GBApplication.deviceService(mGBDevice).onMusicOperation(3, playlist.getId(), null, list);
+ }
+
+ private void deleteMusicFromDevice(GBDeviceMusicPlaylist playlist, final ArrayList<Integer> list) {
+ startLoading();
+ GBApplication.deviceService(mGBDevice).onMusicOperation(4, playlist.getId(), null, list);
+ }
+
+ private void addMusicPlaylist() {
+ final EditText input = new EditText(this);
+ input.setInputType(InputType.TYPE_CLASS_TEXT);
+
+ FrameLayout container = new FrameLayout(this);
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.leftMargin = getResources().getDimensionPixelSize(R.dimen.dialog_margin);
+ params.rightMargin = getResources().getDimensionPixelSize(R.dimen.dialog_margin);
+ input.setLayoutParams(params);
+ container.addView(input);
+
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.music_new_playlist)
+ .setView(container)
+ .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
+ String playlistName = input.getText().toString();
+ if(!playlistName.isEmpty()) {
+ addPlaylistToDevice(playlistName);
+ }
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ private void renameMusicPlaylist(GBDeviceMusicPlaylist playlist) {
+ if(playlist.getId() == 0)
+ return;
+ final EditText input = new EditText(this);
+ input.setInputType(InputType.TYPE_CLASS_TEXT);
+ input.setText(playlist.getName());
+
+ FrameLayout container = new FrameLayout(this);
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.leftMargin = getResources().getDimensionPixelSize(R.dimen.dialog_margin);
+ params.rightMargin = getResources().getDimensionPixelSize(R.dimen.dialog_margin);
+ input.setLayoutParams(params);
+ container.addView(input);
+
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.music_rename_playlist)
+ .setView(container)
+ .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
+ String playlistName = input.getText().toString();
+ if(!playlistName.isEmpty()) {
+ renamePlaylistOnDevice(playlist, playlistName);
+ }
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ private void deleteMusicPlaylist(GBDeviceMusicPlaylist playlist) {
+ if(playlist.getId() == 0)
+ return;
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.Delete)
+ .setMessage(this.getString(R.string.music_delete_confirm_description, playlist.getName()))
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
+ deletePlaylistFromDevice(playlist);
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+ }
+
+ private void addMusicSongToPlaylist(final ArrayList<Integer> list) {
+ final Spinner dPlaylists = new Spinner(this);
+
+ List<GBDeviceMusicPlaylist> dialogPlaylists = new ArrayList<>();
+ for (GBDeviceMusicPlaylist playlist : playlists) {
+ if (playlist.getId() != 0) {
+ dialogPlaylists.add(playlist);
+ }
+ }
+
+ ArrayAdapter<GBDeviceMusicPlaylist> dialogPlaylistAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, dialogPlaylists);
+ dialogPlaylistAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ dPlaylists.setAdapter(dialogPlaylistAdapter);
+
+ FrameLayout container = new FrameLayout(this);
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.leftMargin = getResources().getDimensionPixelSize(R.dimen.dialog_margin);
+ params.rightMargin = getResources().getDimensionPixelSize(R.dimen.dialog_margin);
+ dPlaylists.setLayoutParams(params);
+ container.addView(dPlaylists);
+
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.music_add_to_playlist)
+ .setView(container)
+ .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
+ GBDeviceMusicPlaylist playlist = (GBDeviceMusicPlaylist) dPlaylists.getSelectedItem();
+ addMusicToDevicePlaylist(playlist, list);
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ private void startSyncFromDevice(Intent intent) {
+ String info = intent.getStringExtra("deviceInfo");
+ if (!TextUtils.isEmpty(info)) {
+ musicDeviceInfo.setText(info);
+ } else {
+ musicDeviceInfo.setVisibility(View.GONE);
+ }
+
+ maxMusicCount = intent.getIntExtra("maxMusicCount", 0);
+ maxPlaylistCount = intent.getIntExtra("maxPlaylistCount", 0);
+
+ // Hide playlist if device does not support it.
+ playlistSpinnerLayout.setVisibility(maxPlaylistCount>0?View.VISIBLE:View.GONE);
+
+ allMusic.clear();
+ musicList.clear();
+ initPlaylists();
+ }
+
+ private void musicListFromDevice(Intent intent) {
+ ArrayList<GBDeviceMusic> list = (ArrayList<GBDeviceMusic>) intent.getSerializableExtra("musicList");
+ if (list != null && !list.isEmpty()) {
+ allMusic.addAll(list);
+ }
+
+ ArrayList<GBDeviceMusicPlaylist> devicePlaylist = (ArrayList<GBDeviceMusicPlaylist>) intent.getSerializableExtra("musicPlaylist");
+ if (devicePlaylist != null && !devicePlaylist.isEmpty()) {
+ playlists.addAll(devicePlaylist);
+ playlistAdapter.notifyDataSetChanged();
+ }
+ }
+
+ private void musicOperationResponse(Intent intent) {
+ int operation = intent.getIntExtra("operation", -1);
+ if (operation == 0) {
+ int playlistIndex = intent.getIntExtra("playlistIndex", -1);
+ String playlistName = intent.getStringExtra("playlistName");
+
+ if (playlistIndex != -1 && playlistName != null) {
+ playlists.add(new GBDeviceMusicPlaylist(playlistIndex, playlistName, new ArrayList<>()));
+ playlistAdapter.notifyDataSetChanged();
+ }
+ } else if (operation == 1) {
+ int playlistIndex = intent.getIntExtra("playlistIndex", -1);
+ if (playlistIndex != -1) {
+ for (Iterator<GBDeviceMusicPlaylist> iterator = playlists.iterator(); iterator.hasNext(); ) {
+ GBDeviceMusicPlaylist playlist = iterator.next();
+ if (playlist.getId() == playlistIndex) {
+ iterator.remove();
+ }
+ }
+ playlistAdapter.notifyDataSetChanged();
+ }
+ } else if (operation == 2) {
+ int playlistIndex = intent.getIntExtra("playlistIndex", -1);
+ String playlistName = intent.getStringExtra("playlistName");
+ if (playlistIndex != -1 && playlistName != null) {
+ for (GBDeviceMusicPlaylist playlist : playlists) {
+ if (playlist.getId() == playlistIndex) {
+ playlist.setName(playlistName);
+ break;
+ }
+ }
+ playlistAdapter.notifyDataSetChanged();
+ }
+ } else if (operation == 3) {
+ int playlistIndex = intent.getIntExtra("playlistIndex", -1);
+ ArrayList<Integer> ids = (ArrayList<Integer>) intent.getSerializableExtra("musicIds");
+ if (playlistIndex != -1 && ids != null && !ids.isEmpty()) {
+ for (GBDeviceMusicPlaylist playlist : playlists) {
+ if (playlist.getId() == playlistIndex) {
+ ArrayList<Integer> currentList = playlist.getMusicIds();
+ for (Integer id : ids) {
+ if (!currentList.contains(id))
+ currentList.add(id);
+ }
+ playlist.setMusicIds(currentList);
+ break;
+ }
+ }
+ playlistAdapter.notifyDataSetChanged();
+ updateCurrentMusicList();
+ }
+
+ } else if (operation == 4) {
+ ArrayList<Integer> ids = (ArrayList<Integer>) intent.getSerializableExtra("musicIds");
+ int playlistIndex = intent.getIntExtra("playlistIndex", 0);
+ if (ids != null && !ids.isEmpty()) {
+ if (playlistIndex == 0) {
+ for (Iterator<GBDeviceMusic> iterator = musicList.iterator(); iterator.hasNext(); ) {
+ GBDeviceMusic music = iterator.next();
+ if (ids.contains(music.getId())) {
+ iterator.remove();
+ }
+ }
+ for (Iterator<GBDeviceMusic> iterator = allMusic.iterator(); iterator.hasNext(); ) {
+ GBDeviceMusic music = iterator.next();
+ if (ids.contains(music.getId())) {
+ iterator.remove();
+ }
+ }
+ } else {
+ for (GBDeviceMusicPlaylist playlist : playlists) {
+ if (playlist.getId() == playlistIndex) {
+ ArrayList<Integer> currentList = playlist.getMusicIds();
+ for (Integer id : ids) {
+ currentList.remove(id);
+ }
+ playlist.setMusicIds(currentList);
+ break;
+ }
+ }
+ }
+ playlistAdapter.notifyDataSetChanged();
+ updateCurrentMusicList();
+ }
+ }
+ }
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ switch (action) {
+ case ACTION_MUSIC_DATA: {
+ if (!intent.hasExtra("type"))
+ break;
+ int type = intent.getIntExtra("type", -1);
+
+ LOG.info("UPDATE type: {}", type);
+ if (type == 1) {
+ startSyncFromDevice(intent);
+ } else if (type == 2) {
+ LOG.info("got music list or playlist from device");
+ musicListFromDevice(intent);
+ } else if (type == 10) {
+ updateCurrentMusicList();
+ stopLoading();
+ }
+ break;
+ }
+ case ACTION_MUSIC_UPDATE: {
+ boolean success = intent.getBooleanExtra("success", false);
+ if (intent.hasExtra("operation") && success) {
+ musicOperationResponse(intent);
+ }
+ stopLoading();
+ break;
+ }
+ }
+ }
+ };
+
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeActivity.java
new file mode 100644
index 0000000..b44f8ba
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeActivity.java
@@ -0,0 +1,83 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.activities.welcome;
+
+import android.os.Bundle;
+
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+import androidx.viewpager2.widget.ViewPager2;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+
+public class WelcomeActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(WelcomeActivity.class);
+
+ private ViewPager2 viewPager;
+ private WelcomeFragmentsPagerAdapter pagerAdapter;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ AbstractGBActivity.init(this, AbstractGBActivity.NO_ACTIONBAR);
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_welcome);
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().hide();
+ }
+
+ // Configure ViewPager2 with fragment adapter and default fragment
+ viewPager = findViewById(R.id.welcome_viewpager);
+ pagerAdapter = new WelcomeFragmentsPagerAdapter(this);
+ viewPager.setAdapter(pagerAdapter);
+
+ // Set up welcome page indicator
+ WelcomePageIndicator pageIndicator = findViewById(R.id.welcome_page_indicator);
+ pageIndicator.setViewPager(viewPager);
+ }
+
+ private class WelcomeFragmentsPagerAdapter extends FragmentStateAdapter {
+ public WelcomeFragmentsPagerAdapter(FragmentActivity fa) {
+ super(fa);
+ }
+
+ @Override
+ public Fragment createFragment(int position) {
+ switch (position) {
+ case 0:
+ return new WelcomeFragmentIntro();
+ case 1:
+ return new WelcomeFragmentOverview();
+ case 2:
+ return new WelcomeFragmentDocsSource();
+ case 3:
+ return new WelcomeFragmentPermissions();
+ default:
+ return new WelcomeFragmentGetStarted();
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return 5;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentDocsSource.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentDocsSource.java
new file mode 100644
index 0000000..ca5130c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentDocsSource.java
@@ -0,0 +1,42 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.activities.welcome;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class WelcomeFragmentDocsSource extends Fragment {
+ private static final Logger LOG = LoggerFactory.getLogger(WelcomeFragmentDocsSource.class);
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ return inflater.inflate(R.layout.fragment_welcome_docs_source, container, false);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentGetStarted.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentGetStarted.java
new file mode 100644
index 0000000..2de4198
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentGetStarted.java
@@ -0,0 +1,61 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.activities.welcome;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.DataManagementActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class WelcomeFragmentGetStarted extends Fragment {
+ private static final Logger LOG = LoggerFactory.getLogger(WelcomeFragmentGetStarted.class);
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ View view = inflater.inflate(R.layout.fragment_welcome_get_started, container, false);
+
+ Button firstDevice = view.findViewById(R.id.welcome_button_add_device);
+ firstDevice.setOnClickListener(firstDeviceButton -> startActivity(new Intent(requireActivity(), DiscoveryActivityV2.class)));
+ Button restore = view.findViewById(R.id.welcome_button_restore);
+ restore.setOnClickListener(restoreButton -> startActivity(new Intent(requireActivity(), DataManagementActivity.class)));
+ Button toApp = view.findViewById(R.id.welcome_button_to_app);
+ toApp.setOnClickListener(toAppButton -> {
+ Prefs prefs = GBApplication.getPrefs();
+ prefs.getPreferences().edit().putBoolean("first_run", false).apply();
+ requireActivity().finish();
+ });
+
+ return view;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentIntro.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentIntro.java
new file mode 100644
index 0000000..988b183
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentIntro.java
@@ -0,0 +1,74 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.activities.welcome;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.google.android.material.textfield.MaterialAutoCompleteTextView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class WelcomeFragmentIntro extends Fragment {
+ private static final Logger LOG = LoggerFactory.getLogger(WelcomeFragmentIntro.class);
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+
+ final View view = inflater.inflate(R.layout.fragment_welcome_intro, container, false);
+ final String[] themes = getResources().getStringArray(R.array.pref_theme_values);
+ final Prefs prefs = GBApplication.getPrefs();
+ final String currentTheme = prefs.getString("pref_key_theme", getString(R.string.pref_theme_value_system));
+ final int currentThemeIndex = Arrays.asList(themes).indexOf(currentTheme);
+
+ final MaterialAutoCompleteTextView themeMenu = view.findViewById(R.id.app_theme_dropdown_menu);
+ themeMenu.setSaveEnabled(false); // https://github.com/material-components/material-components-android/issues/1464#issuecomment-1258051448
+ themeMenu.setText(getResources().getStringArray(R.array.pref_theme_options)[currentThemeIndex], false);
+ themeMenu.setOnItemClickListener((adapterView, view1, i, l) -> {
+ final SharedPreferences.Editor editor = prefs.getPreferences().edit();
+ editor.putString("pref_key_theme", themes[i]).apply();
+ final Handler handler = new Handler();
+ handler.postDelayed(() -> {
+ // Delay recreation of the Activity to give the dropdown some time to settle.
+ // If we recreate it immediately, the theme popup will reopen, which is not what the user expects.
+ Intent intent = new Intent();
+ intent.setAction(GBApplication.ACTION_THEME_CHANGE);
+ LocalBroadcastManager.getInstance(requireContext()).sendBroadcast(intent);
+ }, 500);
+ });
+ return view;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentOverview.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentOverview.java
new file mode 100644
index 0000000..3e68bae
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentOverview.java
@@ -0,0 +1,42 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.activities.welcome;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class WelcomeFragmentOverview extends Fragment {
+ private static final Logger LOG = LoggerFactory.getLogger(WelcomeFragmentOverview.class);
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ return inflater.inflate(R.layout.fragment_welcome_overview, container, false);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentPermissions.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentPermissions.java
new file mode 100644
index 0000000..c95b8cd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomeFragmentPermissions.java
@@ -0,0 +1,173 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.activities.welcome;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.app.ActivityCompat;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.PermissionsUtils;
+
+public class WelcomeFragmentPermissions extends Fragment {
+ private static final Logger LOG = LoggerFactory.getLogger(WelcomeFragmentPermissions.class);
+
+ private RecyclerView permissionsListView;
+ private PermissionAdapter permissionAdapter;
+ private Button requestAllButton;
+ private List<String> requestingPermissions = new ArrayList<>();
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ View view = inflater.inflate(R.layout.fragment_welcome_permissions, container, false);
+
+ requestAllButton = view.findViewById(R.id.button_request_all);
+ requestAllButton.setOnClickListener(v -> {
+ List<PermissionsUtils.PermissionDetails> wantedPermissions = PermissionsUtils.getRequiredPermissionsList(requireActivity());
+ requestingPermissions = new ArrayList<>();
+ for (PermissionsUtils.PermissionDetails wantedPermission : wantedPermissions) {
+ requestingPermissions.add(wantedPermission.getPermission());
+ }
+ requestAllPermissions();
+ });
+
+ if (((AppCompatActivity)getActivity()).getSupportActionBar().isShowing()) {
+ // Hide title when the Action Bar is visible (i.e. when not in the first run flow)
+ view.findViewById(R.id.permissions_title).setVisibility(View.GONE);
+ }
+
+ // Initialize RecyclerView and data
+ permissionsListView = view.findViewById(R.id.permissions_list);
+
+ // Set up RecyclerView
+ permissionAdapter = new PermissionAdapter(PermissionsUtils.getRequiredPermissionsList(requireActivity()), requireContext());
+ permissionsListView.setLayoutManager(new LinearLayoutManager(requireContext()));
+ permissionsListView.setAdapter(permissionAdapter);
+
+ return view;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ permissionAdapter.notifyDataSetChanged();
+ if (PermissionsUtils.checkAllPermissions(requireActivity())) {
+ requestAllButton.setEnabled(false);
+ }
+ if (!requestingPermissions.isEmpty()) {
+ requestAllPermissions();
+ }
+ }
+
+ public void requestAllPermissions() {
+ if (!requestingPermissions.isEmpty()) {
+ Iterator<String> it = requestingPermissions.iterator();
+ while (it.hasNext()) {
+ String currentPermission = it.next();
+ if (PermissionsUtils.specialPermissions.contains(currentPermission)) {
+ it.remove();
+ if (!PermissionsUtils.checkPermission(requireActivity(), currentPermission)) {
+ PermissionsUtils.requestPermission(requireActivity(), currentPermission);
+ return;
+ }
+ }
+ }
+ String[] combinedPermissions = requestingPermissions.toArray(new String[0]);
+ requestingPermissions.clear();
+ ActivityCompat.requestPermissions(requireActivity(), combinedPermissions, 0);
+ }
+ }
+
+ private class PermissionHolder extends RecyclerView.ViewHolder {
+ TextView titleTextView;
+ TextView summaryTextView;
+ ImageView checkmarkImageView;
+ Button requestButton;
+
+ public PermissionHolder(View itemView) {
+ super(itemView);
+ titleTextView = itemView.findViewById(R.id.permission_title);
+ summaryTextView = itemView.findViewById(R.id.permission_summary);
+ checkmarkImageView = itemView.findViewById(R.id.permission_check);
+ requestButton = itemView.findViewById(R.id.permission_request);
+ }
+ }
+
+ private class PermissionAdapter extends RecyclerView.Adapter<PermissionHolder> {
+ private List<PermissionsUtils.PermissionDetails> permissionList;
+ private Context context;
+
+ public PermissionAdapter(List<PermissionsUtils.PermissionDetails> permissionList, Context context) {
+ this.permissionList = permissionList;
+ this.context = context;
+ }
+
+ @NonNull
+ @Override
+ public PermissionHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View itemView = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.fragment_welcome_permission_row, parent, false);
+ return new PermissionHolder(itemView);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull PermissionHolder holder, int position) {
+ PermissionsUtils.PermissionDetails permissionData = permissionList.get(position);
+ holder.titleTextView.setText(permissionData.getTitle());
+ holder.summaryTextView.setText(permissionData.getSummary());
+ if (PermissionsUtils.checkPermission(requireContext(), permissionData.getPermission())) {
+ holder.requestButton.setVisibility(View.INVISIBLE);
+ holder.requestButton.setEnabled(false);
+ holder.checkmarkImageView.setVisibility(View.VISIBLE);
+ } else {
+ holder.requestButton.setVisibility(View.VISIBLE);
+ holder.requestButton.setEnabled(true);
+ holder.checkmarkImageView.setVisibility(View.GONE);
+ holder.requestButton.setOnClickListener(view -> {
+ PermissionsUtils.requestPermission(requireActivity(), permissionData.getPermission());
+ });
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return permissionList.size();
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomePageIndicator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomePageIndicator.java
new file mode 100644
index 0000000..be06b8b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/welcome/WelcomePageIndicator.java
@@ -0,0 +1,135 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.activities.welcome;
+
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.viewpager2.widget.ViewPager2;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class WelcomePageIndicator extends View {
+ private ViewPager2 viewPager;
+ private int pageCount;
+ private int dotRadius = 15;
+ private int color;
+
+ private Paint outlinePaint;
+ private Paint filledPaint;
+ private float currentX = 0.0f;
+
+ private ValueAnimator dotAnimator;
+
+ public WelcomePageIndicator(Context context) {
+ super(context);
+ init();
+ }
+
+ public WelcomePageIndicator(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ determineColor(context, attrs);
+ init();
+ }
+
+ public WelcomePageIndicator(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ determineColor(context, attrs);
+ init();
+ }
+
+ private void determineColor(Context context, @Nullable AttributeSet attrs) {
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.WelcomePageIndicator);
+ color = a.getColor(R.styleable.WelcomePageIndicator_page_indicator_color, Color.BLACK);
+ a.recycle();
+ }
+
+ private void init() {
+ outlinePaint = new Paint();
+ outlinePaint.setColor(color);
+ outlinePaint.setStyle(Paint.Style.STROKE);
+ outlinePaint.setStrokeWidth(4);
+ outlinePaint.setAntiAlias(true);
+ filledPaint = new Paint();
+ filledPaint.setColor(color);
+ filledPaint.setStyle(Paint.Style.FILL);
+ outlinePaint.setAntiAlias(true);
+ }
+
+ public void setViewPager(ViewPager2 viewPager) {
+ this.viewPager = viewPager;
+ this.pageCount = viewPager.getAdapter().getItemCount();
+ viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
+ @Override
+ public void onPageSelected(int position) {
+ animateIndicator(position);
+ }
+ });
+ invalidate();
+ }
+
+ private int getHorizontalMargin() {
+ int dotDiameter = dotRadius * 2;
+ int dotSpaces = pageCount * 2 - 1;
+ return (getWidth() - dotSpaces * dotDiameter) / 2 + dotRadius;
+ }
+
+ private void animateIndicator(int position) {
+ float horizontalMargin = getHorizontalMargin();
+ if (horizontalMargin <= 0.0f) {
+ // Not animating because the drawable is not ready yet
+ return;
+ }
+ float targetX = horizontalMargin + 4 * dotRadius * position;
+ if (dotAnimator != null && dotAnimator.isRunning()) {
+ dotAnimator.cancel();
+ }
+ if (currentX == 0.0f) currentX = horizontalMargin;
+ dotAnimator = ValueAnimator.ofFloat(currentX, targetX);
+ dotAnimator.addUpdateListener(animation -> {
+ currentX = (float) animation.getAnimatedValue();
+ invalidate();
+ });
+ dotAnimator.setDuration(300);
+ dotAnimator.start();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (viewPager == null || pageCount == 0) {
+ return;
+ }
+
+ float horizontalMargin = getHorizontalMargin();
+ if (currentX == 0.0f && horizontalMargin != 0.0f) currentX = horizontalMargin;
+ float circleY = getHeight() / 2f;
+ for (int i = 0; i < pageCount; i++) {
+ float circleX = horizontalMargin + 4 * dotRadius * i;
+ canvas.drawCircle(circleX, circleY, dotRadius, outlinePaint);
+ }
+ canvas.drawCircle(currentX, circleY, dotRadius, filledPaint);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenDetailsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenDetailsActivity.java
new file mode 100644
index 0000000..f3783a7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenDetailsActivity.java
@@ -0,0 +1,280 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.widgets;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetLayout;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPart;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPartSubtype;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetScreen;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetType;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class WidgetScreenDetailsActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(WidgetScreenDetailsActivity.class);
+
+ private WidgetScreen widgetScreen;
+ private WidgetManager widgetManager;
+
+ private View cardWidgetTopLeft;
+ private View cardWidgetTopRight;
+ private View cardWidgetCenter;
+ private View cardWidgetBotLeft;
+ private View cardWidgetBotRight;
+
+ private TextView labelWidgetScreenLayout;
+ private TextView labelWidgetTopLeft;
+ private TextView labelWidgetTopRight;
+ private TextView labelWidgetCenter;
+ private TextView labelWidgetBotLeft;
+ private TextView labelWidgetBotRight;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_widget_screen_details);
+
+ final GBDevice device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (device == null) {
+ LOG.error("device must not be null");
+ finish();
+ return;
+ }
+
+ widgetScreen = (WidgetScreen) getIntent().getSerializableExtra(WidgetScreen.EXTRA_WIDGET_SCREEN);
+ if (widgetScreen == null) {
+ GB.toast("No widget screen provided to WidgetScreenDetailsActivity", Toast.LENGTH_LONG, GB.ERROR);
+ finish();
+ return;
+ }
+
+ widgetManager = device.getDeviceCoordinator().getWidgetManager(device);
+
+ // Save button
+ final FloatingActionButton fab = findViewById(R.id.fab_save);
+ fab.setOnClickListener(view -> {
+ for (final WidgetPart part : widgetScreen.getParts()) {
+ if (part.getId() == null) {
+ GB.toast(getBaseContext().getString(R.string.widget_missing_parts), Toast.LENGTH_LONG, GB.WARN);
+ return;
+ }
+ }
+
+ updateWidgetScreen();
+ WidgetScreenDetailsActivity.this.setResult(Activity.RESULT_OK);
+ finish();
+ });
+
+ // Layouts
+ final List<WidgetLayout> supportedLayouts = widgetManager.getSupportedWidgetLayouts();
+ final String[] layoutStrings = new String[supportedLayouts.size()];
+ for (int i = 0; i < supportedLayouts.size(); i++) {
+ layoutStrings[i] = getBaseContext().getString(supportedLayouts.get(i).getName());
+ }
+ final ArrayAdapter<String> layoutAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, layoutStrings);
+
+ final View cardLayout = findViewById(R.id.card_layout);
+ cardLayout.setOnClickListener(view -> {
+ new MaterialAlertDialogBuilder(WidgetScreenDetailsActivity.this).setAdapter(layoutAdapter, (dialogInterface, i) -> {
+ if (widgetScreen.getLayout() != supportedLayouts.get(i)) {
+ final ArrayList<WidgetPart> defaultParts = new ArrayList<>();
+ for (final WidgetType widgetType : supportedLayouts.get(i).getWidgetTypes()) {
+ defaultParts.add(new WidgetPart(null, "", widgetType));
+ }
+ widgetScreen.setParts(defaultParts);
+ }
+ widgetScreen.setLayout(supportedLayouts.get(i));
+ updateUiFromWidget();
+ }).setTitle(R.string.widget_layout).create().show();
+ });
+
+ cardWidgetTopLeft = findViewById(R.id.card_widget_top_left);
+ cardWidgetTopRight = findViewById(R.id.card_widget_top_right);
+ cardWidgetCenter = findViewById(R.id.card_widget_center);
+ cardWidgetBotLeft = findViewById(R.id.card_widget_bottom_left);
+ cardWidgetBotRight = findViewById(R.id.card_widget_bottom_right);
+
+ labelWidgetScreenLayout = findViewById(R.id.widget_screen_layout);
+ labelWidgetTopLeft = findViewById(R.id.widget_top_left);
+ labelWidgetTopRight = findViewById(R.id.widget_top_right);
+ labelWidgetCenter = findViewById(R.id.widget_center);
+ labelWidgetBotLeft = findViewById(R.id.widget_bottom_left);
+ labelWidgetBotRight = findViewById(R.id.widget_bottom_right);
+
+ updateUiFromWidget();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ // TODO confirm when exiting without saving
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void updateWidgetScreen() {
+ widgetManager.saveScreen(widgetScreen);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull final Bundle state) {
+ super.onSaveInstanceState(state);
+ state.putSerializable("widgetScreen", widgetScreen);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ widgetScreen = (WidgetScreen) savedInstanceState.getSerializable("widgetScreen");
+ updateUiFromWidget();
+ }
+
+ private void updateUiFromWidget() {
+ labelWidgetScreenLayout.setText(widgetScreen.getLayout().getName());
+
+ switch (widgetScreen.getLayout()) {
+ case TOP_1_BOT_2:
+ case TOP_2X2_BOT_2:
+ updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, -1);
+ updateWidget(cardWidgetTopRight, labelWidgetTopRight, -1);
+ updateWidget(cardWidgetCenter, labelWidgetCenter, 0);
+ updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, 1);
+ updateWidget(cardWidgetBotRight, labelWidgetBotRight, 2);
+ break;
+ case TOP_2_BOT_1:
+ case TOP_2_BOT_2X2:
+ updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, 0);
+ updateWidget(cardWidgetTopRight, labelWidgetTopRight, 1);
+ updateWidget(cardWidgetCenter, labelWidgetCenter, 2);
+ updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, -1);
+ updateWidget(cardWidgetBotRight, labelWidgetBotRight, -1);
+ break;
+ case TOP_2_BOT_2:
+ updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, 0);
+ updateWidget(cardWidgetTopRight, labelWidgetTopRight, 1);
+ updateWidget(cardWidgetCenter, labelWidgetCenter, -1);
+ updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, 2);
+ updateWidget(cardWidgetBotRight, labelWidgetBotRight, 3);
+ break;
+ case ONE_BY_TWO_SINGLE:
+ case TWO_BY_TWO_SINGLE:
+ case TWO_BY_THREE_SINGLE:
+ updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, -1);
+ updateWidget(cardWidgetTopRight, labelWidgetTopRight, -1);
+ updateWidget(cardWidgetCenter, labelWidgetCenter, 0);
+ updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, -1);
+ updateWidget(cardWidgetBotRight, labelWidgetBotRight, -1);
+ break;
+ case TWO:
+ case TOP_1_BOT_2X2:
+ case TOP_2X2_BOT_1:
+ updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, -1);
+ updateWidget(cardWidgetTopRight, labelWidgetTopRight, 0);
+ updateWidget(cardWidgetCenter, labelWidgetCenter, 1);
+ updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, -1);
+ updateWidget(cardWidgetBotRight, labelWidgetBotRight, -1);
+ break;
+ default:
+ throw new IllegalStateException("Unknown layout " + widgetScreen.getLayout());
+ }
+ }
+
+ private void updateWidget(final View card, final TextView label, final int partIdx) {
+ final boolean validPart = partIdx >= 0 && partIdx < widgetScreen.getParts().size();
+
+ card.setVisibility(validPart ? View.VISIBLE : View.GONE);
+
+ if (!validPart) {
+ card.setOnClickListener(null);
+ label.setText(R.string.not_set);
+ return;
+ }
+
+ final WidgetPart widgetPart = widgetScreen.getParts().get(partIdx);
+
+ if (widgetPart.getId() == null) {
+ label.setText(R.string.not_set);
+ } else {
+ label.setText(widgetPart.getFullName());
+ }
+
+ // Select widget part
+
+ final List<WidgetPart> supportedParts = widgetManager.getSupportedWidgetParts(widgetPart.getType());
+ final String[] layoutStrings = new String[supportedParts.size()];
+ for (int i = 0; i < supportedParts.size(); i++) {
+ layoutStrings[i] = supportedParts.get(i).getName();
+ }
+ final ArrayAdapter<String> partAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, layoutStrings);
+
+ card.setOnClickListener(view -> {
+ new MaterialAlertDialogBuilder(WidgetScreenDetailsActivity.this).setAdapter(partAdapter, (dialogInterface, i) -> {
+ final WidgetPart selectedPart = supportedParts.get(i);
+
+ final List<WidgetPartSubtype> supportedSubtypes = selectedPart.getSupportedSubtypes();
+ if (supportedSubtypes.isEmpty()) {
+ // No subtypes selected
+
+ widgetScreen.getParts().set(partIdx, selectedPart);
+ updateUiFromWidget();
+ return;
+ }
+
+ // If the selected part supports subtypes, the user must select a subtype
+
+ final String[] subtypeStrings = new String[supportedSubtypes.size()];
+ for (int j = 0; j < supportedSubtypes.size(); j++) {
+ subtypeStrings[j] = supportedSubtypes.get(j).getName();
+ }
+ final ArrayAdapter<String> subtypesAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, subtypeStrings);
+
+ new MaterialAlertDialogBuilder(WidgetScreenDetailsActivity.this).setAdapter(subtypesAdapter, (dialogInterface1, j) -> {
+ final WidgetPartSubtype selectedSubtype = supportedSubtypes.get(j);
+ selectedPart.setSubtype(selectedSubtype);
+ widgetScreen.getParts().set(partIdx, selectedPart);
+ updateUiFromWidget();
+ }).setTitle(R.string.widget_subtype).create().show();
+ }).setTitle(R.string.widget).create().show();
+ });
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenListAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenListAdapter.java
new file mode 100644
index 0000000..2c4e019
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenListAdapter.java
@@ -0,0 +1,124 @@
+/* Copyright (C) 2023-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.widgets;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.card.MaterialCardView;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPart;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetScreen;
+
+public class WidgetScreenListAdapter extends RecyclerView.Adapter<WidgetScreenListAdapter.ViewHolder> {
+
+ private final Context mContext;
+ private ArrayList<WidgetScreen> widgetScreenList;
+
+ public WidgetScreenListAdapter(Context context) {
+ this.mContext = context;
+ }
+
+ public void setWidgetScreenList(List<WidgetScreen> widgetScreens) {
+ this.widgetScreenList = new ArrayList<>(widgetScreens);
+ }
+
+ public ArrayList<WidgetScreen> getWidgetScreenList() {
+ return widgetScreenList;
+ }
+
+ @NonNull
+ @Override
+ public WidgetScreenListAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ final View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_widget_screen, parent, false);
+ return new ViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, final int position) {
+ final WidgetScreen widgetScreen = widgetScreenList.get(position);
+
+ holder.container.setOnClickListener(v -> ((WidgetScreensListActivity) mContext).configureWidgetScreen(widgetScreen));
+
+ holder.container.setOnLongClickListener(v -> {
+ // TODO move up
+ // TODO move down
+ new MaterialAlertDialogBuilder(v.getContext())
+ .setTitle(R.string.widget_screen_delete_confirm_title)
+ .setMessage(mContext.getString(
+ R.string.widget_screen_delete_confirm_description,
+ mContext.getString(R.string.widget_screen_x, widgetScreen.getId())
+ ))
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
+ ((WidgetScreensListActivity) mContext).deleteWidgetScreen(widgetScreen);
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+
+ return true;
+ });
+
+ holder.widgetScreenName.setText(mContext.getString(R.string.widget_screen_x, widgetScreen.getId()));
+ final List<String> widgetNames = new ArrayList<>();
+
+ for (final WidgetPart part : widgetScreen.getParts()) {
+ if (part.getId() != null) {
+ widgetNames.add(part.getFullName());
+ }
+ }
+
+ if (!widgetNames.isEmpty()) {
+ holder.widgetScreenDescription.setText(TextUtils.join(", ", widgetNames));
+ } else {
+ holder.widgetScreenDescription.setText(R.string.unknown);
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return widgetScreenList.size();
+ }
+
+ static class ViewHolder extends RecyclerView.ViewHolder {
+ final MaterialCardView container;
+
+ final TextView widgetScreenName;
+ final TextView widgetScreenDescription;
+
+ ViewHolder(View view) {
+ super(view);
+
+ container = view.findViewById(R.id.card_widget_screen);
+
+ widgetScreenName = view.findViewById(R.id.widget_screen_name);
+ widgetScreenDescription = view.findViewById(R.id.widget_screen_description);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreensListActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreensListActivity.java
new file mode 100644
index 0000000..eb4f3eb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreensListActivity.java
@@ -0,0 +1,183 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.activities.widgets;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.MenuItem;
+
+import androidx.activity.result.ActivityResult;
+import androidx.activity.result.ActivityResultCallback;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetLayout;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPart;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetScreen;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetType;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class WidgetScreensListActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(WidgetScreensListActivity.class);
+
+ private WidgetScreenListAdapter mGBWidgetScreenListAdapter;
+ private GBDevice gbDevice;
+ private WidgetManager widgetManager;
+
+ private ActivityResultLauncher<Intent> configureWidgetScreenLauncher;
+ private final ActivityResultCallback<ActivityResult> configureWidgetCallback = result -> {
+ if (result.getResultCode() == Activity.RESULT_OK) {
+ updateWidgetScreensFromManager();
+ sendWidgetsToDevice();
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_widget_screens_list);
+
+ gbDevice = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (gbDevice == null) {
+ LOG.error("gbDevice must not be null");
+ finish();
+ return;
+ }
+
+ widgetManager = gbDevice.getDeviceCoordinator().getWidgetManager(gbDevice);
+ if (widgetManager == null) {
+ LOG.error("widgetManager must not be null");
+ finish();
+ return;
+ }
+
+ configureWidgetScreenLauncher = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ configureWidgetCallback
+ );
+
+ mGBWidgetScreenListAdapter = new WidgetScreenListAdapter(this);
+
+ final RecyclerView widgetsRecyclerView = findViewById(R.id.widget_screens_list);
+ widgetsRecyclerView.setHasFixedSize(true);
+ widgetsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+ widgetsRecyclerView.setAdapter(mGBWidgetScreenListAdapter);
+ updateWidgetScreensFromManager();
+
+ final FloatingActionButton fab = findViewById(R.id.fab);
+ fab.setOnClickListener(v -> {
+ int deviceSlots = widgetManager.getMaxScreens();
+
+ if (mGBWidgetScreenListAdapter.getItemCount() >= deviceSlots) {
+ // No more free slots
+ new MaterialAlertDialogBuilder(v.getContext())
+ .setTitle(R.string.reminder_no_free_slots_title)
+ .setMessage(getBaseContext().getString(R.string.widget_screen_no_free_slots_description, String.format(Locale.getDefault(), "%d", deviceSlots)))
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
+ })
+ .show();
+ return;
+ }
+
+ final WidgetLayout defaultLayout = widgetManager.getSupportedWidgetLayouts().get(0);
+ final ArrayList<WidgetPart> defaultParts = new ArrayList<>();
+ for (final WidgetType widgetType : defaultLayout.getWidgetTypes()) {
+ defaultParts.add(new WidgetPart(null, "", widgetType));
+ }
+ final WidgetScreen widgetScreen = new WidgetScreen(
+ null,
+ defaultLayout,
+ defaultParts
+ );
+
+ configureWidgetScreen(widgetScreen);
+ });
+ }
+
+ /**
+ * Reads the available widgets from the database and updates the view afterwards.
+ */
+ @SuppressLint("NotifyDataSetChanged")
+ private void updateWidgetScreensFromManager() {
+ final List<WidgetScreen> widgetScreens = widgetManager.getWidgetScreens();
+
+ mGBWidgetScreenListAdapter.setWidgetScreenList(widgetScreens);
+ mGBWidgetScreenListAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ // back button
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ public void configureWidgetScreen(final WidgetScreen widgetScreen) {
+ final Intent startIntent = new Intent(getApplicationContext(), WidgetScreenDetailsActivity.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, gbDevice);
+ startIntent.putExtra(WidgetScreen.EXTRA_WIDGET_SCREEN, widgetScreen);
+ configureWidgetScreenLauncher.launch(startIntent);
+ }
+
+ public void deleteWidgetScreen(final WidgetScreen widgetScreen) {
+ if (mGBWidgetScreenListAdapter.getItemCount() - 1 < widgetManager.getMinScreens()) {
+ // Under minimum slots
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.widget_screen_delete_confirm_title)
+ .setMessage(this.getString(R.string.widget_screen_min_screens, String.format(Locale.getDefault(), "%d", widgetManager.getMinScreens())))
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
+ })
+ .show();
+ return;
+ }
+
+ widgetManager.deleteScreen(widgetScreen);
+
+ updateWidgetScreensFromManager();
+ sendWidgetsToDevice();
+ }
+
+ private void sendWidgetsToDevice() {
+ if (gbDevice.isInitialized()) {
+ widgetManager.sendToDevice();
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutValueFormatter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutValueFormatter.java
new file mode 100644
index 0000000..42df33a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutValueFormatter.java
@@ -0,0 +1,159 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.workouts;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_CM;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_KG;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_KILOMETERS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS_PER_SECOND;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS_PER_KM;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS_PER_M;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.DecimalFormat;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public class WorkoutValueFormatter {
+ private static final Logger LOG = LoggerFactory.getLogger(WorkoutValueFormatter.class);
+
+ private boolean show_raw_data = false;
+
+ private final String units;
+ private final String UNIT_IMPERIAL;
+ private final DecimalFormat df = new DecimalFormat("#.##");
+
+ public WorkoutValueFormatter() {
+ this.units = GBApplication.getPrefs().getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, GBApplication.getContext().getString(R.string.p_unit_metric));
+ this.UNIT_IMPERIAL = GBApplication.getContext().getString(R.string.p_unit_imperial);
+ }
+
+ public void toggleRawData() {
+ this.show_raw_data = !show_raw_data;
+ }
+
+ public String formatValue(final Object rawValue, String unit) {
+ if (rawValue == null) {
+ return GBApplication.getContext().getString(R.string.stats_empty_value);
+ }
+
+ if (ActivitySummaryEntries.UNIT_RAW_STRING.equals(unit)) {
+ return String.valueOf(rawValue);
+ }
+
+ if (rawValue instanceof CharSequence || ActivitySummaryEntries.UNIT_STRING.equals(unit)) {
+ // we could optimize here a bit and only do this for particular activities (swim at the moment...)
+ try {
+ return getStringResourceByName(String.valueOf(rawValue));
+ } catch (final Exception e) {
+ LOG.error("Failed to get string resource by name for {}", rawValue);
+ return String.valueOf(rawValue);
+ }
+ }
+
+ if (!(rawValue instanceof Number)) {
+ return String.valueOf(rawValue);
+ }
+
+ double value = ((Number) rawValue).doubleValue();
+
+ if (!show_raw_data) {
+ //special casing here + imperial units handling
+ switch (unit) {
+ case UNIT_KG:
+ if (units.equals(UNIT_IMPERIAL)) {
+ value = value * 2.2046226f;
+ unit = "lb";
+ }
+ break;
+ case UNIT_CM:
+ if (units.equals(UNIT_IMPERIAL)) {
+ value = value * 0.0328084;
+ unit = "ft";
+ }
+ break;
+ case UNIT_METERS_PER_SECOND:
+ if (units.equals(UNIT_IMPERIAL)) {
+ value = value * 2.236936D;
+ unit = "mi_h";
+ } else { //metric
+ value = value * 3.6;
+ unit = "km_h";
+ }
+ break;
+ case UNIT_SECONDS_PER_M:
+ if (units.equals(UNIT_IMPERIAL)) {
+ value = value * (1609.344 / 60D);
+ unit = "minutes_mi";
+ } else { //metric
+ value = value * (1000 / 60D);
+ unit = "minutes_km";
+ }
+ break;
+ case UNIT_SECONDS_PER_KM:
+ if (units.equals(UNIT_IMPERIAL)) {
+ value = value / 60D * 1.609344;
+ unit = "minutes_mi";
+ } else { //metric
+ value = value / 60D;
+ unit = "minutes_km";
+ }
+ break;
+ case UNIT_KILOMETERS:
+ if (units.equals(UNIT_IMPERIAL)) {
+ value = value * 0.621371D;
+ unit = "mi";
+ }
+ break;
+ case UNIT_METERS:
+ if (units.equals(UNIT_IMPERIAL)) {
+ value = value * 3.28084D;
+ unit = "ft";
+ if (value > 6000) {
+ value = value * 0.0001893939D;
+ unit = "mi";
+ }
+ } else { //metric
+ if (value > 2000) {
+ value = value / 1000;
+ unit = "km";
+ }
+ }
+ break;
+ }
+ }
+
+ if (unit.equals("seconds") && !show_raw_data) { //rather then plain seconds, show formatted duration
+ return DateTimeUtils.formatDurationHoursMinutes((long) value, TimeUnit.SECONDS);
+ } else if (unit.equals("minutes_km") || unit.equals("minutes_mi")) {
+ // Format pace
+ return String.format(
+ Locale.getDefault(),
+ "%d:%02d %s",
+ (int) Math.floor(value), (int) Math.round(60 * (value - (int) Math.floor(value))),
+ getStringResourceByName(unit)
+ );
+ } else {
+ return String.format("%s %s", df.format(value), getStringResourceByName(unit));
+ }
+ }
+
+ public String getStringResourceByName(String aString) {
+ String packageName = BuildConfig.APPLICATION_ID;
+ int resId = GBApplication.getContext().getResources().getIdentifier(aString, "string", packageName);
+ if (resId == 0) {
+ //LOG.warn("SportsActivity " + "Missing string in strings:" + aString);
+ return aString;
+ } else {
+ return GBApplication.getContext().getString(resId);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryEntry.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryEntry.java
new file mode 100644
index 0000000..1c64c84
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryEntry.java
@@ -0,0 +1,23 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries;
+
+import android.widget.LinearLayout;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
+
+public abstract class ActivitySummaryEntry {
+ private final String group;
+
+ public ActivitySummaryEntry(final String group) {
+ this.group = group;
+ }
+
+ public String getGroup() {
+ return group;
+ }
+
+ public abstract int getColumnSpan();
+
+ public abstract void populate(final String key,
+ final LinearLayout linearLayout,
+ final WorkoutValueFormatter workoutValueFormatter);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryProgressEntry.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryProgressEntry.java
new file mode 100644
index 0000000..b1fbcb8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryProgressEntry.java
@@ -0,0 +1,78 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.view.Gravity;
+import android.view.View;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
+
+public class ActivitySummaryProgressEntry extends ActivitySummarySimpleEntry {
+ private final int progress;
+ private int color;
+
+ public ActivitySummaryProgressEntry(final Object value, final String unit, final int progress) {
+ this(null, value, unit, progress);
+ }
+
+ public ActivitySummaryProgressEntry(final String group, final Object value, final String unit, final int progress) {
+ super(group, value, unit);
+ this.progress = progress;
+ }
+
+ public ActivitySummaryProgressEntry(final Object value, final String unit, final int progress, final int color) {
+ this(null, value, unit, progress);
+ this.color = color;
+ }
+
+ public int getProgress() {
+ return progress;
+ }
+
+ @Override
+ public int getColumnSpan() {
+ return 2;
+ }
+
+ @Override
+ public void populate(final String key, final LinearLayout linearLayout, final WorkoutValueFormatter workoutValueFormatter) {
+ final Context context = linearLayout.getContext();
+
+ // Label
+ final TextView labelTextView = new TextView(context);
+ labelTextView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT));
+ labelTextView.setTextSize(12);
+ labelTextView.setText(workoutValueFormatter.getStringResourceByName(key));
+
+ // Value
+ final TextView valueTextView = new TextView(context);
+ valueTextView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
+ valueTextView.setText(String.format("%s", "-"));
+ valueTextView.setTextSize(12);
+ valueTextView.setGravity(Gravity.END);
+ valueTextView.setText(workoutValueFormatter.formatValue(getValue(), getUnit()));
+
+ // Layout for the labels, so the value is at the right
+ final LinearLayout labelsLinearLayout = new LinearLayout(context);
+ labelsLinearLayout.setOrientation(LinearLayout.HORIZONTAL);
+ labelsLinearLayout.addView(labelTextView);
+ labelsLinearLayout.addView(valueTextView);
+
+ final LinearLayout progressLayout = new LinearLayout(context);
+ final ProgressBar progressBar = new ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal);
+ progressBar.setIndeterminate(false);
+ progressBar.setProgress(progress);
+ progressBar.setVisibility(View.VISIBLE);
+ if (color != 0) {
+ progressBar.setProgressTintList(ColorStateList.valueOf(color));
+ }
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
+ progressLayout.addView(progressBar, params);
+
+ linearLayout.addView(labelsLinearLayout);
+ linearLayout.addView(progressLayout);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummarySimpleEntry.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummarySimpleEntry.java
new file mode 100644
index 0000000..4dcc441
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummarySimpleEntry.java
@@ -0,0 +1,57 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries;
+
+import android.content.Context;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
+
+public class ActivitySummarySimpleEntry extends ActivitySummaryEntry {
+ private final Object value;
+ private final String unit;
+
+ public ActivitySummarySimpleEntry(final Object value, final String unit) {
+ this(null, value, unit);
+ }
+
+ public ActivitySummarySimpleEntry(final String group, final Object value, final String unit) {
+ super(group);
+ this.value = value;
+ this.unit = unit;
+ }
+
+ public Object getValue() {
+ return value;
+ }
+
+ public String getUnit() {
+ return unit;
+ }
+
+ @Override
+ public int getColumnSpan() {
+ return 1;
+ }
+
+ @Override
+ public void populate(final String key, final LinearLayout linearLayout, final WorkoutValueFormatter workoutValueFormatter) {
+ final Context context = linearLayout.getContext();
+
+ // Value
+ final TextView valueTextView = new TextView(context);
+ valueTextView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT));
+ valueTextView.setText(context.getString(R.string.stats_empty_value));
+ valueTextView.setTextSize(20);
+ valueTextView.setText(workoutValueFormatter.formatValue(value, unit));
+
+ // Label
+ final TextView labelTextView = new TextView(context);
+ labelTextView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT));
+ labelTextView.setTextSize(12);
+ labelTextView.setText(workoutValueFormatter.getStringResourceByName(key));
+
+ linearLayout.addView(valueTextView);
+ linearLayout.addView(labelTextView);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryTableRowEntry.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryTableRowEntry.java
new file mode 100644
index 0000000..14a4f8e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryTableRowEntry.java
@@ -0,0 +1,72 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries;
+
+import android.graphics.Typeface;
+import android.view.Gravity;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.gridlayout.widget.GridLayout;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
+
+public class ActivitySummaryTableRowEntry extends ActivitySummaryEntry {
+ private final List<ActivitySummaryValue> columns;
+ private final boolean isHeader;
+ private final boolean boldFirstColumn;
+
+ public ActivitySummaryTableRowEntry(final List<ActivitySummaryValue> columns) {
+ this(null, columns, false, false);
+ }
+
+ public ActivitySummaryTableRowEntry(final String group,
+ final List<ActivitySummaryValue> columns,
+ final boolean isHeader,
+ final boolean boldFirstColumn) {
+ super(group);
+ this.columns = columns;
+ this.isHeader = isHeader;
+ this.boldFirstColumn = boldFirstColumn;
+ }
+
+ @Override
+ public int getColumnSpan() {
+ return 2;
+ }
+
+ @Override
+ public void populate(final String key, final LinearLayout linearLayout, final WorkoutValueFormatter workoutValueFormatter) {
+ final GridLayout rowLayout = new GridLayout(linearLayout.getContext());
+ rowLayout.setColumnCount(columns.size());
+ rowLayout.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+
+ for (int i = 0; i < columns.size(); i++) {
+ final LinearLayout cellLayout = new LinearLayout(linearLayout.getContext());
+ final GridLayout.LayoutParams columnParams = new GridLayout.LayoutParams();
+ columnParams.columnSpec = GridLayout.spec(i, columns.size());
+ final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(
+ GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f),
+ GridLayout.spec(GridLayout.UNDEFINED, 1, GridLayout.FILL, 1f)
+ );
+ layoutParams.width = 0;
+ cellLayout.setLayoutParams(layoutParams);
+ cellLayout.setOrientation(LinearLayout.VERTICAL);
+ cellLayout.setGravity(Gravity.CENTER);
+
+ final TextView columnTextView = new TextView(linearLayout.getContext());
+ columnTextView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT));
+ columnTextView.setText(columns.get(i).format(workoutValueFormatter));
+ columnTextView.setTextSize(12);
+ if (isHeader || (i == 0 && boldFirstColumn)) {
+ columnTextView.setTypeface(null, Typeface.BOLD);
+ }
+
+ cellLayout.addView(columnTextView);
+ rowLayout.addView(cellLayout);
+ }
+
+ linearLayout.addView(rowLayout);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryValue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryValue.java
new file mode 100644
index 0000000..520f2d8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/entries/ActivitySummaryValue.java
@@ -0,0 +1,22 @@
+package nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
+
+public class ActivitySummaryValue {
+ private final Object value;
+ private final String unit;
+
+ public ActivitySummaryValue(final Object value, final String unit) {
+ this.value = value;
+ this.unit = unit;
+ }
+
+ public ActivitySummaryValue(final String value) {
+ this(value, ActivitySummaryEntries.UNIT_STRING);
+ }
+
+ public String format(final WorkoutValueFormatter formatter) {
+ return formatter.formatValue(value, unit);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AbstractActivityListingAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AbstractActivityListingAdapter.java
new file mode 100644
index 0000000..8591893
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AbstractActivityListingAdapter.java
@@ -0,0 +1,181 @@
+/* Copyright (C) 2020-2024 Petr Van臎k
+
+ 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.adapter;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+/**
+ * Adapter for displaying generic ItemWithDetails instances.
+ */
+public abstract class AbstractActivityListingAdapter<T> extends RecyclerView.Adapter<AbstractActivityListingAdapter.AbstractActivityListingViewHolder<T>> {
+ private final Context context;
+ private final List<T> items;
+ private final BitSet selectedItems = new BitSet();
+
+ private OnItemClickListener onItemSingleClickListener;
+ private OnItemClickListener onItemLongClickListener;
+
+ public AbstractActivityListingAdapter(Context context) {
+ this.context = context;
+ this.items = new ArrayList<T>();
+ }
+
+ public void setOnItemClickListener(final OnItemClickListener onItemSingleClickListener) {
+ this.onItemSingleClickListener = onItemSingleClickListener;
+ }
+
+ public void setOnItemLongClickListener(final OnItemClickListener onItemLongClickListener) {
+ this.onItemLongClickListener = onItemLongClickListener;
+ }
+
+ public Context getContext() {
+ return context;
+ }
+
+ public BitSet getSelectedItems() {
+ return selectedItems;
+ }
+
+ @Override
+ public int getItemCount() {
+ return items.size();
+ }
+
+ public T getItem(final int position) {
+ return items.get(position);
+ }
+
+ public int getPosition(final T item) {
+ return items.indexOf(item);
+ }
+
+ public boolean isSelected(final int position) {
+ return selectedItems.get(position);
+ }
+
+ @Override
+ public int getItemViewType(final int position) {
+ if (position == 0) {
+ // First item is always the dashboard
+ return 0;
+ } else if (position == getItemCount() - 1) {
+ // Last item is always an empty session (prevent overlap with fab)
+ return 1;
+ }
+
+ return 2;
+ }
+
+ @NonNull
+ @Override
+ public AbstractActivityListingViewHolder<T> onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
+ switch (viewType) {
+ case 1: // empty
+ return new EmptyViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.activity_list_item, parent, false));
+ }
+
+ throw new IllegalArgumentException("Unknown view type " + viewType);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull final AbstractActivityListingViewHolder<T> holder, int position) {
+ holder.fill(position, getItem(position), isSelected(position));
+ if (position > 0 && position < getItemCount() - 1) {
+ if (onItemSingleClickListener != null) {
+ holder.itemView.setOnClickListener(v -> onItemSingleClickListener.onClick(position));
+ }
+ if (onItemLongClickListener != null) {
+ holder.itemView.setOnLongClickListener(v -> {
+ onItemLongClickListener.onClick(position);
+ return true;
+ });
+ }
+ }
+ }
+
+ public List<T> getItems() {
+ return items;
+ }
+
+ public void loadItems() {
+ }
+
+ public void setItems(List<T> items, boolean notify) {
+ this.items.clear();
+ this.items.addAll(items);
+ this.selectedItems.clear();
+ if (notify) {
+ notifyDataSetChanged();
+ }
+ }
+
+ public void setActivityKindFilter(int activityKind) {
+ }
+
+ public void setDateFromFilter(long date) {
+ }
+
+ public void setDateToFilter(long date) {
+ }
+
+ public void setNameContainsFilter(String name) {
+ }
+
+ public void setItemsFilter(List<Long> items) {
+ }
+
+ public void setDeviceFilter(long device) {
+ }
+
+ public abstract static class AbstractActivityListingViewHolder<T> extends RecyclerView.ViewHolder {
+ public AbstractActivityListingViewHolder(@NonNull final View itemView) {
+ super(itemView);
+ }
+
+ public abstract void fill(int position, T item, final boolean selected);
+ }
+
+ public interface OnItemClickListener {
+ void onClick(int position);
+ }
+
+ public class EmptyViewHolder extends AbstractActivityListingViewHolder<T> {
+ final View rootView;
+
+ public EmptyViewHolder(@NonNull final View itemView) {
+ super(itemView);
+ this.rootView = itemView;
+ }
+
+ @Override
+ public void fill(final int position, final T item, final boolean selected) {
+ rootView.setVisibility(View.GONE);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AbstractItemAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AbstractItemAdapter.java
new file mode 100644
index 0000000..dbbbeb3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AbstractItemAdapter.java
@@ -0,0 +1,135 @@
+/* Copyright (C) 2017-2024 Arjan Schrijver, Carsten Pfeiffer, Daniele
+ Gobbetti, Petr Van臎k
+
+ 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.adapter;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.DrawableRes;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+/**
+ * Adapter for displaying generic ItemWithDetails instances.
+ */
+public abstract class AbstractItemAdapter<T> extends ArrayAdapter<T> {
+
+ public static final int SIZE_SMALL = 1;
+ public static final int SIZE_MEDIUM = 2;
+ public static final int SIZE_LARGE = 3;
+ private final Context context;
+ private final List<T> items;
+ private boolean horizontalAlignment;
+ private int size = SIZE_MEDIUM;
+ private int backgroundColor=0;
+
+ public AbstractItemAdapter(Context context) {
+ this (context, new ArrayList<T>());
+ }
+
+ public AbstractItemAdapter(Context context, List<T> items) {
+ super(context, 0, items);
+
+ this.context = context;
+ this.items = items;
+ }
+
+ public void setHorizontalAlignment(boolean horizontalAlignment) {
+ this.horizontalAlignment = horizontalAlignment;
+ }
+
+
+
+ @Override
+ public View getView(int position, View view, ViewGroup parent) {
+ T item = getItem(position);
+
+ if (view == null) {
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+
+ if (horizontalAlignment) {
+ view = inflater.inflate(R.layout.item_with_details_horizontal, parent, false);
+ } else {
+ switch (size) {
+ case SIZE_SMALL:
+ view = inflater.inflate(R.layout.item_with_details_small, parent, false);
+ break;
+ default:
+ view = inflater.inflate(R.layout.item_with_details, parent, false);
+ break;
+ }
+ }
+ }
+ ImageView iconView = (ImageView) view.findViewById(R.id.item_image);
+ TextView nameView = (TextView) view.findViewById(R.id.item_name);
+ TextView detailsView = (TextView) view.findViewById(R.id.item_details);
+ ImageView previewView = (ImageView) view.findViewById(R.id.item_preview);
+
+ nameView.setText(getName(item));
+ detailsView.setText(getDetails(item));
+ iconView.setImageResource(getIcon(item));
+ iconView.setBackgroundColor(backgroundColor);
+ previewView.setImageBitmap(getPreview(item));
+
+ return view;
+ }
+
+ protected abstract String getName(T item);
+
+ protected abstract String getDetails(T item);
+
+ @DrawableRes
+ protected abstract int getIcon(T item);
+
+ protected abstract Bitmap getPreview(T item);
+
+ public void setSize(int size) {
+ this.size = size;
+ }
+
+ public void setBackgroundColor(int backgroundColor) {
+ this.backgroundColor = backgroundColor;
+ }
+ public int getSize() {
+ return size;
+ }
+ public List<T> getItems() {
+ return items;
+ }
+
+ public void loadItems() {
+ }
+
+ public void setItems(List<T> items, boolean notify) {
+ this.items.clear();
+ this.items.addAll(items);
+ if (notify) {
+ notifyDataSetChanged();
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ActivitySummariesAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ActivitySummariesAdapter.java
new file mode 100644
index 0000000..bef1e03
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ActivitySummariesAdapter.java
@@ -0,0 +1,394 @@
+/* Copyright (C) 2017-2024 Carsten Pfeiffer, Daniel Dakhno, Daniele Gobbetti,
+ Jos茅 Rebelo, Petr Van臎k
+
+ 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.adapter;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityListItem;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryJsonSummary;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FormatUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.ActivitySummariesFilter.ALL_DEVICES;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.INTERNAL_HAS_GPS;
+
+import androidx.annotation.NonNull;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+public class ActivitySummariesAdapter extends AbstractActivityListingAdapter<BaseActivitySummary> {
+ protected static final Logger LOG = LoggerFactory.getLogger(ActivitySummariesAdapter.class);
+ private final GBDevice device;
+ long dateFromFilter = 0;
+ long dateToFilter = 0;
+ long deviceFilter;
+ String nameContainsFilter;
+ List<Long> itemsFilter;
+ private int activityKindFilter;
+
+ public ActivitySummariesAdapter(Context context, GBDevice device, int activityKindFilter, long dateFromFilter, long dateToFilter, String nameContainsFilter, long deviceFilter, List itemsFilter) {
+ super(context);
+ this.device = device;
+ this.activityKindFilter = activityKindFilter;
+ this.dateFromFilter = dateFromFilter;
+ this.dateToFilter = dateToFilter;
+ this.nameContainsFilter = nameContainsFilter;
+ this.deviceFilter = deviceFilter;
+ this.itemsFilter = itemsFilter;
+ loadItems();
+ }
+
+ @Override
+ public void loadItems() {
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ BaseActivitySummaryDao summaryDao = handler.getDaoSession().getBaseActivitySummaryDao();
+ Device dbDevice = DBHelper.findDevice(device, handler.getDaoSession());
+
+ QueryBuilder<BaseActivitySummary> qb = summaryDao.queryBuilder();
+
+ if (deviceFilter == ALL_DEVICES) {
+ qb.orderDesc(BaseActivitySummaryDao.Properties.StartTime);
+ } else if (deviceFilter != 0) {
+ qb.where(
+ BaseActivitySummaryDao.Properties.DeviceId.eq(
+ deviceFilter)).orderDesc(BaseActivitySummaryDao.Properties.StartTime);
+ } else {
+ qb.where(
+ BaseActivitySummaryDao.Properties.DeviceId.eq(
+ dbDevice.getId())).orderDesc(BaseActivitySummaryDao.Properties.StartTime);
+ }
+
+ if (activityKindFilter != 0) {
+ qb.where(
+ BaseActivitySummaryDao.Properties.ActivityKind.eq(activityKindFilter));
+ }
+
+ if (dateFromFilter != 0) {
+ qb.where(
+ BaseActivitySummaryDao.Properties.StartTime.gt(new Date(dateFromFilter)));
+ }
+ if (dateToFilter != 0) {
+ qb.where(
+ BaseActivitySummaryDao.Properties.EndTime.lt(new Date(dateToFilter)));
+ }
+ if (nameContainsFilter != null && !nameContainsFilter.isEmpty()) {
+ qb.where(
+ BaseActivitySummaryDao.Properties.Name.like("%" + nameContainsFilter + "%"));
+ }
+ if (itemsFilter != null) {
+ qb.where(
+ BaseActivitySummaryDao.Properties.Id.in(itemsFilter));
+ }
+
+
+ List<BaseActivitySummary> allSummaries = new ArrayList<>();
+ // HACK: Populate json in a dummy summary, so stats load faster
+ BaseActivitySummary dashboardSummary = new BaseActivitySummary();
+ final List<BaseActivitySummary> summaries = qb.build().list();
+ dashboardSummary.setSummaryData(StatsContainer.from(
+ device.getDeviceCoordinator().getActivitySummaryParser(device, getContext()),
+ summaries
+ ).toJson());
+ allSummaries.add(dashboardSummary); // dashboard
+ allSummaries.addAll(summaries);
+ allSummaries.add(new BaseActivitySummary()); // empty
+ setItems(allSummaries, true);
+ } catch (Exception e) {
+ GB.toast("Error loading activity summaries.", Toast.LENGTH_SHORT, GB.ERROR, e);
+ }
+ }
+
+ @NonNull
+ @Override
+ public AbstractActivityListingViewHolder<BaseActivitySummary> onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
+ switch (viewType) {
+ case 0: // dashboard
+ return new DashboardViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.activity_summary_dashboard_item, parent, false));
+ case 2: // item
+ return new ActivityItemViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.activity_list_item, parent, false));
+ }
+
+ return super.onCreateViewHolder(parent, viewType);
+ }
+
+ public void setActivityKindFilter(int filter) {
+ this.activityKindFilter = filter;
+ }
+
+ public void setDateFromFilter(long date) {
+ this.dateFromFilter = date;
+ }
+
+ public void setDateToFilter(long date) {
+ this.dateToFilter = date;
+ }
+
+ public void setNameContainsFilter(String name) {
+ this.nameContainsFilter = name;
+ }
+
+ public void setItemsFilter(List<Long> items) {
+ this.itemsFilter = items;
+ }
+
+ public void setDeviceFilter(long device) {
+ this.deviceFilter = device;
+ }
+
+ public int gettActivityKindFilter() {
+ return this.activityKindFilter;
+ }
+
+ public static class ActivityItemViewHolder extends AbstractActivityListingViewHolder<BaseActivitySummary> {
+ final View rootView;
+ final ActivityListItem activityListItem;
+
+ public ActivityItemViewHolder(@NonNull final View itemView) {
+ super(itemView);
+ this.rootView = itemView;
+ this.activityListItem = new ActivityListItem(itemView);
+ }
+
+ @Override
+ public void fill(final int position, final BaseActivitySummary summary, final boolean selected) {
+ final boolean hasGps;
+
+ if (summary.getGpxTrack() != null) {
+ hasGps = true;
+ } else if (summary.getSummaryData() != null && summary.getSummaryData().contains(ActivitySummaryEntries.INTERNAL_HAS_GPS)) {
+ final ActivitySummaryData summaryData = ActivitySummaryData.fromJson(summary.getSummaryData());
+ hasGps = summaryData != null && summaryData.getBoolean(INTERNAL_HAS_GPS, false);
+ } else {
+ hasGps = false;
+ }
+
+ this.activityListItem.update(
+ null,
+ null,
+ ActivityKind.fromCode(summary.getActivityKind()),
+ summary.getName(),
+ -1,
+ -1,
+ -1,
+ -1,
+ summary.getEndTime().getTime() - summary.getStartTime().getTime(),
+ hasGps,
+ summary.getStartTime(),
+ position % 2 == 1,
+ selected
+ );
+ }
+ }
+
+ public class DashboardViewHolder extends AbstractActivityListingViewHolder<BaseActivitySummary> {
+ final TextView durationSumView;
+ final TextView caloriesBurntSumView;
+ final TextView distanceSumView;
+ final TextView activeSecondsSumView;
+ final TextView timeStartView;
+ final TextView timeEndView;
+ final TextView activitiesCountView;
+ final TextView activityKindView;
+ final ImageView activityIconView;
+ final ImageView activityIconBigView;
+
+ public DashboardViewHolder(@NonNull final View itemView) {
+ super(itemView);
+
+ durationSumView = itemView.findViewById(R.id.summary_dashboard_layout_duration_label);
+ caloriesBurntSumView = itemView.findViewById(R.id.summary_dashboard_layout_calories_label);
+ distanceSumView = itemView.findViewById(R.id.summary_dashboard_layout_distance_label);
+ activeSecondsSumView = itemView.findViewById(R.id.summary_dashboard_layout_active_duration_label);
+ timeStartView = itemView.findViewById(R.id.summary_dashboard_layout_from_label);
+ timeEndView = itemView.findViewById(R.id.summary_dashboard_layout_to_label);
+ activitiesCountView = itemView.findViewById(R.id.summary_dashboard_layout_count_label);
+ activityKindView = itemView.findViewById(R.id.summary_dashboard_layout_activity_label);
+ activityIconView = itemView.findViewById(R.id.summary_dashboard_layout_activity_icon);
+ activityIconBigView = itemView.findViewById(R.id.summary_dashboard_layout_big_activity_icon);
+ }
+
+ @Override
+ public void fill(final int position, final BaseActivitySummary summary, final boolean selected) {
+ int activitiesCount = getItemCount() - 2; // remove dashboard and end spacer
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+
+ String summaryData = summary.getSummaryData();
+ final StatsContainer stats;
+ if (StringUtils.isNotBlank(summaryData)) {
+ stats = StatsContainer.fromJson(summaryData);
+ } else {
+ stats = StatsContainer.from(
+ coordinator.getActivitySummaryParser(device, getContext()),
+ getItems()
+ );
+ }
+
+ DecimalFormat df = new DecimalFormat("#.##");
+ durationSumView.setText(String.format("%s", DateTimeUtils.formatDurationHoursMinutes((long) stats.durationSum, TimeUnit.MILLISECONDS)));
+ caloriesBurntSumView.setText(String.format("%s %s", (long) stats.caloriesBurntSum, getContext().getString(R.string.calories_unit)));
+ distanceSumView.setText(String.format("%s %s", df.format(stats.distanceSum / 1000), getContext().getString(R.string.km)));
+ distanceSumView.setText(FormatUtils.getFormattedDistanceLabel(stats.distanceSum));
+
+ activeSecondsSumView.setText(String.format("%s", DateTimeUtils.formatDurationHoursMinutes((long) stats.activeSecondsSum, TimeUnit.SECONDS)));
+ activitiesCountView.setText(String.valueOf(activitiesCount));
+ String activityName = getContext().getString(R.string.activity_summaries_all_activities);
+ if (gettActivityKindFilter() != 0) {
+ ActivityKind activityKind = ActivityKind.fromCode(gettActivityKindFilter());
+ activityName = activityKind.getLabel(getContext());
+ activityIconView.setImageResource(activityKind.getIcon());
+ activityIconBigView.setImageResource(activityKind.getIcon());
+ } else if (stats.activityIcon != 0) {
+ ActivityKind activityKind = ActivityKind.fromCode(stats.activityIcon);
+ activityIconView.setImageResource(activityKind.getIcon());
+ activityIconBigView.setImageResource(activityKind.getIcon());
+ } else {
+ activityIconView.setImageResource(R.drawable.ic_activity_unknown_small);
+ activityIconBigView.setImageResource(R.drawable.ic_activity_unknown_small);
+ }
+
+ activityKindView.setText(activityName);
+
+ //start and end are inverted when filer not applied, because items are sorted the other way
+ timeStartView.setText((dateFromFilter != 0) ? DateTimeUtils.formatDate(new Date(dateFromFilter)) : DateTimeUtils.formatDate(new Date((long) stats.lastItemDate)));
+ timeEndView.setText((dateToFilter != 0) ? DateTimeUtils.formatDate(new Date(dateToFilter)) : DateTimeUtils.formatDate(new Date((long) stats.firstItemDate)));
+ }
+ }
+
+ private static class StatsContainer {
+ private static final Gson GSON = new GsonBuilder().create();
+
+ private final double durationSum;
+ private final double caloriesBurntSum;
+ private final double distanceSum;
+ private final double activeSecondsSum;
+ private final double firstItemDate;
+ private final double lastItemDate;
+ private final int activityIcon;
+
+ public StatsContainer(final double durationSum,
+ final double caloriesBurntSum,
+ final double distanceSum,
+ final double activeSecondsSum,
+ final double firstItemDate,
+ final double lastItemDate,
+ final int activityIcon) {
+ this.durationSum = durationSum;
+ this.caloriesBurntSum = caloriesBurntSum;
+ this.distanceSum = distanceSum;
+ this.activeSecondsSum = activeSecondsSum;
+ this.firstItemDate = firstItemDate;
+ this.lastItemDate = lastItemDate;
+ this.activityIcon = activityIcon;
+ }
+
+ private static StatsContainer from(final ActivitySummaryParser summaryParser,
+ final List<BaseActivitySummary> activities) {
+ double durationSum = 0;
+ double caloriesBurntSum = 0;
+ double distanceSum = 0;
+ double activeSecondsSum = 0;
+ double firstItemDate = 0;
+ double lastItemDate = 0;
+ int activityIcon = 0;
+ boolean activitySame = true;
+
+ for (BaseActivitySummary summary : activities) {
+ if (summary.getStartTime() == null) continue; //first item is empty, for dashboard
+
+ if (firstItemDate == 0) firstItemDate = summary.getStartTime().getTime();
+ lastItemDate = summary.getEndTime().getTime();
+ durationSum += summary.getEndTime().getTime() - summary.getStartTime().getTime();
+
+ if (activityIcon == 0) {
+ activityIcon = summary.getActivityKind();
+ } else {
+ if (activityIcon != summary.getActivityKind()) {
+ activitySame = false;
+ }
+ }
+
+ final ActivitySummaryJsonSummary activitySummaryJsonSummary = new ActivitySummaryJsonSummary(summaryParser, summary);
+ ActivitySummaryData summarySubdata = activitySummaryJsonSummary.getSummaryData(false);
+
+ if (summarySubdata != null) {
+ if (summarySubdata.has("caloriesBurnt")) {
+ caloriesBurntSum += summarySubdata.getNumber("caloriesBurnt", 0).doubleValue();
+ }
+ if (summarySubdata.has("distanceMeters")) {
+ distanceSum += summarySubdata.getNumber("distanceMeters", 0).doubleValue();
+ }
+ if (summarySubdata.has("activeSeconds")) {
+ activeSecondsSum += summarySubdata.getNumber("activeSeconds", 0).doubleValue();
+ }
+ }
+ }
+
+ return new StatsContainer(
+ durationSum,
+ caloriesBurntSum,
+ distanceSum,
+ activeSecondsSum,
+ firstItemDate,
+ lastItemDate,
+ activitySame ? activityIcon : 0
+ );
+ }
+
+ public static StatsContainer fromJson(final String json) {
+ return GSON.fromJson(json, StatsContainer.class);
+ }
+
+ public String toJson() {
+ return GSON.toJson(this);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppBlacklistAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppBlacklistAdapter.java
new file mode 100644
index 0000000..6507a3e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppBlacklistAdapter.java
@@ -0,0 +1,307 @@
+/* Copyright (C) 2017-2024 abettenburg, AndrewBedscastle, Carsten Pfeiffer,
+ Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k
+
+ 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.adapter;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.LauncherActivityInfo;
+import android.content.pm.LauncherApps;
+import android.content.pm.PackageManager;
+import android.os.Process;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CheckedTextView;
+import android.widget.Filter;
+import android.widget.Filterable;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+
+import androidx.recyclerview.widget.RecyclerView;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.NotificationFilterActivity;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+import static nodomain.freeyourgadget.gadgetbridge.GBApplication.packageNameToPebbleMsgSender;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AppBlacklistAdapter extends RecyclerView.Adapter<AppBlacklistAdapter.AppBLViewHolder> implements Filterable {
+ protected static final Logger LOG = LoggerFactory.getLogger(AppBlacklistAdapter.class);
+
+ public static final String STRING_EXTRA_PACKAGE_NAME = "packageName";
+
+ private final List<ApplicationInfo> applicationInfoList;
+ private final int mLayoutId;
+ private final Context mContext;
+ private final PackageManager mPm;
+ private final IdentityHashMap<ApplicationInfo, String> mNameMap;
+
+ private ApplicationFilter applicationFilter;
+
+ public AppBlacklistAdapter(int layoutId, Context context) {
+ mLayoutId = layoutId;
+ mContext = context;
+ mPm = context.getPackageManager();
+
+ applicationInfoList = getAllApplications();
+
+ // sort the package list by label and blacklist status
+ mNameMap = new IdentityHashMap<>(applicationInfoList.size());
+ for (ApplicationInfo ai : applicationInfoList) {
+ CharSequence name = mPm.getApplicationLabel(ai);
+ if (name == null) {
+ name = ai.packageName;
+ }
+ if (GBApplication.appIsNotifBlacklisted(ai.packageName) || GBApplication.appIsPebbleBlacklisted(packageNameToPebbleMsgSender(ai.packageName))) {
+ // sort blacklisted first by prefixing with a '!'
+ name = "!" + name;
+ }
+ mNameMap.put(ai, name.toString());
+ }
+
+ Collections.sort(applicationInfoList, new Comparator<ApplicationInfo>() {
+ @Override
+ public int compare(ApplicationInfo ai1, ApplicationInfo ai2) {
+ final String s1 = mNameMap.get(ai1);
+ final String s2 = mNameMap.get(ai2);
+ return s1.compareToIgnoreCase(s2);
+ }
+ });
+
+ }
+
+ @Override
+ public AppBlacklistAdapter.AppBLViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(mContext).inflate(mLayoutId, parent, false);
+ return new AppBLViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(final AppBlacklistAdapter.AppBLViewHolder holder, int position) {
+ final ApplicationInfo appInfo = applicationInfoList.get(position);
+
+ holder.deviceAppVersionAuthorLabel.setText(appInfo.packageName);
+ holder.deviceAppNameLabel.setText(mNameMap.get(appInfo));
+ holder.deviceImageView.setImageDrawable(appInfo.loadIcon(mPm));
+
+ holder.blacklist_checkbox.setChecked(GBApplication.appIsNotifBlacklisted(appInfo.packageName));
+ holder.blacklist_pebble_checkbox.setChecked(GBApplication.appIsPebbleBlacklisted(packageNameToPebbleMsgSender(appInfo.packageName)));
+
+ holder.blacklist_pebble_checkbox.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ ((CheckedTextView) view).toggle();
+ if (((CheckedTextView) view).isChecked()) {
+ GBApplication.addAppToPebbleBlacklist(appInfo.packageName);
+ } else {
+ GBApplication.removeFromAppsPebbleBlacklist(appInfo.packageName);
+ }
+
+ }
+ });
+ holder.itemView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ CheckedTextView checkBox = (v.findViewById(R.id.item_checkbox));
+ checkBox.toggle();
+ if (checkBox.isChecked()) {
+ GBApplication.addAppToNotifBlacklist(appInfo.packageName);
+ } else {
+ GBApplication.removeFromAppsNotifBlacklist(appInfo.packageName);
+ }
+ }
+ });
+
+ holder.btnConfigureApp.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+
+ if (GBApplication.getPrefs().getString("notification_list_is_blacklist", "true").equals("true")) {
+ if (holder.blacklist_checkbox.isChecked()) {
+ GB.toast(mContext, mContext.getString(R.string.toast_app_must_not_be_selected), Toast.LENGTH_SHORT, GB.INFO);
+ } else {
+ Intent intentStartNotificationFilterActivity = new Intent(mContext, NotificationFilterActivity.class);
+ intentStartNotificationFilterActivity.putExtra(STRING_EXTRA_PACKAGE_NAME, appInfo.packageName);
+ mContext.startActivity(intentStartNotificationFilterActivity);
+ }
+ } else {
+ if (holder.blacklist_checkbox.isChecked()) {
+ Intent intentStartNotificationFilterActivity = new Intent(mContext, NotificationFilterActivity.class);
+ intentStartNotificationFilterActivity.putExtra(STRING_EXTRA_PACKAGE_NAME, appInfo.packageName);
+ mContext.startActivity(intentStartNotificationFilterActivity);
+ } else {
+ GB.toast(mContext, mContext.getString(R.string.toast_app_must_be_selected), Toast.LENGTH_SHORT, GB.INFO);
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Returns all applications on the device, including applications in work profiles.
+ */
+ public List<ApplicationInfo> getAllApplications() {
+ final Set<String> allPackageNames = new HashSet<>();
+ final List<ApplicationInfo> ret = new LinkedList<>();
+
+ // Get apps for the current user
+ final List<ApplicationInfo> currentUserApps = mPm.getInstalledApplications(PackageManager.GET_META_DATA);
+ for (final ApplicationInfo app : currentUserApps) {
+ allPackageNames.add(app.packageName);
+ ret.add(app);
+ }
+
+ // Add all apps from other users (eg. manager profile)
+ try {
+ final UserHandle currentUser = Process.myUserHandle();
+ final LauncherApps launcher = (LauncherApps) mContext.getSystemService(Context.LAUNCHER_APPS_SERVICE);
+ final UserManager um = (UserManager) mContext.getSystemService(Context.USER_SERVICE);
+ final List<UserHandle> userProfiles = um.getUserProfiles();
+ for (final UserHandle userProfile : userProfiles) {
+ if (userProfile.equals(currentUser)) {
+ continue;
+ }
+
+ final List<LauncherActivityInfo> userActivityList = launcher.getActivityList(null, userProfile);
+
+ for (final LauncherActivityInfo app : userActivityList) {
+ if (!allPackageNames.contains(app.getApplicationInfo().packageName)) {
+ allPackageNames.add(app.getApplicationInfo().packageName);
+ ret.add(app.getApplicationInfo());
+ }
+ }
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to get apps from other users", e);
+ }
+
+ return ret;
+ }
+
+ public void checkAllApplications() {
+ Set<String> apps_blacklist = new HashSet<>();
+ List<ApplicationInfo> allApps = getAllApplications();
+ for (ApplicationInfo ai : allApps) {
+ apps_blacklist.add(ai.packageName);
+ }
+ GBApplication.setAppsNotifBlackList(apps_blacklist);
+ notifyDataSetChanged();
+ }
+
+ public void uncheckAllApplications() {
+ Set<String> apps_blacklist = new HashSet<>();
+ GBApplication.setAppsNotifBlackList(apps_blacklist);
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getItemCount() {
+ return applicationInfoList.size();
+ }
+
+ @Override
+ public Filter getFilter() {
+ if (applicationFilter == null)
+ applicationFilter = new ApplicationFilter(this, applicationInfoList);
+ return applicationFilter;
+ }
+
+ class AppBLViewHolder extends RecyclerView.ViewHolder {
+
+ final CheckedTextView blacklist_checkbox;
+ final CheckedTextView blacklist_pebble_checkbox;
+ final ImageView deviceImageView;
+ final TextView deviceAppVersionAuthorLabel;
+ final TextView deviceAppNameLabel;
+ final ImageView btnConfigureApp;
+
+ AppBLViewHolder(View itemView) {
+ super(itemView);
+
+ blacklist_checkbox = itemView.findViewById(R.id.item_checkbox);
+ blacklist_pebble_checkbox = itemView.findViewById(R.id.item_pebble_checkbox);
+ deviceImageView = itemView.findViewById(R.id.item_image);
+ deviceAppVersionAuthorLabel = itemView.findViewById(R.id.item_details);
+ deviceAppNameLabel = itemView.findViewById(R.id.item_name);
+ btnConfigureApp = itemView.findViewById(R.id.btn_configureApp);
+ }
+
+ }
+
+ private class ApplicationFilter extends Filter {
+
+ private final AppBlacklistAdapter adapter;
+ private final List<ApplicationInfo> originalList;
+ private final List<ApplicationInfo> filteredList;
+
+ private ApplicationFilter(AppBlacklistAdapter adapter, List<ApplicationInfo> originalList) {
+ super();
+ this.originalList = new ArrayList<>(originalList);
+ this.filteredList = new ArrayList<>();
+ this.adapter = adapter;
+ }
+
+ @Override
+ protected Filter.FilterResults performFiltering(CharSequence filter) {
+ filteredList.clear();
+ final Filter.FilterResults results = new Filter.FilterResults();
+
+ if (filter == null || filter.length() == 0)
+ filteredList.addAll(originalList);
+ else {
+ final String filterPattern = filter.toString().toLowerCase().trim();
+
+ for (ApplicationInfo ai : originalList) {
+ CharSequence name = mPm.getApplicationLabel(ai);
+ if (name.toString().toLowerCase().contains(filterPattern) ||
+ (ai.packageName.contains(filterPattern))) {
+ filteredList.add(ai);
+ }
+ }
+ }
+
+ results.values = filteredList;
+ results.count = filteredList.size();
+ return results;
+ }
+
+ @Override
+ protected void publishResults(CharSequence charSequence, Filter.FilterResults filterResults) {
+ adapter.applicationInfoList.clear();
+ adapter.applicationInfoList.addAll((List<ApplicationInfo>) filterResults.values);
+ adapter.notifyDataSetChanged();
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppSpecificNotificationSettingsAppListAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppSpecificNotificationSettingsAppListAdapter.java
new file mode 100644
index 0000000..966a13b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/AppSpecificNotificationSettingsAppListAdapter.java
@@ -0,0 +1,252 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.adapter;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.LauncherActivityInfo;
+import android.content.pm.LauncherApps;
+import android.content.pm.PackageManager;
+import android.os.Process;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Filter;
+import android.widget.Filterable;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+
+import androidx.recyclerview.widget.RecyclerView;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.app_specific_notifications.AppSpecificNotificationSettingsDetailActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+import static nodomain.freeyourgadget.gadgetbridge.GBApplication.packageNameToPebbleMsgSender;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AppSpecificNotificationSettingsAppListAdapter extends RecyclerView.Adapter<AppSpecificNotificationSettingsAppListAdapter.AppNotificationSettingsViewHolder> implements Filterable {
+ protected static final Logger LOG = LoggerFactory.getLogger(AppSpecificNotificationSettingsAppListAdapter.class);
+
+ public static final String STRING_EXTRA_PACKAGE_NAME = "packageName";
+ public static final String STRING_EXTRA_PACKAGE_TITLE = "packageTitle";
+
+ private final List<ApplicationInfo> applicationInfoList;
+ private final int mLayoutId;
+ private final Context mContext;
+ private final PackageManager mPm;
+ private GBDevice mDevice;
+ private final IdentityHashMap<ApplicationInfo, String> mNameMap;
+
+ private ApplicationFilter applicationFilter;
+
+ public AppSpecificNotificationSettingsAppListAdapter(int layoutId, Context context, GBDevice device) {
+ mLayoutId = layoutId;
+ mContext = context;
+ mPm = context.getPackageManager();
+ mDevice = device;
+
+ applicationInfoList = getAllApplications();
+
+
+ // sort the package list by label and blacklist status
+ mNameMap = new IdentityHashMap<>(applicationInfoList.size());
+ for (ApplicationInfo ai : applicationInfoList) {
+ CharSequence name = mPm.getApplicationLabel(ai);
+ if (name == null) {
+ name = ai.packageName;
+ }
+ mNameMap.put(ai, name.toString());
+ }
+
+ Collections.sort(applicationInfoList, (ai1, ai2) -> {
+ final String s1 = mNameMap.get(ai1);
+ final String s2 = mNameMap.get(ai2);
+ return s1.compareToIgnoreCase(s2);
+ });
+
+ }
+
+ @Override
+ public AppNotificationSettingsViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(mContext).inflate(mLayoutId, parent, false);
+ return new AppNotificationSettingsViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(final AppNotificationSettingsViewHolder holder, int position) {
+ final ApplicationInfo appInfo = applicationInfoList.get(position);
+
+ holder.deviceAppVersionAuthorLabel.setText(appInfo.packageName);
+ holder.deviceAppNameLabel.setText(mNameMap.get(appInfo));
+ holder.deviceImageView.setImageDrawable(appInfo.loadIcon(mPm));
+
+ holder.itemView.setOnClickListener(v -> {
+ Intent intentStartNotificationFilterActivity = new Intent(mContext, AppSpecificNotificationSettingsDetailActivity.class);
+ intentStartNotificationFilterActivity.putExtra(STRING_EXTRA_PACKAGE_NAME, appInfo.packageName);
+ intentStartNotificationFilterActivity.putExtra(STRING_EXTRA_PACKAGE_TITLE, mNameMap.get(appInfo));
+ intentStartNotificationFilterActivity.putExtra(GBDevice.EXTRA_DEVICE, mDevice);
+ mContext.startActivity(intentStartNotificationFilterActivity);
+ });
+
+ holder.btnConfigureApp.setOnClickListener(view -> {
+ Intent intentStartNotificationFilterActivity = new Intent(mContext, AppSpecificNotificationSettingsDetailActivity.class);
+ intentStartNotificationFilterActivity.putExtra(STRING_EXTRA_PACKAGE_NAME, appInfo.packageName);
+ intentStartNotificationFilterActivity.putExtra(STRING_EXTRA_PACKAGE_TITLE, mNameMap.get(appInfo));
+ intentStartNotificationFilterActivity.putExtra(GBDevice.EXTRA_DEVICE, mDevice);
+ mContext.startActivity(intentStartNotificationFilterActivity);
+ });
+ }
+
+ /**
+ * Returns the applications for which the Gadgetbridge notifications are enabled.
+ */
+ public List<ApplicationInfo> getAllApplications() {
+ final Set<String> allPackageNames = new HashSet<>();
+ final List<ApplicationInfo> ret = new LinkedList<>();
+ boolean filterInverted = !GBApplication.getPrefs().getString("notification_list_is_blacklist", "true").equals("true");
+
+ // Get apps for the current user
+ final List<ApplicationInfo> currentUserApps = mPm.getInstalledApplications(PackageManager.GET_META_DATA);
+ for (final ApplicationInfo app : currentUserApps) {
+ boolean blacklisted = GBApplication.appIsNotifBlacklisted(app.packageName) || GBApplication.appIsPebbleBlacklisted(packageNameToPebbleMsgSender(app.packageName));
+ if((!filterInverted && !blacklisted) || (filterInverted && blacklisted)) {
+ allPackageNames.add(app.packageName);
+ ret.add(app);
+ }
+ }
+
+ // Add all apps from other users (eg. manager profile)
+ try {
+ final UserHandle currentUser = Process.myUserHandle();
+ final LauncherApps launcher = (LauncherApps) mContext.getSystemService(Context.LAUNCHER_APPS_SERVICE);
+ final UserManager um = (UserManager) mContext.getSystemService(Context.USER_SERVICE);
+ final List<UserHandle> userProfiles = um.getUserProfiles();
+ for (final UserHandle userProfile : userProfiles) {
+ if (userProfile.equals(currentUser)) {
+ continue;
+ }
+
+ final List<LauncherActivityInfo> userActivityList = launcher.getActivityList(null, userProfile);
+
+ for (final LauncherActivityInfo app : userActivityList) {
+ if (!allPackageNames.contains(app.getApplicationInfo().packageName)) {
+ boolean blacklisted = GBApplication.appIsNotifBlacklisted(app.getApplicationInfo().packageName) || GBApplication.appIsPebbleBlacklisted(packageNameToPebbleMsgSender(app.getApplicationInfo().packageName));
+ if((!filterInverted && !blacklisted) || (filterInverted && blacklisted)) {
+ allPackageNames.add(app.getApplicationInfo().packageName);
+ ret.add(app.getApplicationInfo());
+ }
+ }
+ }
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to get apps from other users", e);
+ }
+
+ return ret;
+ }
+
+
+ @Override
+ public int getItemCount() {
+ return applicationInfoList.size();
+ }
+
+ @Override
+ public Filter getFilter() {
+ if (applicationFilter == null)
+ applicationFilter = new ApplicationFilter(this, applicationInfoList);
+ return applicationFilter;
+ }
+
+ class AppNotificationSettingsViewHolder extends RecyclerView.ViewHolder {
+
+ final ImageView deviceImageView;
+ final TextView deviceAppVersionAuthorLabel;
+ final TextView deviceAppNameLabel;
+ final ImageView btnConfigureApp;
+
+ AppNotificationSettingsViewHolder(View itemView) {
+ super(itemView);
+
+ deviceImageView = itemView.findViewById(R.id.item_image);
+ deviceAppVersionAuthorLabel = itemView.findViewById(R.id.item_details);
+ deviceAppNameLabel = itemView.findViewById(R.id.item_name);
+ btnConfigureApp = itemView.findViewById(R.id.btn_configureApp);
+ }
+
+ }
+
+ private class ApplicationFilter extends Filter {
+
+ private final AppSpecificNotificationSettingsAppListAdapter adapter;
+ private final List<ApplicationInfo> originalList;
+ private final List<ApplicationInfo> filteredList;
+
+ private ApplicationFilter(AppSpecificNotificationSettingsAppListAdapter adapter, List<ApplicationInfo> originalList) {
+ super();
+ this.originalList = new ArrayList<>(originalList);
+ this.filteredList = new ArrayList<>();
+ this.adapter = adapter;
+ }
+
+ @Override
+ protected Filter.FilterResults performFiltering(CharSequence filter) {
+ filteredList.clear();
+ final Filter.FilterResults results = new Filter.FilterResults();
+
+ if (filter == null || filter.length() == 0)
+ filteredList.addAll(originalList);
+ else {
+ final String filterPattern = filter.toString().toLowerCase().trim();
+
+ for (ApplicationInfo ai : originalList) {
+ CharSequence name = mPm.getApplicationLabel(ai);
+ if (name.toString().toLowerCase().contains(filterPattern) ||
+ (ai.packageName.contains(filterPattern))) {
+ filteredList.add(ai);
+ }
+ }
+ }
+
+ results.values = filteredList;
+ results.count = filteredList.size();
+ return results;
+ }
+
+ @Override
+ protected void publishResults(CharSequence charSequence, Filter.FilterResults filterResults) {
+ adapter.applicationInfoList.clear();
+ adapter.applicationInfoList.addAll((List<ApplicationInfo>) filterResults.values);
+ adapter.notifyDataSetChanged();
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/DeviceCandidateAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/DeviceCandidateAdapter.java
new file mode 100644
index 0000000..40126b3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/DeviceCandidateAdapter.java
@@ -0,0 +1,100 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
+ Dakhno, Jos茅 Rebelo, Petr Van臎k, Taavi Eom盲e
+
+ 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.adapter;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+/**
+ * Adapter for displaying GBDeviceCandate instances.
+ */
+public class DeviceCandidateAdapter extends ArrayAdapter<GBDeviceCandidate> {
+
+ private final Context context;
+
+ public DeviceCandidateAdapter(Context context, List<GBDeviceCandidate> deviceCandidates) {
+ super(context, 0, deviceCandidates);
+
+ this.context = context;
+ }
+
+ @Override
+ public View getView(int position, View view, ViewGroup parent) {
+ GBDeviceCandidate device = getItem(position);
+ if (view == null) {
+ LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ view = inflater.inflate(R.layout.item_with_details, parent, false);
+ }
+ ImageView deviceImageView = view.findViewById(R.id.item_image);
+ TextView deviceNameLabel = view.findViewById(R.id.item_name);
+ TextView deviceAddressLabel = view.findViewById(R.id.item_details);
+ TextView deviceStatus = view.findViewById(R.id.item_status);
+
+ DeviceType deviceType = DeviceHelper.getInstance().resolveDeviceType(device);
+
+ DeviceCoordinator coordinator = deviceType.getDeviceCoordinator();
+
+ String name = formatDeviceCandidate(device);
+ deviceNameLabel.setText(name);
+ deviceAddressLabel.setText(device.getMacAddress());
+ deviceImageView.setImageResource(coordinator.getDefaultIconResource());
+
+ final List<String> statusLines = new ArrayList<>();
+ if (device.isBonded()) {
+ statusLines.add(getContext().getString(R.string.device_is_currently_bonded));
+ }
+
+ if (!deviceType.isSupported()) {
+ statusLines.add(getContext().getString(R.string.device_unsupported));
+ }
+
+ if (coordinator.isExperimental()) {
+ statusLines.add(getContext().getString(R.string.device_experimental));
+ }
+ if (coordinator.getBondingStyle() == DeviceCoordinator.BONDING_STYLE_REQUIRE_KEY) {
+ statusLines.add(getContext().getString(R.string.device_requires_key));
+ }
+
+ deviceStatus.setText(TextUtils.join("\n", statusLines));
+ return view;
+ }
+
+ private String formatDeviceCandidate(GBDeviceCandidate device) {
+ if (device.getRssi() > GBDevice.RSSI_UNKNOWN) {
+ return context.getString(R.string.device_with_rssi, device.getName(), GB.formatRssi(device.getRssi()));
+ }
+ return device.getName();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBAlarmListAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBAlarmListAdapter.java
new file mode 100644
index 0000000..66ace18
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBAlarmListAdapter.java
@@ -0,0 +1,166 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniele Gobbetti
+
+ 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.adapter;
+
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CheckedTextView;
+import android.widget.CompoundButton;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.card.MaterialCardView;
+import com.google.android.material.materialswitch.MaterialSwitch;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureAlarms;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.entities.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+/**
+ * Adapter for displaying GBAlarm instances.
+ */
+public class GBAlarmListAdapter extends RecyclerView.Adapter<GBAlarmListAdapter.ViewHolder> {
+
+ private final Context mContext;
+ private ArrayList<Alarm> alarmList;
+
+ public GBAlarmListAdapter(Context context) {
+ this.mContext = context;
+ }
+
+ public void setAlarmList(List<Alarm> alarms) {
+ this.alarmList = new ArrayList<>(alarms);
+ }
+
+ public ArrayList<Alarm> getAlarmList() {
+ return alarmList;
+ }
+
+ private void updateInDB(Alarm alarm) {
+ DBHelper.store(alarm);
+ }
+
+ @NonNull
+ @Override
+ public GBAlarmListAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_alarm, parent, false);
+ return new ViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull final ViewHolder holder, final int position) {
+
+ final Alarm alarm = alarmList.get(position);
+
+ holder.alarmDayMonday.setChecked(alarm.getRepetition(Alarm.ALARM_MON));
+ holder.alarmDayTuesday.setChecked(alarm.getRepetition(Alarm.ALARM_TUE));
+ holder.alarmDayWednesday.setChecked(alarm.getRepetition(Alarm.ALARM_WED));
+ holder.alarmDayThursday.setChecked(alarm.getRepetition(Alarm.ALARM_THU));
+ holder.alarmDayFriday.setChecked(alarm.getRepetition(Alarm.ALARM_FRI));
+ holder.alarmDaySaturday.setChecked(alarm.getRepetition(Alarm.ALARM_SAT));
+ holder.alarmDaySunday.setChecked(alarm.getRepetition(Alarm.ALARM_SUN));
+ holder.container.setAlpha(alarm.getUnused() ? 0.5f : 1.0f);
+ holder.isEnabled.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ if (isChecked) {
+ alarm.setUnused(false);
+ holder.container.setAlpha(1.0f);
+ }
+ alarm.setEnabled(isChecked);
+ updateInDB(alarm);
+ }
+ });
+
+ holder.container.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ((ConfigureAlarms) mContext).configureAlarm(alarm);
+ }
+ });
+ holder.container.setOnLongClickListener(new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ alarm.setUnused(!alarm.getUnused());
+ holder.container.setAlpha(alarm.getUnused() ? 0.5f : 1.0f);
+ holder.isEnabled.setChecked(false); // This falls through to the onCheckChanged function
+ updateInDB(alarm);
+ return true;
+ }
+ });
+
+ holder.alarmTime.setText(DateTimeUtils.formatTime(alarm.getHour(), alarm.getMinute()));
+ holder.isEnabled.setChecked(alarm.getEnabled());
+ if (alarm.getSmartWakeup()) {
+ holder.isSmartWakeup.setVisibility(TextView.VISIBLE);
+ } else {
+ holder.isSmartWakeup.setVisibility(TextView.GONE);
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return alarmList.size();
+ }
+
+ static class ViewHolder extends RecyclerView.ViewHolder {
+
+ MaterialCardView container;
+
+ TextView alarmTime;
+ MaterialSwitch isEnabled;
+ TextView isSmartWakeup;
+
+ CheckedTextView alarmDayMonday;
+ CheckedTextView alarmDayTuesday;
+ CheckedTextView alarmDayWednesday;
+ CheckedTextView alarmDayThursday;
+ CheckedTextView alarmDayFriday;
+ CheckedTextView alarmDaySaturday;
+ CheckedTextView alarmDaySunday;
+
+ ViewHolder(View view) {
+ super(view);
+
+ container = view.findViewById(R.id.card_view);
+
+ alarmTime = view.findViewById(R.id.alarm_item_time);
+ isEnabled = view.findViewById(R.id.alarm_item_toggle);
+ isSmartWakeup = view.findViewById(R.id.alarm_smart_wakeup);
+
+ alarmDayMonday = view.findViewById(R.id.alarm_item_monday);
+ alarmDayTuesday = view.findViewById(R.id.alarm_item_tuesday);
+ alarmDayWednesday = view.findViewById(R.id.alarm_item_wednesday);
+ alarmDayThursday = view.findViewById(R.id.alarm_item_thursday);
+ alarmDayFriday = view.findViewById(R.id.alarm_item_friday);
+ alarmDaySaturday = view.findViewById(R.id.alarm_item_saturday);
+ alarmDaySunday = view.findViewById(R.id.alarm_item_sunday);
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBContactListAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBContactListAdapter.java
new file mode 100644
index 0000000..7c98310
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBContactListAdapter.java
@@ -0,0 +1,110 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.adapter;
+
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.card.MaterialCardView;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureContacts;
+import nodomain.freeyourgadget.gadgetbridge.entities.Contact;
+
+/**
+ * Adapter for displaying Contact instances.
+ */
+public class GBContactListAdapter extends RecyclerView.Adapter<GBContactListAdapter.ViewHolder> {
+
+ private final Context mContext;
+ private ArrayList<Contact> contactList;
+
+ public GBContactListAdapter(Context context) {
+ this.mContext = context;
+ }
+
+ public void setContactList(List<Contact> contacts) {
+ this.contactList = new ArrayList<>(contacts);
+ }
+
+ public ArrayList<Contact> getContactList() {
+ return contactList;
+ }
+
+ @NonNull
+ @Override
+ public GBContactListAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ final View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_contact, parent, false);
+ return new ViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, final int position) {
+ final Contact contact = contactList.get(position);
+
+ holder.container.setOnClickListener(v -> ((ConfigureContacts) mContext).configureContact(contact));
+
+ holder.container.setOnLongClickListener(v -> {
+ new MaterialAlertDialogBuilder(v.getContext())
+ .setTitle(R.string.contact_delete_confirm_title)
+ .setMessage(mContext.getString(R.string.contact_delete_confirm_description, contact.getName()))
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
+ ((ConfigureContacts) mContext).deleteContact(contact);
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+
+ return true;
+ });
+
+ holder.contactName.setText(contact.getName());
+ holder.contactNumber.setText(contact.getNumber());
+ }
+
+ @Override
+ public int getItemCount() {
+ return contactList.size();
+ }
+
+ static class ViewHolder extends RecyclerView.ViewHolder {
+ final MaterialCardView container;
+
+ final TextView contactName;
+ final TextView contactNumber;
+
+ ViewHolder(View view) {
+ super(view);
+
+ container = view.findViewById(R.id.card_contact);
+
+ contactName = view.findViewById(R.id.contact_item_name);
+ contactNumber = view.findViewById(R.id.contact_item_phone_number);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java
new file mode 100644
index 0000000..aab204d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java
@@ -0,0 +1,1520 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Damien Gaignon, Daniel Dakhno, Daniele Gobbetti, Davis Mosenkovs,
+ fparri, Jos茅 Rebelo, mamucho, maxirnilian, mkusnierz, Petr Van臎k, Taavi
+ Eom盲e
+
+ 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.adapter;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_CONNECT;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutManager;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.Icon;
+import android.os.Build;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.Pair;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.NumberPicker;
+import android.widget.PopupMenu;
+import android.widget.ProgressBar;
+import android.widget.RelativeLayout;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.coordinatorlayout.widget.CoordinatorLayout;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.ListAdapter;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.github.mikephil.charting.charts.PieChart;
+import com.github.mikephil.charting.data.PieData;
+import com.github.mikephil.charting.data.PieDataSet;
+import com.github.mikephil.charting.data.PieEntry;
+import com.github.mikephil.charting.utils.MPPointF;
+import com.google.android.flexbox.FlexboxLayout;
+import com.google.android.material.card.MaterialCardView;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.snackbar.Snackbar;
+import com.jaredrummler.android.colorpicker.ColorPickerDialog;
+import com.jaredrummler.android.colorpicker.ColorPickerDialogListener;
+
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Hashtable;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.ActivitySummariesActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.BatteryInfoActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureAlarms;
+import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureReminders;
+import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2;
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateDialog;
+import nodomain.freeyourgadget.gadgetbridge.activities.OpenFwAppInstallerActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.VibrationActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.ActivityChartsActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+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.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceFolder;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
+import nodomain.freeyourgadget.gadgetbridge.model.DailyTotals;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.FormatUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+/**
+ * Adapter for displaying GBDevice instances.
+ */
+public class GBDeviceAdapterv2 extends ListAdapter<GBDevice, GBDeviceAdapterv2.ViewHolder> {
+ private static final Logger LOG = LoggerFactory.getLogger(GBDeviceAdapterv2.class);
+
+ private final Context context;
+ private List<GBDevice> deviceList;
+ private List<GBDevice> devicesListWithFolders;
+ private String expandedDeviceAddress = "";
+ private String expandedFolderName = "";
+ private ViewGroup parent;
+ private HashMap<String, DailyTotals> deviceActivityMap = new HashMap<>();
+ private final StableIdGenerator idGenerator = new StableIdGenerator();
+
+ public GBDeviceAdapterv2(Context context, List<GBDevice> deviceList, HashMap<String, DailyTotals> deviceMap) {
+ super(new GBDeviceDiffUtil());
+ this.context = context;
+ this.deviceList = deviceList;
+ rebuildFolders();
+ this.deviceActivityMap = deviceMap;
+ }
+
+ public void rebuildFolders() {
+ this.devicesListWithFolders = enrichDeviceListWithFolder(deviceList);
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ public final void refreshSingleDevice(final GBDevice device) {
+ final int i = devicesListWithFolders.indexOf(device);
+ if (i > 0) {
+ notifyItemChanged(i);
+ } else {
+ // Somehow the device was not on the list - rebuild everything
+ rebuildFolders();
+ notifyDataSetChanged();
+ }
+ }
+
+ private List<GBDevice> enrichDeviceListWithFolder(List<GBDevice> deviceList) {
+ final Map<String, List<GBDevice>> devicesPerFolder = new LinkedHashMap<>();
+ final List<GBDevice> enrichedList = new ArrayList<>();
+
+ for (GBDevice device : deviceList) {
+ String folder = device.getParentFolder();
+ if (StringUtils.isNullOrEmpty(folder)){
+ enrichedList.add(device);
+ continue;
+ }
+ if (!devicesPerFolder.containsKey(folder)) {
+ devicesPerFolder.put(folder, new ArrayList<>());
+ }
+ devicesPerFolder.get(folder).add(device);
+ }
+
+ for (final Map.Entry<String, List<GBDevice>> folder : devicesPerFolder.entrySet()) {
+ enrichedList.add(new GBDeviceFolder(folder.getKey()));
+ if (folder.getKey().equals(expandedFolderName)) {
+ enrichedList.addAll(folder.getValue());
+ }
+ }
+
+ return enrichedList;
+ }
+
+ @NonNull
+ @Override
+ public GBDeviceAdapterv2.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ this.parent = parent;
+ View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.device_itemv2, parent, false);
+ return new ViewHolder(view);
+ }
+
+ private int countDevicesInFolder(String folderName, boolean needsToBeConnected){
+ int count = 0;
+ for(GBDevice device : deviceList){
+ if(folderName.equals(device.getParentFolder()) && ((!needsToBeConnected) || device.isConnected())){
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private void showDeviceFolder(ViewHolder holder, final GBDeviceFolder folder){
+ holder.container.setVisibility(View.VISIBLE);
+ holder.deviceNameLabel.setText(folder.getName());
+ holder.infoIcons.setVisibility(View.GONE);
+ holder.deviceInfoBox.setVisibility(View.GONE);
+ holder.cardViewActivityCardLayout.setVisibility(View.GONE);
+ if(countDevicesInFolder(folder.getName(), true) == 0){
+ holder.deviceImageView.setImageResource(R.drawable.ic_device_folder_disabled);
+ }else{
+
+ holder.deviceImageView.setImageResource(R.drawable.ic_device_folder);
+ }
+ holder.deviceInfoView.setVisibility(View.GONE);
+ int countInFolder = countDevicesInFolder(folder.getName(), false);
+ int connectedInFolder = countDevicesInFolder(folder.getName(), true);
+ holder.deviceStatusLabel.setText(context.getString(R.string.controlcenter_connected_fraction, connectedInFolder, countInFolder));
+
+ holder.container.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (expandedFolderName.equals(folder.getName())){
+ // collapse open folder
+ expandedFolderName = "";
+ } else {
+ expandedFolderName = folder.getName();
+ }
+ rebuildFolders();
+ notifyDataSetChanged();
+ }
+ });
+ holder.container.setOnLongClickListener(null);
+ }
+
+ private void setItemMargin(ViewHolder holder, GBDevice device){
+ Resources r = context.getResources();
+ int widthDp = 8;
+ if(!StringUtils.isNullOrEmpty(device.getParentFolder())){
+ widthDp = 16;
+ }
+ float px = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ widthDp,
+ r.getDisplayMetrics()
+ );
+ CoordinatorLayout.LayoutParams layoutParams = (CoordinatorLayout.LayoutParams) holder.container.getLayoutParams();
+ layoutParams.setMarginStart((int) px);
+ holder.container.setLayoutParams(layoutParams);
+
+ int alpha = 0;
+ if(device instanceof GBDeviceFolder && device.getName().equals(expandedFolderName)){
+ alpha = 50;
+ }else if(!StringUtils.isNullOrEmpty(device.getParentFolder()) && expandedFolderName.equals(device.getParentFolder())){
+ alpha = 50;
+ }
+
+ holder.root.setBackgroundColor(Color.argb(alpha, 0, 0, 0));
+ }
+
+ void handleDeviceConnect(GBDevice device){
+ if(!device.getDeviceCoordinator().isConnectable()){
+ device.setState(GBDevice.State.WAITING_FOR_SCAN);
+ device.sendDeviceUpdateIntent(GBApplication.getContext(), GBDevice.DeviceUpdateSubject.CONNECTION_STATE);
+ return;
+ }
+
+ GBApplication.deviceService(device).connect();
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, final int position) {
+ final GBDevice device = devicesListWithFolders.get(position);
+
+ setItemMargin(holder, device);
+
+ if(device instanceof GBDeviceFolder){
+ showDeviceFolder(holder, (GBDeviceFolder) device);
+ return;
+ }
+
+ String parentFolder = device.getParentFolder();
+ if(!StringUtils.isNullOrEmpty(parentFolder)){
+ if(parentFolder.equals(expandedFolderName)){
+ holder.container.setVisibility(View.VISIBLE);
+ }else{
+ holder.container.setVisibility(View.GONE);
+ }
+ }else{
+ holder.container.setVisibility(View.VISIBLE);
+ }
+
+ DailyTotals dailyTotals = new DailyTotals();
+ if (deviceActivityMap.containsKey(device.getAddress())) {
+ dailyTotals = deviceActivityMap.get(device.getAddress());
+ }
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ holder.container.setOnClickListener(new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+
+ if (device.isInitialized() || device.isConnected()) {
+ showTransientSnackbar(R.string.controlcenter_snackbar_need_longpress);
+ } else {
+ showTransientSnackbar(R.string.controlcenter_snackbar_connecting);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ createDynamicShortcut(device);
+ }
+ handleDeviceConnect(device);
+ }
+ }
+ });
+
+ holder.container.setOnLongClickListener(new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ showDeviceSubmenu(v, device);
+ return true;
+ }
+ });
+
+ holder.deviceImageView.setImageResource(device.getEnabledDisabledIconResource());
+
+ holder.deviceNameLabel.setText(getUniqueDeviceName(device));
+
+ if (device.isBusy()) {
+ holder.deviceStatusLabel.setText(device.getBusyTask());
+ holder.busyIndicator.setVisibility(View.VISIBLE);
+ } else {
+ holder.deviceStatusLabel.setText(device.getStateString(context));
+ holder.busyIndicator.setVisibility(View.INVISIBLE);
+ }
+
+ //begin of action row
+ //battery
+ // multiple battery support: at this point we support up to three batteries
+ // to support more batteries, the battery UI would need to be extended
+
+ holder.batteryStatusBox0.setVisibility(coordinator.getBatteryCount() > 0 ? View.VISIBLE : View.GONE);
+ holder.batteryStatusBox1.setVisibility(coordinator.getBatteryCount() > 1 ? View.VISIBLE : View.GONE);
+ holder.batteryStatusBox2.setVisibility(coordinator.getBatteryCount() > 2 ? View.VISIBLE : View.GONE);
+
+ LinearLayout[] batteryStatusBoxes = {holder.batteryStatusBox0, holder.batteryStatusBox1, holder.batteryStatusBox2};
+ TextView[] batteryStatusLabels = {holder.batteryStatusLabel0, holder.batteryStatusLabel1, holder.batteryStatusLabel2};
+ ImageView[] batteryIcons = {holder.batteryIcon0, holder.batteryIcon1, holder.batteryIcon2};
+
+ for (int batteryIndex = 0; batteryIndex < coordinator.getBatteryCount(); batteryIndex++) {
+
+ int batteryLevel = device.getBatteryLevel(batteryIndex);
+ float batteryVoltage = device.getBatteryVoltage(batteryIndex);
+ BatteryState batteryState = device.getBatteryState(batteryIndex);
+ int batteryIcon = device.getBatteryIcon(batteryIndex);
+ int batteryLabel = device.getBatteryLabel(batteryIndex); //unused for now
+ batteryIcons[batteryIndex].setImageResource(R.drawable.level_list_battery);
+
+ if (batteryIcon != GBDevice.BATTERY_ICON_DEFAULT){
+ batteryIcons[batteryIndex].setImageResource(batteryIcon);
+ }
+
+ if (batteryLevel != GBDevice.BATTERY_UNKNOWN) {
+ batteryStatusLabels[batteryIndex].setText(device.getBatteryLevel(batteryIndex) + "%");
+ if (BatteryState.BATTERY_CHARGING.equals(batteryState) ||
+ BatteryState.BATTERY_CHARGING_FULL.equals(batteryState)) {
+ batteryIcons[batteryIndex].setImageLevel(device.getBatteryLevel(batteryIndex) + 100);
+ } else {
+ batteryIcons[batteryIndex].setImageLevel(device.getBatteryLevel(batteryIndex));
+ }
+ } else if (BatteryState.NO_BATTERY.equals(batteryState) && batteryVoltage != GBDevice.BATTERY_UNKNOWN) {
+ batteryStatusLabels[batteryIndex].setText(String.format(Locale.getDefault(), "%.2f", batteryVoltage));
+ batteryIcons[batteryIndex].setImageLevel(200);
+ } else {
+ //should be the "default" status, shown when the device is not connected
+ batteryStatusLabels[batteryIndex].setText("");
+ batteryIcons[batteryIndex].setImageLevel(300);
+ }
+ final int finalBatteryIndex = batteryIndex;
+ batteryStatusBoxes[batteryIndex].setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent startIntent;
+ startIntent = new Intent(context, BatteryInfoActivity.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ startIntent.putExtra(GBDevice.BATTERY_INDEX, finalBatteryIndex);
+ context.startActivity(startIntent);
+ }
+ }
+ );
+
+ // Hide the battery status level, if it has no text
+ if (TextUtils.isEmpty(batteryStatusLabels[batteryIndex].getText())) {
+ batteryStatusLabels[batteryIndex].setVisibility(View.GONE);
+ } else {
+ batteryStatusLabels[batteryIndex].setVisibility(View.VISIBLE);
+ }
+ }
+ holder.heartRateStatusBox.setVisibility((device.isInitialized() && coordinator.supportsRealtimeData() && coordinator.supportsManualHeartRateMeasurement(device)) ? View.VISIBLE : View.GONE);
+ if (parent.getContext() instanceof ControlCenterv2) {
+ ActivitySample sample = ((ControlCenterv2) parent.getContext()).getCurrentHRSample(device);
+ if (sample != null) {
+ holder.heartRateStatusLabel.setText(String.valueOf(sample.getHeartRate()));
+ } else {
+ holder.heartRateStatusLabel.setText("");
+ }
+
+ // Hide the level, if it has no text
+ if (TextUtils.isEmpty(holder.heartRateStatusLabel.getText())) {
+ holder.heartRateStatusLabel.setVisibility(View.GONE);
+ } else {
+ holder.heartRateStatusLabel.setVisibility(View.VISIBLE);
+ }
+ }
+
+ holder.heartRateStatusBox.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GBApplication.deviceService(device).onHeartRateTest();
+ HeartRateDialog dialog = new HeartRateDialog(device, context);
+ dialog.show();
+ }
+ }
+ );
+
+ //device specific settings
+ holder.deviceSpecificSettingsView.setVisibility(coordinator.getSupportedDeviceSpecificSettings(device) != null ? View.VISIBLE : View.GONE);
+ holder.deviceSpecificSettingsView.setOnClickListener(new View.OnClickListener()
+
+ {
+ @Override
+ public void onClick(View v) {
+ Intent startIntent;
+ startIntent = new Intent(context, DeviceSettingsActivity.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ startIntent.putExtra(DeviceSettingsActivity.MENU_ENTRY_POINT, DeviceSettingsActivity.MENU_ENTRY_POINTS.DEVICE_SETTINGS);
+ context.startActivity(startIntent);
+ }
+ }
+ );
+
+ //fetch activity data
+ holder.fetchActivityDataBox.setVisibility((device.isInitialized() && coordinator.supportsActivityDataFetching()) ? View.VISIBLE : View.GONE);
+ holder.fetchActivityData.setOnClickListener(new View.OnClickListener()
+
+ {
+ @Override
+ public void onClick(View v) {
+ showTransientSnackbar(R.string.busy_task_fetch_activity_data);
+ GBApplication.deviceService(device).onFetchRecordedData(RecordedDataTypes.TYPE_SYNC);
+ }
+ }
+ );
+
+
+ //take screenshot
+ holder.takeScreenshotView.setVisibility((device.isInitialized() && coordinator.supportsScreenshots(device)) ? View.VISIBLE : View.GONE);
+ holder.takeScreenshotView.setOnClickListener(new View.OnClickListener()
+
+ {
+ @Override
+ public void onClick(View v) {
+ showTransientSnackbar(R.string.controlcenter_snackbar_requested_screenshot);
+ GBApplication.deviceService(device).onScreenshotReq();
+ }
+ }
+ );
+
+ //manage apps
+ holder.manageAppsView.setVisibility((device.isInitialized() && coordinator.supportsAppsManagement(device)) ? View.VISIBLE : View.GONE);
+ holder.manageAppsView.setOnClickListener(new View.OnClickListener()
+
+ {
+ @Override
+ public void onClick(View v) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ Class<? extends Activity> appsManagementActivity = coordinator.getAppsManagementActivity();
+ if (appsManagementActivity != null) {
+ Intent startIntent = new Intent(context, appsManagementActivity);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ context.startActivity(startIntent);
+ }
+ }
+ }
+ );
+
+ //set alarms
+ holder.setAlarmsView.setVisibility(coordinator.getAlarmSlotCount(device) > 0 ? View.VISIBLE : View.GONE);
+ holder.setAlarmsView.setOnClickListener(new View.OnClickListener()
+
+ {
+ @Override
+ public void onClick(View v) {
+ Intent startIntent;
+ startIntent = new Intent(context, ConfigureAlarms.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ context.startActivity(startIntent);
+ }
+ }
+ );
+
+ //set reminders
+ holder.setRemindersView.setVisibility(coordinator.getReminderSlotCount(device) > 0 ? View.VISIBLE : View.GONE);
+ holder.setRemindersView.setOnClickListener(new View.OnClickListener()
+
+ {
+ @Override
+ public void onClick(View v) {
+ Intent startIntent;
+ startIntent = new Intent(context, ConfigureReminders.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ context.startActivity(startIntent);
+ }
+ }
+ );
+
+ //show graphs
+ holder.showActivityGraphs.setVisibility(coordinator.supportsActivityTracking() ? View.VISIBLE : View.GONE);
+ holder.showActivityGraphs.setOnClickListener(new View.OnClickListener()
+
+ {
+ @Override
+ public void onClick(View v) {
+ Intent startIntent;
+ startIntent = new Intent(context, ActivityChartsActivity.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ context.startActivity(startIntent);
+ }
+ }
+ );
+
+ //show activity tracks
+ holder.showActivityTracks.setVisibility(coordinator.supportsActivityTracks() ? View.VISIBLE : View.GONE);
+ holder.showActivityTracks.setOnClickListener(new View.OnClickListener()
+ {
+ @Override
+ public void onClick(View v) {
+ Intent startIntent;
+ startIntent = new Intent(context, ActivitySummariesActivity.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ context.startActivity(startIntent);
+ }
+ }
+ );
+
+ ItemWithDetailsAdapter infoAdapter = new ItemWithDetailsAdapter(context, device.getDeviceInfos());
+ infoAdapter.setHorizontalAlignment(true);
+ holder.deviceInfoList.setAdapter(infoAdapter);
+ justifyListViewHeightBasedOnChildren(holder.deviceInfoList);
+ holder.deviceInfoList.setFocusable(false);
+
+ holder.infoIcons.setVisibility(View.VISIBLE);
+
+ final boolean detailsShown = expandedDeviceAddress.equals(device.getAddress());
+ boolean showInfoIcon = device.hasDeviceInfos() && !device.isBusy();
+ holder.deviceInfoBox.setActivated(detailsShown);
+ holder.deviceInfoBox.setVisibility(detailsShown ? View.VISIBLE : View.GONE);
+ holder.deviceInfoView.setVisibility(View.VISIBLE);
+ holder.deviceInfoView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ showDeviceSubmenu(v, device);
+ }
+ });
+
+ holder.findDevice.setVisibility(device.isInitialized() && coordinator.supportsFindDevice() ? View.VISIBLE : View.GONE);
+ holder.findDevice.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ new MaterialAlertDialogBuilder(context)
+ .setCancelable(true)
+ .setTitle(context.getString(R.string.controlcenter_find_device))
+ .setMessage(context.getString(R.string.find_lost_device_message, device.getName()))
+ .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (device.getType() == DeviceType.VIBRATISSIMO) {
+ Intent startIntent;
+ startIntent = new Intent(context, VibrationActivity.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ context.startActivity(startIntent);
+ return;
+ }
+ GBApplication.deviceService(device).onFindDevice(true);
+ Snackbar.make(parent, R.string.control_center_find_lost_device, Snackbar.LENGTH_INDEFINITE).setAction(R.string.find_lost_device_you_found_it, new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GBApplication.deviceService(device).onFindDevice(false);
+ }
+ }).setCallback(new Snackbar.Callback() {
+ @Override
+ public void onDismissed(Snackbar snackbar, int event) {
+ GBApplication.deviceService(device).onFindDevice(false);
+ super.onDismissed(snackbar, event);
+ }
+ }).show();
+
+ }
+ })
+ .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ // do nothing
+ }
+ })
+ .show();
+// ProgressDialog.show(
+// context,
+// context.getString(R.string.control_center_find_lost_device),
+// context.getString(R.string.control_center_cancel_to_stop_vibration),
+// true, true,
+// new DialogInterface.OnCancelListener() {
+// @Override
+// public void onCancel(DialogInterface dialog) {
+// GBApplication.deviceService().onFindDevice(false);
+// }
+// });
+ }
+ }
+ );
+
+ holder.calibrateDevice.setVisibility(device.isInitialized() && (coordinator.getCalibrationActivity() != null) ? View.VISIBLE : View.GONE);
+ holder.calibrateDevice.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent startIntent = new Intent(context, coordinator.getCalibrationActivity());
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ context.startActivity(startIntent);
+ }
+ });
+
+ holder.fmFrequencyBox.setVisibility(View.GONE);
+ if (device.isInitialized() && device.getExtraInfo("fm_frequency") != null) {
+ holder.fmFrequencyBox.setVisibility(View.VISIBLE);
+ holder.fmFrequencyLabel.setText(String.format(Locale.getDefault(), "%.1f", (float) device.getExtraInfo("fm_frequency")));
+ }
+ final TextView fmFrequencyLabel = holder.fmFrequencyLabel;
+ final float FREQ_MIN = 87.5F;
+ final float FREQ_MAX = 108.0F;
+ final int FREQ_MIN_INT = (int) Math.floor(FREQ_MIN);
+ final int FREQ_MAX_INT = (int) Math.round(FREQ_MAX);
+ final AlertDialog alert[] = new AlertDialog[1];
+
+ holder.fmFrequencyBox.setOnClickListener(new View.OnClickListener() {
+
+ @Override
+ public void onClick(View view) {
+ final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context);
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ final View frequency_picker_view = inflater.inflate(R.layout.dialog_frequency_picker, null);
+ builder.setTitle(R.string.preferences_fm_frequency);
+ final float[] fm_presets = new float[3];
+
+ fm_presets[0] = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()).getFloat("fm_preset0", 99);
+ fm_presets[1] = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()).getFloat("fm_preset1", 100);
+ fm_presets[2] = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()).getFloat("fm_preset2", 101);
+
+ final NumberPicker frequency_decimal_picker = frequency_picker_view.findViewById(R.id.frequency_dec);
+ frequency_decimal_picker.setMinValue(FREQ_MIN_INT);
+ frequency_decimal_picker.setMaxValue(FREQ_MAX_INT);
+
+ final NumberPicker frequency_fraction_picker = frequency_picker_view.findViewById(R.id.frequency_fraction);
+ frequency_fraction_picker.setMinValue(0);
+ frequency_fraction_picker.setMaxValue(9);
+
+ final NumberPicker.OnValueChangeListener picker_listener = new NumberPicker.OnValueChangeListener() {
+ @Override
+ public void onValueChange(NumberPicker numberPicker, int oldVal, int newVal) {
+
+ int decimal_value = numberPicker.getValue();
+ if (decimal_value == FREQ_MIN_INT) {
+ frequency_fraction_picker.setMinValue(5);
+ frequency_fraction_picker.setMaxValue(9);
+ } else if (decimal_value == FREQ_MAX_INT) {
+ frequency_fraction_picker.setMinValue(0);
+ frequency_fraction_picker.setMaxValue(0);
+ } else {
+ frequency_fraction_picker.setMinValue(0);
+ frequency_fraction_picker.setMaxValue(9);
+ }
+ }
+ };
+
+ frequency_decimal_picker.setOnValueChangedListener(picker_listener);
+
+ final Button[] button_presets = new Button[]{
+ frequency_picker_view.findViewById(R.id.frequency_preset1),
+ frequency_picker_view.findViewById(R.id.frequency_preset2),
+ frequency_picker_view.findViewById(R.id.frequency_preset3)
+ };
+
+ for (int i = 0; i < button_presets.length; i++) {
+ final int index = i;
+ button_presets[index].setText(String.valueOf(fm_presets[index]));
+ button_presets[index].setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ final float frequency = fm_presets[index];
+ device.setExtraInfo("fm_frequency", fm_presets[index]);
+ fmFrequencyLabel.setText(String.format(Locale.getDefault(), "%.1f", (float) frequency));
+ GBApplication.deviceService(device).onSetFmFrequency(frequency);
+ alert[0].dismiss();
+ }
+ });
+ button_presets[index].setOnLongClickListener(new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View view) {
+ final float frequency = (float) (frequency_decimal_picker.getValue() + (0.1 * frequency_fraction_picker.getValue()));
+ fm_presets[index] = frequency;
+ button_presets[index].setText(String.valueOf(frequency));
+ SharedPreferences.Editor editor = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()).edit();
+ editor.putFloat((String.format("fm_preset%s", index)), frequency);
+ editor.apply();
+ return true;
+ }
+ });
+
+ }
+
+ final float frequency = (float) device.getExtraInfo("fm_frequency");
+ final int decimal = (int) frequency;
+ final int fraction = Math.round((frequency - decimal) * 10);
+ frequency_decimal_picker.setValue(decimal);
+ picker_listener.onValueChange(frequency_decimal_picker, frequency_decimal_picker.getValue(), decimal);
+ frequency_fraction_picker.setValue(fraction);
+
+ builder.setView(frequency_picker_view);
+
+ builder.setPositiveButton(context.getResources().getString(android.R.string.ok),
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ float frequency = (float) (frequency_decimal_picker.getValue() + (0.1 * frequency_fraction_picker.getValue()));
+ if (frequency < FREQ_MIN || frequency > FREQ_MAX) {
+ new MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.pref_invalid_frequency_title)
+ .setMessage(R.string.pref_invalid_frequency_message)
+ .setNeutralButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ })
+ .show();
+ } else {
+ device.setExtraInfo("fm_frequency", frequency);
+ fmFrequencyLabel.setText(String.format(Locale.getDefault(), "%.1f", frequency));
+ GBApplication.deviceService(device).onSetFmFrequency(frequency);
+ }
+ }
+ });
+ builder.setNegativeButton(context.getResources().getString(R.string.Cancel), new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.cancel();
+ }
+ });
+
+ alert[0] = builder.create();
+ alert[0].show();
+ }
+ });
+
+ holder.ledColor.setVisibility(View.GONE);
+ if (device.isInitialized() && device.getExtraInfo("led_color") != null && coordinator.supportsLedColor()) {
+ holder.ledColor.setVisibility(View.VISIBLE);
+ final GradientDrawable ledColor = (GradientDrawable) holder.ledColor.getDrawable().mutate();
+ ledColor.setColor((int) device.getExtraInfo("led_color"));
+ holder.ledColor.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ ColorPickerDialog.Builder builder = ColorPickerDialog.newBuilder();
+ builder.setDialogTitle(R.string.preferences_led_color);
+
+ int[] presets = coordinator.getColorPresets();
+
+ builder.setColor((int) device.getExtraInfo("led_color"));
+ builder.setShowAlphaSlider(false);
+ builder.setShowColorShades(false);
+ if (coordinator.supportsRgbLedColor()) {
+ builder.setAllowCustom(true);
+ if (presets.length == 0) {
+ builder.setDialogType(ColorPickerDialog.TYPE_CUSTOM);
+ }
+ } else {
+ builder.setAllowCustom(false);
+ }
+
+ if (presets.length > 0) {
+ builder.setAllowPresets(true);
+ builder.setPresets(presets);
+ }
+
+ ColorPickerDialog dialog = builder.create();
+ dialog.setColorPickerDialogListener(new ColorPickerDialogListener() {
+ @Override
+ public void onColorSelected(int dialogId, int color) {
+ ledColor.setColor(color);
+ device.setExtraInfo("led_color", color);
+ GBApplication.deviceService(device).onSetLedColor(color);
+ }
+
+ @Override
+ public void onDialogDismissed(int dialogId) {
+ // Nothing to do
+ }
+ });
+ dialog.show(((AppCompatActivity) context).getSupportFragmentManager(), "color-picker-dialog");
+ }
+ });
+ }
+
+ holder.powerOff.setVisibility(View.GONE);
+ if (device.isInitialized() && coordinator.supportsPowerOff()) {
+ holder.powerOff.setVisibility(View.VISIBLE);
+ holder.powerOff.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ new MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.controlcenter_power_off_confirm_title)
+ .setMessage(R.string.controlcenter_power_off_confirm_description)
+ .setIcon(R.drawable.ic_power_settings_new)
+ .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
+ public void onClick(final DialogInterface dialog, final int whichButton) {
+ GBApplication.deviceService(device).onPowerOff();
+ }
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+ }
+ });
+ }
+
+ holder.cardViewActivityCardLayout.setVisibility(coordinator.supportsActivityTracking() ? View.VISIBLE : View.GONE);
+ holder.cardViewActivityCardLayout.setMinimumWidth(coordinator.supportsActivityTracking() ? View.VISIBLE : View.GONE);
+
+ if (coordinator.supportsActivityTracking()) {
+ setActivityCard(holder, device, dailyTotals);
+ }
+ }
+
+ private boolean showInstallerItem(GBDevice device) {
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.supportsAppsManagement(device) || coordinator.supportsFlashing();
+ }
+
+ 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);
+
+ final boolean detailsShown = expandedDeviceAddress.equals(device.getAddress());
+ boolean showInfoIcon = device.hasDeviceInfos() && !device.isBusy();
+
+ menu.getMenu().findItem(R.id.controlcenter_device_submenu_connect).setVisible(!deviceConnected);
+ menu.getMenu().findItem(R.id.controlcenter_device_submenu_disconnect).setVisible(deviceConnected);
+ menu.getMenu().findItem(R.id.controlcenter_device_submenu_show_details).setEnabled(showInfoIcon);
+ menu.getMenu().findItem(R.id.controlcenter_device_submenu_installer).setEnabled(deviceConnected);
+ menu.getMenu().findItem(R.id.controlcenter_device_submenu_installer).setVisible(showInstallerItem(device));
+
+ menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == R.id.controlcenter_device_submenu_connect) {
+ if (device.getState() != GBDevice.State.CONNECTED) {
+ showTransientSnackbar(R.string.controlcenter_snackbar_connecting);
+ handleDeviceConnect(device);
+ }
+ return true;
+ } else if (itemId == R.id.controlcenter_device_submenu_disconnect) {
+ if (device.getState() != GBDevice.State.NOT_CONNECTED) {
+ showTransientSnackbar(R.string.controlcenter_snackbar_disconnecting);
+ GBApplication.deviceService(device).disconnect();
+ }
+ removeFromLastDeviceAddressesPref(device);
+ return true;
+ } else if (itemId == R.id.controlcenter_device_submenu_set_alias) {
+ showSetAliasDialog(device);
+ return true;
+ } else if (itemId == R.id.controlcenter_device_submenu_remove) {
+ showRemoveDeviceDialog(device);
+ return true;
+ } else if (itemId == R.id.controlcenter_device_submenu_show_details) {
+ final String previouslyExpandedDeviceAddress = expandedDeviceAddress;
+ expandedDeviceAddress = detailsShown ? "" : device.getAddress();
+
+ if (!previouslyExpandedDeviceAddress.isEmpty()) {
+ // Notify the previously expanded device for a change (collapsing it)
+ for (int i = 0; i < devicesListWithFolders.size(); i++) {
+ final GBDevice gbDevice = devicesListWithFolders.get(i);
+ if (gbDevice.getAddress().equals(previouslyExpandedDeviceAddress)) {
+ notifyItemChanged(devicesListWithFolders.indexOf(gbDevice));
+ break;
+ }
+ }
+ }
+
+ // Update the current one
+ notifyItemChanged(devicesListWithFolders.indexOf(device));
+ return true;
+ } else if (itemId == R.id.controlcenter_device_submenu_set_parent_folder) {
+ showSetParentFolderDialog(device);
+ return true;
+ } else if (itemId == R.id.controlcenter_device_submenu_installer) {
+ Intent openFwIntent = new Intent(context, OpenFwAppInstallerActivity.class);
+ openFwIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ context.startActivity(openFwIntent);
+ return false;
+ }
+
+ return false;
+ }
+ });
+ menu.show();
+ }
+
+ private void showRemoveDeviceDialog(final GBDevice device) {
+ new MaterialAlertDialogBuilder(context)
+ .setCancelable(true)
+ .setTitle(context.getString(R.string.controlcenter_delete_device_name, device.getName()))
+ .setMessage(R.string.controlcenter_delete_device_dialogmessage)
+ .setPositiveButton(R.string.Delete, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ try {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ coordinator.deleteDevice(device);
+ DeviceHelper.getInstance().removeBond(device);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ removeDynamicShortcut(device);
+ }
+ } catch (Exception ex) {
+ GB.toast(context, context.getString(R.string.error_deleting_device, ex.getMessage()), Toast.LENGTH_LONG, GB.ERROR, ex);
+ } finally {
+ Intent refreshIntent = new Intent(DeviceManager.ACTION_REFRESH_DEVICELIST);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(refreshIntent);
+ }
+ }
+ })
+ .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ // do nothing
+ }
+ })
+ .show();
+ }
+
+ private void showSetParentFolderDialog(final GBDevice device) {
+ final String[] selectedFolder = new String[1];
+
+ final LinearLayout linearLayout = new LinearLayout(context);
+ linearLayout.setOrientation(LinearLayout.VERTICAL);
+
+ final LinearLayout newFolderLayout = new LinearLayout(context);
+ newFolderLayout.setOrientation(LinearLayout.HORIZONTAL);
+ newFolderLayout.setPadding(context.getResources().getDimensionPixelSize(R.dimen.dialog_margin),
+ 0, context.getResources().getDimensionPixelSize(R.dimen.dialog_margin), 0);
+
+ final TextView newFolderLabel = new TextView(context);
+ newFolderLabel.setText(R.string.controlcenter_folder_name);
+ final EditText newFolderInput = new EditText(context);
+ newFolderInput.setInputType(InputType.TYPE_CLASS_TEXT);
+
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ newFolderInput.setLayoutParams(params);
+
+ newFolderInput.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable editable) {
+ selectedFolder[0] = editable.toString();
+ }
+ });
+
+ newFolderLayout.addView(newFolderLabel);
+ newFolderLayout.addView(newFolderInput);
+
+ final Spinner deviceListSpinner = new Spinner(context);
+ ArrayList<SpinnerWithIconItem> foldersList = new ArrayList<>();
+ for (GBDevice oneDevice : deviceList) {
+ String folder = oneDevice.getParentFolder();
+ if (StringUtils.isNullOrEmpty(folder)) {
+ continue;
+ }
+ if (folderListContainsName(foldersList, folder)) {
+ continue;
+ }
+ foldersList.add(new SpinnerWithIconItem(folder, 2L, R.drawable.ic_folder));
+ }
+
+ foldersList.add(new SpinnerWithIconItem(context.getString(R.string.controlcenter_add_new_folder), 0L, R.drawable.ic_create_new_folder));
+ if (foldersList.toArray().length > 1) {
+ foldersList.add(new SpinnerWithIconItem(context.getString(R.string.controlcenter_unset_folder), 1L, R.drawable.ic_folder_delete));
+ }
+
+ final SpinnerWithIconAdapter deviceListAdapter = new SpinnerWithIconAdapter((Activity) context,
+ R.layout.spinner_with_image_layout, R.id.spinner_item_text, foldersList);
+ deviceListSpinner.setAdapter(deviceListAdapter);
+
+ deviceListSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
+ SpinnerWithIconItem selectedItem = (SpinnerWithIconItem) parent.getItemAtPosition(pos);
+ int folderId = selectedItem.getId().intValue();
+ switch (folderId) {
+ case 0: //Add new folder from test input
+ newFolderLayout.setVisibility(View.VISIBLE);
+ selectedFolder[0] = newFolderInput.getText().toString();
+ break;
+ case 1: //Unset folder
+ newFolderLayout.setVisibility(View.GONE);
+ selectedFolder[0] = "";
+ break;
+ default: //Set folder from selection
+ newFolderLayout.setVisibility(View.GONE);
+ selectedFolder[0] = selectedItem.getText();
+ break;
+ }
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> arg0) {
+ // TODO Auto-generated method stub
+ }
+ });
+
+ linearLayout.addView(deviceListSpinner);
+ linearLayout.addView(newFolderLayout);
+
+ new MaterialAlertDialogBuilder(context)
+ .setCancelable(true)
+ .setTitle(R.string.controlcenter_set_folder_title)
+ .setView(linearLayout)
+ .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ DaoSession session = dbHandler.getDaoSession();
+ Device dbDevice = DBHelper.getDevice(device, session);
+ String parentFolder = selectedFolder[0];
+ dbDevice.setParentFolder(parentFolder);
+ dbDevice.update();
+ device.setParentFolder(parentFolder);
+ expandedFolderName = parentFolder;
+ } catch (Exception ex) {
+ GB.toast(context, context.getString(R.string.error_setting_parent_folder, ex.getMessage()), Toast.LENGTH_LONG, GB.ERROR, ex);
+ } finally {
+ Intent refreshIntent = new Intent(DeviceManager.ACTION_REFRESH_DEVICELIST);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(refreshIntent);
+ }
+ }
+ })
+ .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ })
+ .show();
+ }
+
+ private boolean folderListContainsName(ArrayList<SpinnerWithIconItem> list, String name){
+ for (SpinnerWithIconItem item: list) {
+ if (item.getText().equals(name)){
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void removeFromLastDeviceAddressesPref(GBDevice device) {
+ Set<String> lastDeviceAddresses = GBApplication.getPrefs().getStringSet(GBPrefs.LAST_DEVICE_ADDRESSES, Collections.emptySet());
+ if (lastDeviceAddresses.contains(device.getAddress())) {
+ lastDeviceAddresses = new HashSet<String>(lastDeviceAddresses);
+ lastDeviceAddresses.remove(device.getAddress());
+ GBApplication.getPrefs().getPreferences().edit().putStringSet(GBPrefs.LAST_DEVICE_ADDRESSES, lastDeviceAddresses).apply();
+ }
+ }
+
+ private void setAppPreferences(GBDevice device) {
+ Intent startIntent;
+ startIntent = new Intent(context, DeviceSettingsActivity.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ startIntent.putExtra(DeviceSettingsActivity.MENU_ENTRY_POINT, DeviceSettingsActivity.MENU_ENTRY_POINTS.APPLICATION_SETTINGS);
+ context.startActivity(startIntent);
+ }
+ private void showSetAliasDialog(final GBDevice device) {
+ final EditText input = new EditText(context);
+ input.setInputType(InputType.TYPE_CLASS_TEXT);
+ input.setText(device.getAlias());
+ FrameLayout container = new FrameLayout(context);
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.leftMargin = context.getResources().getDimensionPixelSize(R.dimen.dialog_margin);
+ params.rightMargin = context.getResources().getDimensionPixelSize(R.dimen.dialog_margin);
+ input.setLayoutParams(params);
+ container.addView(input);
+ // Specify the type of input expected; this, for example, sets the input as a password, and will mask the text
+
+ new MaterialAlertDialogBuilder(context)
+ .setView(container)
+ .setCancelable(true)
+ .setTitle(context.getString(R.string.controlcenter_set_alias))
+ .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ DaoSession session = dbHandler.getDaoSession();
+ Device dbDevice = DBHelper.getDevice(device, session);
+ String alias = input.getText().toString();
+ dbDevice.setAlias(alias);
+ dbDevice.update();
+ device.setAlias(alias);
+ } catch (Exception ex) {
+ GB.toast(context, context.getString(R.string.error_setting_alias) + ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR, ex);
+ } finally {
+ Intent refreshIntent = new Intent(DeviceManager.ACTION_REFRESH_DEVICELIST);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(refreshIntent);
+ }
+ }
+ })
+ .setNegativeButton(R.string.Cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ // do nothing
+ }
+ })
+ .show();
+ }
+
+ @Override
+ public int getItemCount() {
+ return devicesListWithFolders.size();
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return idGenerator.getId(devicesListWithFolders.get(position));
+ }
+
+ static class ViewHolder extends RecyclerView.ViewHolder {
+
+ View root;
+ MaterialCardView container;
+
+ ImageView deviceImageView;
+ TextView deviceNameLabel;
+ TextView deviceStatusLabel;
+
+ //actions
+ LinearLayout batteryStatusBox0;
+ TextView batteryStatusLabel0;
+ ImageView batteryIcon0;
+ LinearLayout batteryStatusBox1;
+ TextView batteryStatusLabel1;
+ ImageView batteryIcon1;
+ LinearLayout batteryStatusBox2;
+ TextView batteryStatusLabel2;
+ ImageView batteryIcon2;
+ ImageView deviceSpecificSettingsView;
+ LinearLayout fetchActivityDataBox;
+ ImageView fetchActivityData;
+ ProgressBar busyIndicator;
+ ImageView takeScreenshotView;
+ ImageView manageAppsView;
+ ImageView setAlarmsView;
+ ImageView setRemindersView;
+ ImageView showActivityGraphs;
+ ImageView showActivityTracks;
+ ImageView calibrateDevice;
+ LinearLayout heartRateStatusBox;
+ ImageView heartRateIcon;
+ TextView heartRateStatusLabel;
+ FlexboxLayout infoIcons;
+
+
+ ImageView deviceInfoView;
+ //overflow
+ final RelativeLayout deviceInfoBox;
+ ListView deviceInfoList;
+ ImageView findDevice;
+ LinearLayout fmFrequencyBox;
+ TextView fmFrequencyLabel;
+ ImageView ledColor;
+ ImageView powerOff;
+
+ //activity card
+ LinearLayout cardViewActivityCardLayout;
+ PieChart TotalStepsChart;
+ PieChart TotalDistanceChart;
+ PieChart SleepTimeChart;
+
+ ViewHolder(View view) {
+ super(view);
+
+ root = view;
+
+ container = view.findViewById(R.id.card_view);
+
+ deviceImageView = view.findViewById(R.id.device_image);
+ deviceNameLabel = view.findViewById(R.id.device_name);
+ deviceStatusLabel = view.findViewById(R.id.device_status);
+
+ //actions
+ batteryStatusBox0 = view.findViewById(R.id.device_battery_status_box);
+ batteryStatusLabel0 = view.findViewById(R.id.battery_status);
+ batteryIcon0 = view.findViewById(R.id.device_battery_status);
+ batteryStatusBox1 = view.findViewById(R.id.device_battery_status_box1);
+ batteryStatusLabel1 = view.findViewById(R.id.battery_status1);
+ batteryIcon1 = view.findViewById(R.id.device_battery_status1);
+ batteryStatusBox2 = view.findViewById(R.id.device_battery_status_box2);
+ batteryStatusLabel2 = view.findViewById(R.id.battery_status2);
+ batteryIcon2 = view.findViewById(R.id.device_battery_status2);
+
+
+
+ deviceSpecificSettingsView = view.findViewById(R.id.device_specific_settings);
+ fetchActivityDataBox = view.findViewById(R.id.device_action_fetch_activity_box);
+ fetchActivityData = view.findViewById(R.id.device_action_fetch_activity);
+ busyIndicator = view.findViewById(R.id.device_busy_indicator);
+ takeScreenshotView = view.findViewById(R.id.device_action_take_screenshot);
+ manageAppsView = view.findViewById(R.id.device_action_manage_apps);
+ setAlarmsView = view.findViewById(R.id.device_action_set_alarms);
+ setRemindersView = view.findViewById(R.id.device_action_set_reminders);
+ showActivityGraphs = view.findViewById(R.id.device_action_show_activity_graphs);
+ showActivityTracks = view.findViewById(R.id.device_action_show_activity_tracks);
+ deviceInfoView = view.findViewById(R.id.device_info_image);
+ calibrateDevice = view.findViewById(R.id.device_action_calibrate);
+
+ deviceInfoBox = view.findViewById(R.id.device_item_infos_box);
+ //overflow
+ deviceInfoList = view.findViewById(R.id.device_item_infos);
+ findDevice = view.findViewById(R.id.device_action_find);
+ fmFrequencyBox = view.findViewById(R.id.device_fm_frequency_box);
+ fmFrequencyLabel = view.findViewById(R.id.fm_frequency);
+ ledColor = view.findViewById(R.id.device_led_color);
+ powerOff = view.findViewById(R.id.device_action_power_off);
+ heartRateStatusBox = view.findViewById(R.id.device_heart_rate_status_box);
+ heartRateStatusLabel = view.findViewById(R.id.heart_rate_status);
+ heartRateIcon = view.findViewById(R.id.device_heart_rate_status);
+ infoIcons = view.findViewById(R.id.device_info_icons);
+
+ cardViewActivityCardLayout = view.findViewById(R.id.card_view_activity_card_layout);
+
+ TotalStepsChart = view.findViewById(R.id.activity_dashboard_piechart1);
+ TotalDistanceChart = view.findViewById(R.id.activity_dashboard_piechart2);
+ SleepTimeChart = view.findViewById(R.id.activity_dashboard_piechart3);
+ }
+
+ }
+
+ private void justifyListViewHeightBasedOnChildren(ListView listView) {
+ ArrayAdapter adapter = (ArrayAdapter) listView.getAdapter();
+
+ if (adapter == null) {
+ return;
+ }
+ int totalHeight = 0;
+ for (int i = 0; i < adapter.getCount(); i++) {
+ View listItem = adapter.getView(i, null, listView);
+ listItem.measure(0, 0);
+ totalHeight += listItem.getMeasuredHeight();
+ }
+
+ ViewGroup.LayoutParams par = listView.getLayoutParams();
+ par.height = totalHeight + (listView.getDividerHeight() * (adapter.getCount() - 1));
+ listView.setLayoutParams(par);
+ listView.requestLayout();
+ }
+
+ private String getUniqueDeviceName(GBDevice device) {
+ String deviceName = device.getAliasOrName();
+
+ if (!isUniqueDeviceName(device, deviceName)) {
+ if (device.getModel() != null) {
+ deviceName = deviceName + " " + device.getModel();
+ if (!isUniqueDeviceName(device, deviceName)) {
+ deviceName = deviceName + " " + device.getShortAddress();
+ }
+ } else {
+ deviceName = deviceName + " " + device.getShortAddress();
+ }
+ }
+ return deviceName;
+ }
+
+ private boolean isUniqueDeviceName(GBDevice device, String deviceName) {
+ for (int i = 0; i < deviceList.size(); i++) {
+ GBDevice item = deviceList.get(i);
+ if (item == device) {
+ continue;
+ }
+ if (deviceName.equals(item.getName())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private void showTransientSnackbar(int resource) {
+ Snackbar snackbar = Snackbar.make(parent, resource, Snackbar.LENGTH_SHORT);
+
+ //View snackbarView = snackbar.getView();
+
+ // change snackbar text color
+ //int snackbarTextId = android.support.design.R.id.snackbar_text;
+ //TextView textView = snackbarView.findViewById(snackbarTextId);
+ //textView.setTextColor();
+ //snackbarView.setBackgroundColor(Color.MAGENTA);
+ snackbar.show();
+ }
+
+ private void setActivityCard(ViewHolder holder, final GBDevice device, DailyTotals dailyTotals) {
+ boolean showActivityCard = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()).getBoolean(DeviceSettingsPreferenceConst.PREFS_ACTIVITY_IN_DEVICE_CARD, true);
+ holder.cardViewActivityCardLayout.setVisibility(showActivityCard ? View.VISIBLE : View.GONE);
+
+ if (!showActivityCard) {
+ return;
+ }
+
+ int steps = (int) dailyTotals.getSteps();
+ int sleep = (int) dailyTotals.getSleep();
+ int distanceCm = (int) dailyTotals.getDistance();
+ ActivityUser activityUser = new ActivityUser();
+ int stepGoal = activityUser.getStepsGoal();
+ int sleepGoal = activityUser.getSleepDurationGoal();
+ int sleepGoalMinutes = sleepGoal * 60;
+ int distanceGoal = activityUser.getDistanceGoalMeters() * 100;
+ int stepLength = activityUser.getStepLengthCm();
+ double distanceMeters = (distanceCm > 0 ? distanceCm : steps * stepLength) * 0.01;
+ String distanceFormatted = FormatUtils.getFormattedDistanceLabel(distanceMeters);
+
+ setUpChart(holder.TotalStepsChart);
+ setChartsData(holder.TotalStepsChart, steps, stepGoal, context.getString(R.string.steps), String.valueOf(steps), context);
+
+ setUpChart(holder.TotalDistanceChart);
+ setChartsData(holder.TotalDistanceChart, steps * stepLength, distanceGoal, context.getString(R.string.distance), distanceFormatted, context);
+
+ setUpChart(holder.SleepTimeChart);
+ setChartsData(holder.SleepTimeChart, sleep, sleepGoalMinutes, context.getString(R.string.prefs_activity_in_device_card_sleep_title), String.format("%1s", getHM(sleep)), context);
+
+
+ boolean showActivitySteps = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()).getBoolean(DeviceSettingsPreferenceConst.PREFS_ACTIVITY_IN_DEVICE_CARD_STEPS, true);
+ boolean showActivitySleep = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()).getBoolean(DeviceSettingsPreferenceConst.PREFS_ACTIVITY_IN_DEVICE_CARD_SLEEP, true);
+ boolean showActivityDistance = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()).getBoolean(DeviceSettingsPreferenceConst.PREFS_ACTIVITY_IN_DEVICE_CARD_DISTANCE, true);
+
+ //do the multiple mini-charts for activities in a loop
+ Hashtable<PieChart, Pair<Boolean, Integer>> activitiesStatusMiniCharts = new Hashtable<>();
+ activitiesStatusMiniCharts.put(holder.TotalStepsChart, new Pair<>(showActivitySteps && steps > 0, ActivityChartsActivity.getChartsTabIndex("stepsweek", device, context)));
+ activitiesStatusMiniCharts.put(holder.SleepTimeChart, new Pair<>(showActivitySleep && sleep > 0, ActivityChartsActivity.getChartsTabIndex("sleep", device, context)));
+ activitiesStatusMiniCharts.put(holder.TotalDistanceChart, new Pair<>(showActivityDistance && steps > 0, ActivityChartsActivity.getChartsTabIndex("activity", device, context)));
+
+ for (Map.Entry<PieChart, Pair<Boolean, Integer>> miniCharts : activitiesStatusMiniCharts.entrySet()) {
+ PieChart miniChart = miniCharts.getKey();
+ final Pair<Boolean, Integer> parameters = miniCharts.getValue();
+ miniChart.setVisibility(parameters.first ? View.VISIBLE : View.GONE);
+ miniChart.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent startIntent;
+ startIntent = new Intent(context, ActivityChartsActivity.class);
+ startIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ startIntent.putExtra(ActivityChartsActivity.EXTRA_FRAGMENT_ID, parameters.second);
+ context.startActivity(startIntent);
+ }
+ }
+ );
+ }
+ }
+
+ private String getHM(long value) {
+ return DateTimeUtils.formatDurationHoursMinutes(value, TimeUnit.MINUTES);
+ }
+ private void setUpChart(PieChart DashboardChart) {
+ DashboardChart.setTouchEnabled(false);
+ DashboardChart.setNoDataText("");
+ DashboardChart.getLegend().setEnabled(false);
+ DashboardChart.setDrawHoleEnabled(true);
+ DashboardChart.setHoleColor(Color.WHITE);
+ DashboardChart.getDescription().setText("");
+ DashboardChart.setTransparentCircleColor(Color.WHITE);
+ DashboardChart.setTransparentCircleAlpha(110);
+ DashboardChart.setHoleRadius(70f);
+ DashboardChart.setTransparentCircleRadius(75f);
+ DashboardChart.setDrawCenterText(true);
+ DashboardChart.setRotationEnabled(true);
+ DashboardChart.setHighlightPerTapEnabled(true);
+ DashboardChart.setCenterTextOffset(0, 0);
+ }
+ private void setChartsData(PieChart pieChart, float value, float target, String label, String stringValue, Context context) {
+ final String CHART_COLOR_START = "#e74c3c";
+ final String CHART_COLOR_END = "#2ecc71";
+
+ ArrayList<PieEntry> entries = new ArrayList<>();
+ entries.add(new PieEntry((float) value, context.getResources().getDrawable(R.drawable.ic_star_gold)));
+
+ if (value < target) {
+ entries.add(new PieEntry((float) (target - value)));
+ }
+
+ pieChart.setCenterText(String.format("%s\n%s", stringValue, label));
+ float colorValue = Math.max(0, Math.min(1, value / target));
+ int chartColor = interpolateColor(Color.parseColor(CHART_COLOR_START), Color.parseColor(CHART_COLOR_END), colorValue);
+
+ PieDataSet dataSet = new PieDataSet(entries, "");
+ dataSet.setDrawIcons(false);
+ dataSet.setIconsOffset(new MPPointF(0, -66));
+
+ if (colorValue == 1) {
+ dataSet.setDrawIcons(true);
+ }
+ dataSet.setSliceSpace(0f);
+ dataSet.setSelectionShift(5f);
+ dataSet.setColors(chartColor, Color.LTGRAY);
+
+ PieData data = new PieData(dataSet);
+ data.setValueTextSize(0f);
+ data.setValueTextColor(Color.WHITE);
+
+ pieChart.setData(data);
+ pieChart.invalidate();
+ }
+ private float interpolate(float a, float b, float proportion) {
+ return (a + ((b - a) * proportion));
+ }
+
+ private int interpolateColor(int a, int b, float proportion) {
+ float[] hsva = new float[3];
+ float[] hsvb = new float[3];
+ Color.colorToHSV(a, hsva);
+ Color.colorToHSV(b, hsvb);
+ for (int i = 0; i < 3; i++) {
+ hsvb[i] = interpolate(hsva[i], hsvb[i], proportion);
+ }
+ return Color.HSVToColor(hsvb);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.R)
+ void createDynamicShortcut(GBDevice device) {
+ Intent intent = new Intent(context, ControlCenterv2.class)
+ .setAction(ACTION_CONNECT)
+ .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ .putExtra("device", device.getAddress());
+
+ ShortcutManager shortcutManager = (ShortcutManager) context.getApplicationContext().getSystemService(Context.SHORTCUT_SERVICE);
+
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+
+ shortcutManager.pushDynamicShortcut(new ShortcutInfo.Builder(context, device.getAddress())
+ .setLongLived(false)
+ .setShortLabel(device.getAliasOrName())
+ .setIntent(intent)
+ .setIcon(Icon.createWithResource(context, coordinator.getDefaultIconResource()))
+ .build()
+ );
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.R)
+ void removeDynamicShortcut(GBDevice device) {
+ final ShortcutManager shortcutManager = (ShortcutManager) context.getApplicationContext().getSystemService(Context.SHORTCUT_SERVICE);
+
+ shortcutManager.removeDynamicShortcuts(Collections.singletonList(device.getAddress()));
+ }
+
+ private static class GBDeviceDiffUtil extends DiffUtil.ItemCallback<GBDevice> {
+ @Override
+ public boolean areItemsTheSame(@NonNull GBDevice oldItem, @NonNull GBDevice newItem) {
+ return new EqualsBuilder()
+ .append(oldItem.getAddress(), newItem.getAddress())
+ .append(oldItem.getName(), newItem.getName())
+ .isEquals();
+ }
+
+ @Override
+ public boolean areContentsTheSame(@NonNull GBDevice oldItem, @NonNull GBDevice newItem) {
+ return EqualsBuilder.reflectionEquals(oldItem, newItem);
+ }
+ }
+
+ /**
+ * A generator of stable IDs, given a string, since hashCode can easily have collisions.
+ */
+ private static class StableIdGenerator {
+ private final Map<String, Long> idMapping = new HashMap<String, Long>();
+
+ private long nextId = 0;
+
+ public long getId(final GBDevice device) {
+ final String str = String.format("%s_%s", device.getAddress(), device.getName());
+
+ if (!idMapping.containsKey(str)) {
+ idMapping.put(str, nextId++);
+ }
+
+ return idMapping.get(str);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAppAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAppAdapter.java
new file mode 100644
index 0000000..5cd82eb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAppAdapter.java
@@ -0,0 +1,175 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniele Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.adapter;
+
+import android.graphics.Bitmap;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import java.util.Collections;
+import java.util.List;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AbstractAppManagerFragment;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+
+/**
+ * Adapter for displaying GBDeviceApp instances.
+ */
+
+public class GBDeviceAppAdapter extends RecyclerView.Adapter<GBDeviceAppAdapter.AppViewHolder> {
+ private final Logger LOG = LoggerFactory.getLogger(GBDeviceAppAdapter.class);
+
+ private final int mLayoutId;
+ private final List<GBDeviceApp> appList;
+ private final AbstractAppManagerFragment mParentFragment;
+ private final boolean allowReorder;
+
+ public List<GBDeviceApp> getAppList() {
+ return appList;
+ }
+
+ public GBDeviceAppAdapter(List<GBDeviceApp> list, int layoutId, AbstractAppManagerFragment parentFragment, boolean reorder) {
+ mLayoutId = layoutId;
+ appList = list;
+ mParentFragment = parentFragment;
+ allowReorder = reorder;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return appList.get(position).getUUID().getLeastSignificantBits();
+ }
+
+ @Override
+ public int getItemCount() {
+ return appList.size();
+ }
+
+ @Override
+ public GBDeviceAppAdapter.AppViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(parent.getContext()).inflate(mLayoutId, parent, false);
+ return new AppViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(final AppViewHolder holder, int position) {
+ final GBDeviceApp deviceApp = appList.get(position);
+
+ String appVersionAuthor;
+ if ((deviceApp.getCreator().equals("")) || (deviceApp.getCreator().equals("(unknown)"))) {
+ appVersionAuthor = deviceApp.getVersion();
+ } else {
+ appVersionAuthor = GBApplication.getContext().getString(R.string.appversion_by_creator, deviceApp.getVersion(), deviceApp.getCreator());
+ }
+ holder.mDeviceAppVersionAuthorLabel.setText(appVersionAuthor);
+ if (deviceApp.isUpToDate()) {
+ holder.mDeviceAppOutdated.setVisibility(View.GONE);
+ } else {
+ holder.mDeviceAppOutdated.setVisibility(View.VISIBLE);
+ }
+ // FIXME: replace with small icons
+ String appNameLabelText = deviceApp.getName();
+ holder.mDeviceAppNameLabel.setText(appNameLabelText);
+
+ Bitmap previewImage = deviceApp.getPreviewImage();
+ holder.mPreviewImage.setImageBitmap(previewImage);
+ if (previewImage == null) {
+ holder.mPreviewImage.setVisibility(View.GONE);
+ } else {
+ holder.mPreviewImage.setVisibility(View.VISIBLE);
+ }
+
+ switch (deviceApp.getType()) {
+ case APP_GENERIC:
+ holder.mDeviceImageView.setImageResource(R.drawable.ic_watchapp);
+ break;
+ case APP_ACTIVITYTRACKER:
+ holder.mDeviceImageView.setImageResource(R.drawable.ic_activitytracker);
+ break;
+ case APP_SYSTEM:
+ holder.mDeviceImageView.setImageResource(R.drawable.ic_systemapp);
+ break;
+ case WATCHFACE:
+ holder.mDeviceImageView.setImageResource(R.drawable.ic_watchface);
+ break;
+ default:
+ holder.mDeviceImageView.setImageResource(R.drawable.ic_watchapp);
+ }
+
+ holder.itemView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ mParentFragment.onItemClick(view, deviceApp);
+ }
+ });
+
+ holder.itemView.setOnLongClickListener(new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View view) {
+ return mParentFragment.openPopupMenu(view, deviceApp);
+ }
+ });
+
+ holder.mDragHandle.setVisibility(allowReorder ? View.VISIBLE : View.GONE);
+ holder.mDragHandle.setOnTouchListener(new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(View view, MotionEvent motionEvent) {
+ mParentFragment.startDragging(holder);
+ return true;
+ }
+ });
+
+ }
+
+ public void onItemMove(int from, int to) {
+ Collections.swap(appList, from, to);
+ notifyItemMoved(from, to);
+ }
+
+ public class AppViewHolder extends RecyclerView.ViewHolder {
+ final TextView mDeviceAppVersionAuthorLabel;
+ final TextView mDeviceAppNameLabel;
+ final TextView mDeviceAppOutdated;
+ final ImageView mDeviceImageView;
+ final ImageView mDragHandle;
+ final ImageView mPreviewImage;
+
+ AppViewHolder(View itemView) {
+ super(itemView);
+ mDeviceAppVersionAuthorLabel = (TextView) itemView.findViewById(R.id.item_details);
+ mDeviceAppNameLabel = (TextView) itemView.findViewById(R.id.item_name);
+ mDeviceAppOutdated = (TextView) itemView.findViewById(R.id.item_watchapp_outdated);
+ mDeviceImageView = (ImageView) itemView.findViewById(R.id.item_image);
+ mDragHandle = (ImageView) itemView.findViewById(R.id.drag_handle);
+ mPreviewImage = (ImageView) itemView.findViewById(R.id.item_preview_image);
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBReminderListAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBReminderListAdapter.java
new file mode 100644
index 0000000..7163bdf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBReminderListAdapter.java
@@ -0,0 +1,156 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver, Jos茅 Rebelo, Johannes Krude
+
+ 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.adapter;
+
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.card.MaterialCardView;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureReminders;
+import nodomain.freeyourgadget.gadgetbridge.entities.Reminder;
+
+/**
+ * Adapter for displaying Reminder instances.
+ */
+public class GBReminderListAdapter extends RecyclerView.Adapter<GBReminderListAdapter.ViewHolder> {
+
+ private final Context mContext;
+ private ArrayList<Reminder> reminderList;
+ private boolean remindersHaveTime;
+
+ public GBReminderListAdapter(Context context, boolean remindersHaveTime) {
+ this.mContext = context;
+ this.remindersHaveTime = remindersHaveTime;
+ }
+
+ public void setReminderList(List<Reminder> reminders) {
+ this.reminderList = new ArrayList<>(reminders);
+ }
+
+ public ArrayList<Reminder> getReminderList() {
+ return reminderList;
+ }
+
+ @NonNull
+ @Override
+ public GBReminderListAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ final View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_reminder, parent, false);
+ return new ViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, final int position) {
+ final Reminder reminder = reminderList.get(position);
+
+ holder.container.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ((ConfigureReminders) mContext).configureReminder(reminder);
+ }
+ });
+
+ holder.container.setOnLongClickListener(new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ new MaterialAlertDialogBuilder(v.getContext())
+ .setTitle(R.string.reminder_delete_confirm_title)
+ .setMessage(R.string.reminder_delete_confirm_description)
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
+ public void onClick(final DialogInterface dialog, final int whichButton) {
+ ((ConfigureReminders) mContext).deleteReminder(reminder);
+ }
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+
+ return true;
+ }
+ });
+
+ holder.reminderMessage.setText(reminder.getMessage());
+
+ final Date time = reminder.getDate();
+ SimpleDateFormat format;
+ if (remindersHaveTime) {
+ format = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault());
+ } else {
+ format = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault());
+ }
+
+ int stringResId = 0;
+
+ switch (reminder.getRepetition()) {
+ case Reminder.ONCE:
+ stringResId = R.string.reminder_time_once;
+ break;
+ case Reminder.EVERY_DAY:
+ stringResId = R.string.reminder_time_every_day;
+ break;
+ case Reminder.EVERY_WEEK:
+ stringResId = R.string.reminder_time_every_week;
+ break;
+ case Reminder.EVERY_MONTH:
+ stringResId = R.string.reminder_time_every_month;
+ break;
+ case Reminder.EVERY_YEAR:
+ stringResId = R.string.reminder_time_every_year;
+ break;
+ }
+
+ final String reminderTimeText = mContext.getString(stringResId, format.format(time));
+ holder.reminderTime.setText(reminderTimeText);
+ }
+
+ @Override
+ public int getItemCount() {
+ return reminderList.size();
+ }
+
+ static class ViewHolder extends RecyclerView.ViewHolder {
+ final MaterialCardView container;
+
+ final TextView reminderTime;
+ final TextView reminderMessage;
+
+ ViewHolder(View view) {
+ super(view);
+
+ container = view.findViewById(R.id.card_view);
+
+ reminderTime = view.findViewById(R.id.reminder_item_time);
+ reminderMessage = view.findViewById(R.id.reminder_item_message);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBWorldClockListAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBWorldClockListAdapter.java
new file mode 100644
index 0000000..b687be8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBWorldClockListAdapter.java
@@ -0,0 +1,132 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.adapter;
+
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.card.MaterialCardView;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.TimeZone;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureWorldClocks;
+import nodomain.freeyourgadget.gadgetbridge.entities.WorldClock;
+
+/**
+ * Adapter for displaying WorldClock instances.
+ */
+public class GBWorldClockListAdapter extends RecyclerView.Adapter<GBWorldClockListAdapter.ViewHolder> {
+
+ private final Context mContext;
+ private ArrayList<WorldClock> worldClockList;
+
+ public GBWorldClockListAdapter(final Context context) {
+ this.mContext = context;
+ }
+
+ public void setWorldClockList(final List<WorldClock> worldClocks) {
+ this.worldClockList = new ArrayList<>(worldClocks);
+ }
+
+ public ArrayList<WorldClock> getWorldClockList() {
+ return worldClockList;
+ }
+
+ @NonNull
+ @Override
+ public GBWorldClockListAdapter.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
+ final View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_world_clock, parent, false);
+ return new ViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, final int position) {
+ final WorldClock worldClock = worldClockList.get(position);
+
+ holder.container.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ((ConfigureWorldClocks) mContext).configureWorldClock(worldClock);
+ }
+ });
+
+ holder.container.setOnLongClickListener(new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ new MaterialAlertDialogBuilder(v.getContext())
+ .setTitle(v.getContext().getString(R.string.world_clock_delete_confirm_title, worldClock.getLabel()))
+ .setMessage(R.string.world_clock_delete_confirm_description)
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
+ public void onClick(final DialogInterface dialog, final int whichButton) {
+ ((ConfigureWorldClocks) mContext).deleteWorldClock(worldClock);
+ }
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+
+ return true;
+ }
+ });
+
+ holder.worldClockLabel.setText(worldClock.getLabel());
+ holder.worldClockTimezone.setText(worldClock.getTimeZoneId());
+
+ final DateFormat df = new SimpleDateFormat("HH:mm", GBApplication.getLanguage());
+ df.setTimeZone(TimeZone.getTimeZone(worldClock.getTimeZoneId()));
+ holder.worldClockCurrentTime.setText(df.format(new Date()));
+ }
+
+ @Override
+ public int getItemCount() {
+ return worldClockList.size();
+ }
+
+ static class ViewHolder extends RecyclerView.ViewHolder {
+ final MaterialCardView container;
+
+ final TextView worldClockTimezone;
+ final TextView worldClockLabel;
+ final TextView worldClockCurrentTime;
+
+ ViewHolder(View view) {
+ super(view);
+
+ container = view.findViewById(R.id.card_view);
+
+ worldClockTimezone = view.findViewById(R.id.world_clock_item_timezone);
+ worldClockLabel = view.findViewById(R.id.world_clock_item_label);
+ worldClockCurrentTime = view.findViewById(R.id.world_clock_current_time);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ItemWithDetailsAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ItemWithDetailsAdapter.java
new file mode 100644
index 0000000..3bb2e07
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/ItemWithDetailsAdapter.java
@@ -0,0 +1,55 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer
+
+ 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.adapter;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ItemWithDetails;
+
+/**
+ * Adapter for displaying generic ItemWithDetails instances.
+ */
+public class ItemWithDetailsAdapter extends AbstractItemAdapter<ItemWithDetails> {
+
+ public ItemWithDetailsAdapter(Context context, List<ItemWithDetails> items) {
+ super(context, items);
+ }
+
+ @Override
+ protected String getName(ItemWithDetails item) {
+ return item.getName();
+ }
+
+ @Override
+ protected String getDetails(ItemWithDetails item) {
+ return item.getDetails();
+ }
+
+ @Override
+ protected int getIcon(ItemWithDetails item) {
+ return item.getIcon();
+ }
+
+ @Override
+ protected Bitmap getPreview(ItemWithDetails item) {
+ return item.getPreview();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/MusicListAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/MusicListAdapter.java
new file mode 100644
index 0000000..6b9580d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/MusicListAdapter.java
@@ -0,0 +1,118 @@
+package nodomain.freeyourgadget.gadgetbridge.adapter;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.recyclerview.widget.RecyclerView;
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceMusic;
+
+public class MusicListAdapter extends RecyclerView.Adapter<MusicListAdapter.MusicViewHolder> {
+
+ public static int MAX_MULTISELECT_COUNT = 50;
+ public interface onDataAction {
+ void onItemClick(View view, GBDeviceMusic music);
+ void onMultiSelect(int count, boolean limit);
+ }
+
+ private final List<GBDeviceMusic> musicList;
+ private final onDataAction callback;
+
+ private final List<GBDeviceMusic> selectedItems = new ArrayList<>();
+
+ public MusicListAdapter(List<GBDeviceMusic> list, onDataAction callback) {
+ this.musicList = list;
+ this.callback = callback;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return musicList.get(position).getId();
+ }
+
+ @Override
+ public int getItemCount() {
+ return musicList.size();
+ }
+
+ @Override
+ public MusicListAdapter.MusicViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_musicmanager_song, parent, false);
+ return new MusicViewHolder(view);
+ }
+
+ private void toggleSelection(GBDeviceMusic music) {
+ if(selectedItems.size() >= MAX_MULTISELECT_COUNT) {
+ callback.onMultiSelect(selectedItems.size(), true);
+ return;
+ }
+ if(selectedItems.contains(music)) {
+ selectedItems.remove(music);
+ } else {
+ selectedItems.add(music);
+ }
+ notifyItemChanged(musicList.indexOf(music));
+ callback.onMultiSelect(selectedItems.size(), false);
+ }
+
+ @Override
+ public void onBindViewHolder(final MusicListAdapter.MusicViewHolder holder, int position) {
+ final GBDeviceMusic music = musicList.get(position);
+
+ holder.musicTitle.setText(music.getTitle());
+ holder.musicArtist.setText(music.getArtist());
+
+ holder.icon.setSelected(selectedItems.contains(music));
+
+ if(callback != null) {
+ holder.itemView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if(!selectedItems.isEmpty()) {
+ toggleSelection(music);
+ } else {
+ callback.onItemClick(view, music);
+ }
+ }
+ });
+
+ holder.itemView.setOnLongClickListener(new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View view) {
+ toggleSelection(music);
+ return true;
+ }
+ });
+ }
+ }
+
+ public void clearSelectedItems() {
+ selectedItems.clear();
+ notifyDataSetChanged();
+ callback.onMultiSelect(selectedItems.size(), false);
+ }
+
+ public List<GBDeviceMusic> getSelectedItems() {
+ return selectedItems;
+ }
+
+ public static class MusicViewHolder extends RecyclerView.ViewHolder {
+ final TextView musicArtist;
+ final TextView musicTitle;
+ final ImageView icon;
+
+ MusicViewHolder(View itemView) {
+ super(itemView);
+ musicArtist = itemView.findViewById(R.id.item_details);
+ musicTitle = itemView.findViewById(R.id.item_name);
+ icon = itemView.findViewById(R.id.item_image);
+ }
+
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/NestedFragmentAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/NestedFragmentAdapter.java
new file mode 100644
index 0000000..48f40e5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/NestedFragmentAdapter.java
@@ -0,0 +1,52 @@
+/* Copyright (C) 2024 a0z, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.adapter;
+
+import androidx.fragment.app.FragmentManager;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
+
+public abstract class NestedFragmentAdapter extends FragmentStateAdapter {
+ protected FragmentManager fragmentManager;
+
+ public NestedFragmentAdapter(AbstractGBFragment fragment, FragmentManager childFragmentManager) {
+ super(fragment);
+ fragmentManager = childFragmentManager;
+ }
+
+ @Override
+ public int getItemCount() {
+ return 3;
+ }
+
+ public void updateFragments(int position) {
+ List<AbstractGBFragment> fragments = fragmentManager.getFragments()
+ .stream()
+ .map(e -> (AbstractGBFragment) e)
+ .collect(Collectors.toList());
+ for (AbstractGBFragment fragment : fragments) {
+ if (position < 0 || fragment != fragmentManager.findFragmentByTag("f" + position)) {
+ fragment.onMadeInvisibleInActivity();
+ } else {
+ fragment.onMadeVisibleInActivityInternal();
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/RespiratoryRateFragmentAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/RespiratoryRateFragmentAdapter.java
new file mode 100644
index 0000000..5394197
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/RespiratoryRateFragmentAdapter.java
@@ -0,0 +1,32 @@
+package nodomain.freeyourgadget.gadgetbridge.adapter;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.RespiratoryRateDailyFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.RespiratoryRatePeriodFragment;
+
+public class RespiratoryRateFragmentAdapter extends NestedFragmentAdapter {
+ protected FragmentManager fragmentManager;
+
+ public RespiratoryRateFragmentAdapter(AbstractGBFragment fragment, FragmentManager childFragmentManager) {
+ super(fragment, childFragmentManager);
+ fragmentManager = childFragmentManager;
+ }
+
+ @NonNull
+ @Override
+ public Fragment createFragment(int position) {
+ switch (position) {
+ case 0:
+ return new RespiratoryRateDailyFragment();
+ case 1:
+ return RespiratoryRatePeriodFragment.newInstance(7);
+ case 2:
+ return RespiratoryRatePeriodFragment.newInstance(30);
+ }
+ return new RespiratoryRateDailyFragment();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SleepFragmentAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SleepFragmentAdapter.java
new file mode 100644
index 0000000..7ea34a8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SleepFragmentAdapter.java
@@ -0,0 +1,30 @@
+package nodomain.freeyourgadget.gadgetbridge.adapter;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.DaySleepChartFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.WeekSleepChartFragment;
+
+public class SleepFragmentAdapter extends NestedFragmentAdapter {
+ public SleepFragmentAdapter(AbstractGBFragment fragment, FragmentManager childFragmentManager) {
+ super(fragment, childFragmentManager);
+ }
+
+ @NonNull
+ @Override
+ public Fragment createFragment(int position) {
+ switch (position) {
+ case 0:
+ return new DaySleepChartFragment();
+ case 1:
+ return WeekSleepChartFragment.newInstance(7);
+ case 2:
+ return WeekSleepChartFragment.newInstance(30);
+ }
+ return new DaySleepChartFragment();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SpinnerWithIconAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SpinnerWithIconAdapter.java
new file mode 100644
index 0000000..5732cdb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SpinnerWithIconAdapter.java
@@ -0,0 +1,78 @@
+/* Copyright (C) 2020-2024 Petr Van臎k
+
+ 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.adapter;
+
+import android.app.Activity;
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class SpinnerWithIconAdapter extends ArrayAdapter<SpinnerWithIconItem> {
+ private static final Logger LOG = LoggerFactory.getLogger(SpinnerWithIconAdapter.class);
+ int groupid;
+ Activity context;
+ ArrayList<SpinnerWithIconItem> list;
+ LayoutInflater inflater;
+
+ public SpinnerWithIconAdapter(Activity context, int groupid, int id, ArrayList<SpinnerWithIconItem>
+ list) {
+ super(context, id, list);
+ this.list = list;
+ inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ this.groupid = groupid;
+ }
+
+ public View getView(int position, View convertView, ViewGroup parent) {
+ View itemView = inflater.inflate(groupid, parent, false);
+ ImageView imageView = (ImageView) itemView.findViewById(R.id.spinner_item_icon);
+ imageView.setImageResource(list.get(position).getImageId());
+ TextView textView = (TextView) itemView.findViewById(R.id.spinner_item_text);
+ textView.setText(list.get(position).getText());
+
+ return itemView;
+ }
+
+ public View getDropDownView(int position, View convertView, ViewGroup
+ parent) {
+ return getView(position, convertView, parent);
+
+ }
+
+ public int getItemPositionForSelection(SpinnerWithIconItem item) {
+ if (item == null) return -1;
+ int index = 0;
+ for (SpinnerWithIconItem listItem : list) {
+ if (listItem.id.equals(item.id)) {
+ return index;
+ }
+ index++;
+ }
+ return -1;
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SpinnerWithIconItem.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SpinnerWithIconItem.java
new file mode 100644
index 0000000..dd1292b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/SpinnerWithIconItem.java
@@ -0,0 +1,48 @@
+/* Copyright (C) 2020-2024 Petr Van臎k
+
+ 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.adapter;
+
+public class SpinnerWithIconItem {
+
+ String text;
+ Long id;
+ int imageId;
+
+ public SpinnerWithIconItem(String text, Long id, int imageId) {
+ this.text = text;
+ this.id = id;
+ this.imageId = imageId;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public int getImageId() {
+ return imageId;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ @Override
+ public String toString() {
+ return text + " " + id + " " + imageId;
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/StepsFragmentAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/StepsFragmentAdapter.java
new file mode 100644
index 0000000..295aa65
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/StepsFragmentAdapter.java
@@ -0,0 +1,32 @@
+package nodomain.freeyourgadget.gadgetbridge.adapter;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.StepsDailyFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.StepsPeriodFragment;
+
+public class StepsFragmentAdapter extends NestedFragmentAdapter {
+ protected FragmentManager fragmentManager;
+
+ public StepsFragmentAdapter(AbstractGBFragment fragment, FragmentManager childFragmentManager) {
+ super(fragment, childFragmentManager);
+ fragmentManager = childFragmentManager;
+ }
+
+ @NonNull
+ @Override
+ public Fragment createFragment(int position) {
+ switch (position) {
+ case 0:
+ return new StepsDailyFragment();
+ case 1:
+ return StepsPeriodFragment.newInstance(7);
+ case 2:
+ return StepsPeriodFragment.newInstance(30);
+ }
+ return new StepsDailyFragment();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/GpsCapability.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/GpsCapability.java
new file mode 100644
index 0000000..b718fbf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/GpsCapability.java
@@ -0,0 +1,45 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities;
+
+public class GpsCapability {
+ public enum Preset {
+ ACCURACY,
+ BALANCED,
+ POWER_SAVING,
+ CUSTOM
+ }
+
+ public enum Band {
+ SINGLE_BAND,
+ DUAL_BAND
+ }
+
+ public enum Combination {
+ LOW_POWER_GPS,
+ GPS,
+ GPS_BDS,
+ GPS_GLONASS,
+ GPS_GALILEO,
+ ALL_SATELLITES
+ }
+
+ public enum SatelliteSearch {
+ SPEED_FIRST,
+ ACCURACY_FIRST
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java
new file mode 100644
index 0000000..c6029fb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java
@@ -0,0 +1,163 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ACTIVITY_MONITORING;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ENABLED;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_MEASUREMENT_INTERVAL;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION;
+
+import android.content.Context;
+
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.SwitchPreferenceCompat;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+
+public class HeartRateCapability {
+ public enum MeasurementInterval {
+ OFF(0, R.string.off),
+ SMART(-1, R.string.smart),
+ MINUTES_1(60, R.string.interval_one_minute),
+ MINUTES_5(300, R.string.interval_five_minutes),
+ MINUTES_10(600, R.string.interval_ten_minutes),
+ MINUTES_15(900, R.string.interval_fifteen_minutes),
+ MINUTES_30(1800, R.string.interval_thirty_minutes),
+ MINUTES_45(2700, R.string.interval_forty_five_minutes),
+ HOUR_1(3600, R.string.interval_one_hour),
+ ;
+
+ private final int intervalSeconds;
+ private final int label;
+
+ MeasurementInterval(final int intervalSeconds, final int label) {
+ this.intervalSeconds = intervalSeconds;
+ this.label = label;
+ }
+
+ public int getIntervalSeconds() {
+ return intervalSeconds;
+ }
+
+ public int getLabel() {
+ return label;
+ }
+
+ public String getLabel(final Context context) {
+ return context.getString(label);
+ }
+ }
+
+ public void registerPreferences(final Context context, final List<MeasurementInterval> intervals, final DeviceSpecificSettingsHandler handler) {
+ final Preference enableHeartrateSleepSupport = handler.findPreference(PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION);
+ if (enableHeartrateSleepSupport != null) {
+ enableHeartrateSleepSupport.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ GBApplication.deviceService(handler.getDevice()).onEnableHeartRateSleepSupport(Boolean.TRUE.equals(newVal));
+ return true;
+ }
+ });
+ }
+
+ handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD);
+ handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_HIGH_THRESHOLD);
+ handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_LOW_THRESHOLD);
+
+ final ListPreference heartrateMeasurementInterval = handler.findPreference(PREF_HEARTRATE_MEASUREMENT_INTERVAL);
+ final ListPreference heartrateAlertActiveHigh = handler.findPreference(PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD);
+ final ListPreference heartrateAlertHigh = handler.findPreference(PREF_HEARTRATE_ALERT_HIGH_THRESHOLD);
+ final ListPreference heartrateAlertLow = handler.findPreference(PREF_HEARTRATE_ALERT_LOW_THRESHOLD);
+ // Newer devices that have low alert threshold can only use it if measurement interval is smart (-1) or 1 minute
+ final boolean hrAlertsNeedSmartOrOne = heartrateAlertActiveHigh != null && heartrateAlertHigh != null && heartrateAlertLow != null && heartrateMeasurementInterval != null;
+ if (hrAlertsNeedSmartOrOne) {
+ final boolean hrMonitoringIsSmartOrOne = heartrateMeasurementInterval.getValue().equals("60") ||
+ heartrateMeasurementInterval.getValue().equals("-1");
+
+ heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne);
+ heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne);
+ heartrateAlertActiveHigh.setEnabled(hrMonitoringIsSmartOrOne);
+ }
+
+ if (heartrateMeasurementInterval != null) {
+ // Set the measurement intervals dynamically, as per the device's capability
+ final CharSequence[] entries = new CharSequence[intervals.size()];
+ final CharSequence[] values = new CharSequence[intervals.size()];
+ for (int i = 0; i < intervals.size(); i++) {
+ entries[i] = intervals.get(i).getLabel(context);
+ values[i] = String.valueOf(intervals.get(i).getIntervalSeconds());
+ }
+ heartrateMeasurementInterval.setEntries(entries);
+ heartrateMeasurementInterval.setEntryValues(values);
+
+ final SwitchPreferenceCompat activityMonitoring = handler.findPreference(PREF_HEARTRATE_ACTIVITY_MONITORING);
+ final SwitchPreferenceCompat heartrateAlertEnabled = handler.findPreference(PREF_HEARTRATE_ALERT_ENABLED);
+ final SwitchPreferenceCompat stressMonitoring = handler.findPreference(PREF_HEARTRATE_STRESS_MONITORING);
+
+ heartrateMeasurementInterval.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ public boolean onPreferenceChange(final Preference preference, final Object newVal) {
+ GBApplication.deviceService(handler.getDevice()).onSetHeartRateMeasurementInterval(Integer.parseInt((String) newVal));
+
+ final boolean isMeasurementIntervalEnabled = !newVal.equals("0");
+
+ if (activityMonitoring != null) {
+ activityMonitoring.setEnabled(isMeasurementIntervalEnabled);
+ }
+ if (heartrateAlertEnabled != null) {
+ heartrateAlertEnabled.setEnabled(isMeasurementIntervalEnabled);
+ }
+ if (hrAlertsNeedSmartOrOne) {
+ // Same as above, check if smart or 1 minute
+ final boolean hrMonitoringIsSmartOrOne = newVal.equals("60") || newVal.equals("-1");
+
+ heartrateAlertActiveHigh.setEnabled(hrMonitoringIsSmartOrOne);
+ heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne);
+ heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne);
+ }
+ if (stressMonitoring != null && !hrAlertsNeedSmartOrOne) {
+ // Newer devices (that have hrAlertsNeedSmartOrOne) also don't need HR monitoring for stress monitoring
+ stressMonitoring.setEnabled(isMeasurementIntervalEnabled);
+ }
+
+ return true;
+ }
+ });
+
+ final boolean isMeasurementIntervalEnabled = !heartrateMeasurementInterval.getValue().equals("0");
+
+ if (activityMonitoring != null) {
+ activityMonitoring.setEnabled(isMeasurementIntervalEnabled);
+ }
+ if (heartrateAlertEnabled != null) {
+ heartrateAlertEnabled.setEnabled(isMeasurementIntervalEnabled);
+ }
+ if (stressMonitoring != null && !hrAlertsNeedSmartOrOne) {
+ // Newer devices (that have hrAlertsNeedSmartOrOne) also don't need HR monitoring for stress monitoring
+ stressMonitoring.setEnabled(isMeasurementIntervalEnabled);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/WorkoutDetectionCapability.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/WorkoutDetectionCapability.java
new file mode 100644
index 0000000..12da8ab
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/WorkoutDetectionCapability.java
@@ -0,0 +1,36 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities;
+
+public class WorkoutDetectionCapability {
+ public enum Category {
+ WALKING,
+ INDOOR_WALKING,
+ OUTDOOR_RUNNING,
+ TREADMILL,
+ OUTDOOR_CYCLING,
+ POOL_SWIMMING,
+ ELLIPTICAL,
+ ROWING_MACHINE
+ }
+
+ public enum Sensitivity {
+ HIGH,
+ STANDARD,
+ LOW
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/BarcodeFormat.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/BarcodeFormat.java
new file mode 100644
index 0000000..3f6311d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/BarcodeFormat.java
@@ -0,0 +1,36 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards;
+
+/**
+ * Matching com.google.zxing.BarcodeFormat
+ */
+public enum BarcodeFormat {
+ AZTEC,
+ CODE_39,
+ CODE_93,
+ CODE_128,
+ CODABAR,
+ DATA_MATRIX,
+ EAN_8,
+ EAN_13,
+ ITF,
+ PDF_417,
+ QR_CODE,
+ UPC_A,
+ UPC_E,
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaContentProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaContentProvider.java
new file mode 100644
index 0000000..a6b6da7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaContentProvider.java
@@ -0,0 +1,264 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Currency;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+public class CatimaContentProvider {
+ private static final Logger LOG = LoggerFactory.getLogger(CatimaContentProvider.class);
+
+ public static final List<String> KNOWN_PACKAGES = new ArrayList<String>() {{
+ add("me.hackerchick.catima");
+ add("me.hackerchick.catima.debug");
+ }};
+
+ private final Context mContext;
+ private final Uri versionUri;
+ private final Uri cardsUri;
+ private final Uri groupsUri;
+ private final Uri cardGroupsUri;
+ private final String readPermission;
+
+ public CatimaContentProvider(final Context context, final String catimaPackageName) {
+ this.mContext = context;
+ final String catimaAuthority = catimaPackageName + ".contentprovider.cards";
+ this.versionUri = Uri.parse(String.format(Locale.ROOT, "content://%s/version", catimaAuthority));
+ this.cardsUri = Uri.parse(String.format(Locale.ROOT, "content://%s/cards", catimaAuthority));
+ this.groupsUri = Uri.parse(String.format(Locale.ROOT, "content://%s/groups", catimaAuthority));
+ this.cardGroupsUri = Uri.parse(String.format(Locale.ROOT, "content://%s/card_groups", catimaAuthority));
+ this.readPermission = String.format(Locale.ROOT, "%s.READ_CARDS", catimaPackageName);
+ }
+
+ public String getReadPermission() {
+ return this.readPermission;
+ }
+
+ public boolean isCatimaCompatible() {
+ final ContentResolver contentResolver = mContext.getContentResolver();
+ try (Cursor cursor = contentResolver.query(versionUri, null, null, null, null)) {
+ if (cursor == null || cursor.getCount() == 0) {
+ LOG.warn("Catima content provider version not found");
+ return false;
+ }
+
+ cursor.moveToNext();
+ final int major = cursor.getInt(cursor.getColumnIndexOrThrow("major"));
+ final int minor = cursor.getInt(cursor.getColumnIndexOrThrow("minor"));
+
+ LOG.info("Got catima content provider version: {}.{}", major, minor);
+
+ // We only support version 1.x for now
+ return major == 1;
+ } catch (final Exception e) {
+ LOG.error("Failed to get content provider version from Catima", e);
+ }
+
+ return false;
+ }
+
+ public List<LoyaltyCard> getCards() {
+ final List<LoyaltyCard> cards = new ArrayList<>();
+ final ContentResolver contentResolver = mContext.getContentResolver();
+ try (Cursor cursor = contentResolver.query(cardsUri, null, null, null, null)) {
+ if (cursor == null || cursor.getCount() == 0) {
+ LOG.debug("No cards found");
+ return cards;
+ }
+
+ while (cursor.moveToNext()) {
+ final LoyaltyCard loyaltyCard = toLoyaltyCard(cursor);
+ cards.add(loyaltyCard);
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to list cards from Catima", e);
+ return cards;
+ }
+
+ return cards;
+ }
+
+ public List<String> getGroups() {
+ final List<String> groups = new ArrayList<>();
+ final ContentResolver contentResolver = mContext.getContentResolver();
+ try (Cursor cursor = contentResolver.query(groupsUri, null, null, null, null)) {
+ if (cursor == null || cursor.getCount() == 0) {
+ LOG.debug("No groups found");
+ return groups;
+ }
+
+ while (cursor.moveToNext()) {
+ final String groupId = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbGroups.ID));
+ groups.add(groupId);
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to list groups from Catima", e);
+ return groups;
+ }
+
+ return groups;
+ }
+
+ /**
+ * Gets the mapping of group to list of card IDs.
+ *
+ * @return the mapping of group to list of card IDS.
+ */
+ public Map<String, List<Integer>> getGroupCards() {
+ final Map<String, List<Integer>> groupCards = new HashMap<>();
+ final ContentResolver contentResolver = mContext.getContentResolver();
+ try (Cursor cursor = contentResolver.query(cardGroupsUri, null, null, null, null)) {
+ if (cursor == null || cursor.getCount() == 0) {
+ LOG.debug("No card groups found");
+ return groupCards;
+ }
+
+ while (cursor.moveToNext()) {
+ final int cardId = cursor.getInt(cursor.getColumnIndexOrThrow(LoyaltyCardDbIdsGroups.cardID));
+ final String groupId = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIdsGroups.groupID));
+ final List<Integer> group;
+ if (groupCards.containsKey(groupId)) {
+ group = groupCards.get(groupId);
+ } else {
+ group = new ArrayList<>();
+ groupCards.put(groupId, group);
+ }
+ group.add(cardId);
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to get group cards from Catima", e);
+ return groupCards;
+ }
+
+ return groupCards;
+ }
+
+ public static LoyaltyCard toLoyaltyCard(final Cursor cursor) {
+ final int id = cursor.getInt(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.ID));
+ final String name = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.STORE));
+ final String note = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.NOTE));
+ final long expiryLong = cursor.getLong(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.EXPIRY));
+ final BigDecimal balance = new BigDecimal(cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.BALANCE)));
+ final String cardId = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.CARD_ID));
+ final String barcodeId = cursor.getString(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.BARCODE_ID));
+ final int starred = cursor.getInt(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.STAR_STATUS));
+ final long lastUsed = cursor.getLong(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.LAST_USED));
+ final int archiveStatus = cursor.getInt(cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.ARCHIVE_STATUS));
+
+ int barcodeTypeColumn = cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.BARCODE_TYPE);
+ int balanceTypeColumn = cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.BALANCE_TYPE);
+ int headerColorColumn = cursor.getColumnIndexOrThrow(LoyaltyCardDbIds.HEADER_COLOR);
+
+ BarcodeFormat barcodeFormat = null;
+ Currency balanceType = null;
+ Date expiry = null;
+ Integer headerColor = null;
+
+ if (!cursor.isNull(barcodeTypeColumn)) {
+ try {
+ barcodeFormat = BarcodeFormat.valueOf(cursor.getString(barcodeTypeColumn));
+ } catch (final IllegalArgumentException e) {
+ LOG.error("Unknown barcode format {}", barcodeTypeColumn);
+ }
+ }
+
+ if (!cursor.isNull(balanceTypeColumn)) {
+ balanceType = Currency.getInstance(cursor.getString(balanceTypeColumn));
+ }
+
+ if (expiryLong > 0) {
+ expiry = new Date(expiryLong);
+ }
+
+ if (!cursor.isNull(headerColorColumn)) {
+ headerColor = cursor.getInt(headerColorColumn);
+ }
+
+ return new LoyaltyCard(
+ id,
+ name,
+ note,
+ expiry,
+ balance,
+ balanceType,
+ cardId,
+ barcodeId,
+ barcodeFormat,
+ headerColor,
+ starred != 0,
+ archiveStatus != 0,
+ lastUsed
+ );
+ }
+
+ /**
+ * Copied from Catima, protect.card_locker.DBHelper.LoyaltyCardDbGroups.
+ * Commit: 8607e1c2
+ */
+ public static class LoyaltyCardDbGroups {
+ public static final String TABLE = "groups";
+ public static final String ID = "_id";
+ public static final String ORDER = "orderId";
+ }
+
+ /**
+ * Copied from Catima, protect.card_locker.DBHelper.LoyaltyCardDbIds.
+ * Commit: 8607e1c2
+ */
+ public static class LoyaltyCardDbIds {
+ public static final String TABLE = "cards";
+ public static final String ID = "_id";
+ public static final String STORE = "store";
+ public static final String EXPIRY = "expiry";
+ public static final String BALANCE = "balance";
+ public static final String BALANCE_TYPE = "balancetype";
+ public static final String NOTE = "note";
+ public static final String HEADER_COLOR = "headercolor";
+ public static final String HEADER_TEXT_COLOR = "headertextcolor";
+ public static final String CARD_ID = "cardid";
+ public static final String BARCODE_ID = "barcodeid";
+ public static final String BARCODE_TYPE = "barcodetype";
+ public static final String STAR_STATUS = "starstatus";
+ public static final String LAST_USED = "lastused";
+ public static final String ZOOM_LEVEL = "zoomlevel";
+ public static final String ARCHIVE_STATUS = "archive";
+ }
+
+ /**
+ * Copied from Catima, protect.card_locker.DBHelper.LoyaltyCardDbIdsGroups.
+ * Commit: 8607e1c2
+ */
+ public static class LoyaltyCardDbIdsGroups {
+ public static final String TABLE = "cardsGroups";
+ public static final String cardID = "cardId";
+ public static final String groupID = "groupId";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaManager.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaManager.java
new file mode 100644
index 0000000..88715f4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/CatimaManager.java
@@ -0,0 +1,128 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_CATIMA_PACKAGE;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_ARCHIVED;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_GROUPS;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_GROUPS_ONLY;
+import static nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst.LOYALTY_CARDS_SYNC_STARRED;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.widget.Toast;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class CatimaManager {
+ private static final Logger LOG = LoggerFactory.getLogger(CatimaManager.class);
+
+ private final Context context;
+
+ public CatimaManager(final Context context) {
+ this.context = context;
+ }
+
+ public void sync(final GBDevice gbDevice) {
+ final List<CharSequence> installedCatimaPackages = findInstalledCatimaPackages();
+ if (installedCatimaPackages.isEmpty()) {
+ LOG.warn("Catima is not installed");
+ return;
+ }
+
+ final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
+
+ final boolean syncGroupsOnly = prefs.getBoolean(LOYALTY_CARDS_SYNC_GROUPS_ONLY, false);
+ final Set<String> syncGroups = prefs.getStringSet(LOYALTY_CARDS_SYNC_GROUPS, Collections.emptySet());
+ final boolean syncArchived = prefs.getBoolean(LOYALTY_CARDS_SYNC_ARCHIVED, false);
+ final boolean syncStarred = prefs.getBoolean(LOYALTY_CARDS_SYNC_STARRED, false);
+
+ final String catimaPackage = prefs.getString(LOYALTY_CARDS_CATIMA_PACKAGE, installedCatimaPackages.get(0).toString());
+ final CatimaContentProvider catima = new CatimaContentProvider(context, catimaPackage);
+
+ if (!catima.isCatimaCompatible()) {
+ LOG.warn("Catima is not compatible");
+ return;
+ }
+
+ final List<LoyaltyCard> cards = catima.getCards();
+ final Map<String, List<Integer>> groupCards = catima.getGroupCards();
+
+ final Set<Integer> cardsInGroupsToSync = new HashSet<>();
+ if (syncGroupsOnly) {
+ for (final Map.Entry<String, List<Integer>> groupCardsEntry : groupCards.entrySet()) {
+ if (syncGroups.contains(groupCardsEntry.getKey())) {
+ cardsInGroupsToSync.addAll(groupCardsEntry.getValue());
+ }
+ }
+ }
+
+ final ArrayList<LoyaltyCard> cardsToSync = new ArrayList<>();
+ for (final LoyaltyCard card : cards) {
+ if (syncGroupsOnly && !cardsInGroupsToSync.contains(card.getId())) {
+ continue;
+ }
+ if (!syncArchived && card.isArchived()) {
+ continue;
+ }
+ if (syncStarred && !card.isStarred()) {
+ continue;
+ }
+ cardsToSync.add(card);
+ }
+
+ Collections.sort(cardsToSync);
+
+ LOG.debug("Will sync cards: {}", cardsToSync);
+
+ GB.toast(context, context.getString(R.string.loyalty_cards_syncing, cardsToSync.size()), Toast.LENGTH_LONG, GB.INFO);
+
+ GBApplication.deviceService(gbDevice).onSetLoyaltyCards(cardsToSync);
+ }
+
+ public List<CharSequence> findInstalledCatimaPackages() {
+ final List<CharSequence> installedCatimaPackages = new ArrayList<>();
+ for (final String knownPackage : CatimaContentProvider.KNOWN_PACKAGES) {
+ if (isPackageInstalled(knownPackage)) {
+ installedCatimaPackages.add(knownPackage);
+ }
+ }
+ return installedCatimaPackages;
+ }
+
+ private boolean isPackageInstalled(final String packageName) {
+ try {
+ return context.getPackageManager().getApplicationInfo(packageName, 0).enabled;
+ } catch (final PackageManager.NameNotFoundException e) {
+ return false;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/LoyaltyCard.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/LoyaltyCard.java
new file mode 100644
index 0000000..9b3d131
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/loyaltycards/LoyaltyCard.java
@@ -0,0 +1,153 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards;
+
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.builder.CompareToBuilder;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Currency;
+import java.util.Date;
+import java.util.Locale;
+
+public class LoyaltyCard implements Serializable, Comparable<LoyaltyCard> {
+
+ private final int id;
+ private final String name;
+ private final String note;
+ private final Date expiry;
+ private final BigDecimal balance;
+ private final Currency balanceType;
+ private final String cardId;
+
+ @Nullable
+ private final String barcodeId;
+
+ @Nullable
+ private final BarcodeFormat barcodeFormat;
+
+ @Nullable
+ private final Integer color;
+
+ private final boolean starred;
+ private final boolean archived;
+ private final long lastUsed;
+
+ public LoyaltyCard(final int id,
+ final String name,
+ final String note,
+ final Date expiry,
+ final BigDecimal balance,
+ final Currency balanceType,
+ final String cardId,
+ @Nullable final String barcodeId,
+ @Nullable final BarcodeFormat barcodeFormat,
+ @Nullable final Integer color,
+ final boolean starred,
+ final boolean archived,
+ final long lastUsed) {
+ this.id = id;
+ this.name = name;
+ this.note = note;
+ this.expiry = expiry;
+ this.balance = balance;
+ this.balanceType = balanceType;
+ this.cardId = cardId;
+ this.barcodeId = barcodeId;
+ this.barcodeFormat = barcodeFormat;
+ this.color = color;
+ this.starred = starred;
+ this.archived = archived;
+ this.lastUsed = lastUsed;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getNote() {
+ return note;
+ }
+
+ public Date getExpiry() {
+ return expiry;
+ }
+
+ public BigDecimal getBalance() {
+ return balance;
+ }
+
+ public Currency getBalanceType() {
+ return balanceType;
+ }
+
+ public String getCardId() {
+ return cardId;
+ }
+
+ @Nullable
+ public String getBarcodeId() {
+ return barcodeId;
+ }
+
+ @Nullable
+ public BarcodeFormat getBarcodeFormat() {
+ return barcodeFormat;
+ }
+
+ @Nullable
+ public Integer getColor() {
+ return color;
+ }
+
+ public boolean isStarred() {
+ return starred;
+ }
+
+ public boolean isArchived() {
+ return archived;
+ }
+
+ public long getLastUsed() {
+ return lastUsed;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ Locale.ROOT,
+ "LoyaltyCard{id=%s, name=%s, cardId=%s}",
+ id, name, cardId
+ );
+ }
+
+ @Override
+ public int compareTo(final LoyaltyCard o) {
+ return new CompareToBuilder()
+ .append(isStarred(), o.isStarred())
+ .append(isArchived(), o.isArchived())
+ .append(getName(), o.getName())
+ .append(getCardId(), o.getCardId())
+ .build();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/password/PasswordCapabilityImpl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/password/PasswordCapabilityImpl.java
new file mode 100644
index 0000000..9e4aab9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/password/PasswordCapabilityImpl.java
@@ -0,0 +1,153 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.password;
+
+import android.content.Context;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.text.Spanned;
+import android.text.TextWatcher;
+import android.widget.EditText;
+
+import androidx.annotation.NonNull;
+import androidx.preference.EditTextPreference;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+
+public class PasswordCapabilityImpl {
+ public static final String PREF_PASSWORD = "pref_password";
+ public static final String PREF_PASSWORD_ENABLED = "pref_password_enabled";
+
+ public enum Mode {
+ NONE,
+ NUMBERS_4_DIGITS_0_TO_9,
+ NUMBERS_4_DIGITS_1_TO_4,
+ NUMBERS_6,
+ }
+
+ public void registerPreferences(final Context context, final Mode mode, final DeviceSpecificSettingsHandler handler) {
+ if (mode == Mode.NONE) {
+ return;
+ }
+
+ final EditTextPreference password = handler.findPreference(PREF_PASSWORD);
+ if (password == null) {
+ return;
+ }
+
+ handler.addPreferenceHandlerFor(PREF_PASSWORD);
+ handler.addPreferenceHandlerFor(PREF_PASSWORD_ENABLED);
+
+ switch (mode) {
+ case NUMBERS_6:
+ password.setSummary(R.string.prefs_password_6_digits_0_to_9_summary);
+ break;
+ case NUMBERS_4_DIGITS_0_TO_9:
+ password.setSummary(R.string.prefs_password_4_digits_0_to_9_summary);
+ break;
+ case NUMBERS_4_DIGITS_1_TO_4:
+ password.setSummary(R.string.prefs_password_4_digits_1_to_4_summary);
+ break;
+ default:
+ break;
+ }
+
+ password.setOnBindEditTextListener(new EditTextPreference.OnBindEditTextListener() {
+ @Override
+ public void onBindEditText(@NonNull final EditText editText) {
+ final int expectedLength;
+ final List<InputFilter> inputFilters = new ArrayList<>();
+
+ switch (mode) {
+ case NUMBERS_6:
+ password.setSummary(R.string.prefs_password_6_digits_0_to_9_summary);
+ editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
+ expectedLength = 6;
+ break;
+ case NUMBERS_4_DIGITS_0_TO_9:
+ password.setSummary(R.string.prefs_password_4_digits_0_to_9_summary);
+ editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
+ expectedLength = 4;
+ break;
+ case NUMBERS_4_DIGITS_1_TO_4:
+ password.setSummary(R.string.prefs_password_4_digits_1_to_4_summary);
+ editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
+ expectedLength = 4;
+ inputFilters.add(new InputFilter_Digits_1to4());
+ break;
+ default:
+ editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
+ expectedLength = -1;
+ break;
+ }
+
+ if (expectedLength != -1) {
+ inputFilters.add(new InputFilter.LengthFilter(expectedLength));
+ }
+
+ editText.setSelection(editText.getText().length());
+ editText.setFilters(inputFilters.toArray(new InputFilter[0]));
+ editText.addTextChangedListener(new ExpectedLengthTextWatcher(editText, expectedLength));
+ }
+ });
+ }
+
+ private static class InputFilter_Digits_1to4 implements InputFilter {
+ @Override
+ public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
+ for (int i = start; i < end; i++) {
+ if (!Character.isDigit(source.charAt(i))) {
+ return "";
+ }
+
+ if (source.charAt(i) < '1' || source.charAt(i) > '4') {
+ return "";
+ }
+ }
+ return null;
+ }
+ }
+
+ private static class ExpectedLengthTextWatcher implements TextWatcher {
+ private final EditText editText;
+ private final int expectedLength;
+
+ private ExpectedLengthTextWatcher(final EditText editText, final int expectedLength) {
+ this.editText = editText;
+ this.expectedLength = expectedLength;
+ }
+
+ @Override
+ public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) {
+ }
+
+ @Override
+ public void onTextChanged(final CharSequence s, final int start, final int before, final int count) {
+ }
+
+ @Override
+ public void afterTextChanged(final Editable editable) {
+ editText.getRootView().findViewById(android.R.id.button1)
+ .setEnabled(expectedLength == -1 || editable.length() == expectedLength);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetLayout.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetLayout.java
new file mode 100644
index 0000000..3df35e4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetLayout.java
@@ -0,0 +1,59 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets;
+
+import androidx.annotation.StringRes;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public enum WidgetLayout {
+ // Square screen layouts, 2x2
+ TOP_1_BOT_2(R.string.widget_layout_top_1_bot_2, WidgetType.WIDE, WidgetType.SMALL, WidgetType.SMALL),
+ TOP_2_BOT_1(R.string.widget_layout_top_2_bot_1, WidgetType.SMALL, WidgetType.SMALL, WidgetType.SMALL),
+ TOP_2_BOT_2(R.string.widget_layout_top_2_bot_2, WidgetType.SMALL, WidgetType.SMALL, WidgetType.SMALL, WidgetType.SMALL),
+ TWO_BY_TWO_SINGLE(R.string.widget_layout_single, WidgetType.LARGE),
+
+ // Narrow screen layouts, 2x1
+ ONE_BY_TWO_SINGLE(R.string.widget_layout_single, WidgetType.TALL),
+ TWO(R.string.widget_layout_two, WidgetType.SMALL, WidgetType.SMALL),
+
+ // Portrait 2x3 screen layouts
+ TOP_2_BOT_2X2(R.string.widget_layout_top_2_bot_1, WidgetType.SMALL, WidgetType.SMALL, WidgetType.LARGE),
+ TOP_2X2_BOT_2(R.string.widget_layout_top_1_bot_2, WidgetType.LARGE, WidgetType.SMALL, WidgetType.SMALL),
+ TOP_1_BOT_2X2(R.string.widget_layout_top_wide_bot_large, WidgetType.WIDE, WidgetType.LARGE),
+ TOP_2X2_BOT_1(R.string.widget_layout_top_large_bot_wide, WidgetType.LARGE, WidgetType.WIDE),
+ TWO_BY_THREE_SINGLE(R.string.widget_layout_single, WidgetType.PORTRAIT_LARGE),
+ ;
+
+ @StringRes
+ private final int name;
+ private final WidgetType[] widgetTypes;
+
+ WidgetLayout(final int name, final WidgetType... widgetTypes) {
+ this.name = name;
+ this.widgetTypes = widgetTypes;
+ }
+
+ @StringRes
+ public int getName() {
+ return name;
+ }
+
+ public WidgetType[] getWidgetTypes() {
+ return widgetTypes;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetManager.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetManager.java
new file mode 100644
index 0000000..0d4c9c4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetManager.java
@@ -0,0 +1,70 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+/**
+ * Provide an interface to manage a device's widgets in a device-independent manner.
+ */
+public interface WidgetManager {
+ /**
+ * The widget layouts supported by this device.
+ */
+ List<WidgetLayout> getSupportedWidgetLayouts();
+
+ /**
+ * The widget parts that can be used to build a widget screen, for a specific widget type.x
+ */
+ List<WidgetPart> getSupportedWidgetParts(WidgetType targetWidgetType);
+
+ /**
+ * The currently configured widget screens on this device.
+ */
+ List<WidgetScreen> getWidgetScreens();
+
+ GBDevice getDevice();
+
+ /**
+ * The minimum number of screens that must be present - deleting screens won't be allowed
+ * past this number.
+ */
+ int getMinScreens();
+
+ /**
+ * The maximum number of screens that can be created.
+ */
+ int getMaxScreens();
+
+ /**
+ * Saves a screen. If the screen is new, the ID will be null, otherwise it matches the
+ * screen that is being updated.
+ */
+ void saveScreen(WidgetScreen widgetScreen);
+
+ /**
+ * Deletes a screen.
+ */
+ void deleteScreen(WidgetScreen widgetScreen);
+
+ /**
+ * Send the currently configured screens to the device.
+ */
+ void sendToDevice();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPart.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPart.java
new file mode 100644
index 0000000..c224672
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPart.java
@@ -0,0 +1,97 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets;
+
+import androidx.annotation.Nullable;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * A widget part is a single widget in a widget screen.
+ */
+public class WidgetPart implements Serializable {
+ // Null when not selected
+ @Nullable
+ private String id;
+
+ // The human-readable part name
+ private String name;
+
+ private WidgetType type;
+
+ // Null if it has no specific subtype
+ @Nullable
+ private WidgetPartSubtype subtype;
+
+ // The list of subtypes supported by this part, if any
+ private final List<WidgetPartSubtype> supportedSubtypes = new ArrayList<>();
+
+ public WidgetPart(@Nullable final String id, final String name, final WidgetType type) {
+ this.id = id;
+ this.name = name;
+ this.type = type;
+ }
+
+ @Nullable
+ public String getId() {
+ return id;
+ }
+
+ public void setId(@Nullable final String id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getFullName() {
+ if (subtype != null) {
+ return String.format(Locale.ROOT, "%s (%s)", name, subtype.getName());
+ }
+
+ return name;
+ }
+
+ public void setName(final String name) {
+ this.name = name;
+ }
+
+ public WidgetType getType() {
+ return type;
+ }
+
+ public void setType(final WidgetType type) {
+ this.type = type;
+ }
+
+ @Nullable
+ public WidgetPartSubtype getSubtype() {
+ return subtype;
+ }
+
+ public void setSubtype(@Nullable final WidgetPartSubtype subtype) {
+ this.subtype = subtype;
+ }
+
+ public List<WidgetPartSubtype> getSupportedSubtypes() {
+ return supportedSubtypes;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPartSubtype.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPartSubtype.java
new file mode 100644
index 0000000..b4be3ff
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPartSubtype.java
@@ -0,0 +1,48 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets;
+
+import java.io.Serializable;
+
+/**
+ * A widget part can have an optional subtype (eg. a workout type).
+ */
+public class WidgetPartSubtype implements Serializable {
+ private String id;
+ private String name;
+
+ public WidgetPartSubtype(final String id, final String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(final String id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(final String name) {
+ this.name = name;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetScreen.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetScreen.java
new file mode 100644
index 0000000..25cd567
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetScreen.java
@@ -0,0 +1,65 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets;
+
+import androidx.annotation.Nullable;
+
+import java.io.Serializable;
+import java.util.List;
+
+public class WidgetScreen implements Serializable {
+ public static final String EXTRA_WIDGET_SCREEN = "widget_screen";
+
+ // Null when creating a new screen
+ @Nullable
+ private String id;
+ private WidgetLayout layout;
+
+ // The list of parts must match what the WidgetLayout expects
+ private List<WidgetPart> parts;
+
+ public WidgetScreen(@Nullable final String id, final WidgetLayout layout, final List<WidgetPart> parts) {
+ this.id = id;
+ this.layout = layout;
+ this.parts = parts;
+ }
+
+ @Nullable
+ public String getId() {
+ return id;
+ }
+
+ public void setId(@Nullable final String id) {
+ this.id = id;
+ }
+
+ public WidgetLayout getLayout() {
+ return layout;
+ }
+
+ public void setLayout(final WidgetLayout layout) {
+ this.layout = layout;
+ }
+
+ public List<WidgetPart> getParts() {
+ return parts;
+ }
+
+ public void setParts(final List<WidgetPart> parts) {
+ this.parts = parts;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetType.java
new file mode 100644
index 0000000..01e3e95
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetType.java
@@ -0,0 +1,26 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets;
+
+public enum WidgetType {
+ SMALL, // 1x1
+ TALL, // 1x2
+ WIDE, // 2x1
+ LARGE, // 2x2
+ PORTRAIT_LARGE, // 2x3
+ ;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/contentprovider/PebbleContentProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/contentprovider/PebbleContentProvider.java
new file mode 100644
index 0000000..4e97565
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/contentprovider/PebbleContentProvider.java
@@ -0,0 +1,117 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti
+
+ 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.contentprovider;
+
+import android.content.BroadcastReceiver;
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs;
+
+public class PebbleContentProvider extends ContentProvider {
+
+ public static final int COLUMN_CONNECTED = 0;
+ public static final int COLUMN_APPMSG_SUPPORT = 1;
+ public static final int COLUMN_DATALOGGING_SUPPORT = 2;
+ public static final int COLUMN_VERSION_MAJOR = 3;
+ public static final int COLUMN_VERSION_MINOR = 4;
+ public static final int COLUMN_VERSION_POINT = 5;
+ public static final int COLUMN_VERSION_TAG = 6;
+
+ // this is only needed for the MatrixCursor constructor
+ public static final String[] columnNames = new String[]{"0", "1", "2", "3", "4", "5", "6"};
+
+ static final String PROVIDER_NAME = "com.getpebble.android.provider";
+ static final String URL = "content://" + PROVIDER_NAME + "/state";
+ static final Uri CONTENT_URI = Uri.parse(URL);
+
+ private GBDevice mGBDevice = null;
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action.equals(GBDevice.ACTION_DEVICE_CHANGED)) {
+ mGBDevice = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ }
+ }
+ };
+
+ @Override
+ public boolean onCreate() {
+
+ LocalBroadcastManager.getInstance(this.getContext()).registerReceiver(mReceiver, new IntentFilter(GBDevice.ACTION_DEVICE_CHANGED));
+
+ return true;
+ }
+
+ @Override
+ public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ if (uri.equals(CONTENT_URI)) {
+ MatrixCursor mc = new MatrixCursor(columnNames);
+ int connected = 0;
+ int pebbleKit = 0;
+ String fwString = "unknown";
+ if (mGBDevice != null && mGBDevice.getType() == DeviceType.PEBBLE && mGBDevice.isInitialized()) {
+ final DevicePrefs deviceSpecificSharedPrefsrefs = GBApplication.getDevicePrefs(mGBDevice);
+ if (deviceSpecificSharedPrefsrefs.getBoolean("third_party_apps_set_settings", false)) {
+ pebbleKit = 1;
+ }
+ connected = 1;
+ fwString = mGBDevice.getFirmwareVersion();
+ }
+ mc.addRow(new Object[]{connected, pebbleKit, pebbleKit, 3, 8, 2, fwString});
+
+ return mc;
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public String getType(@NonNull Uri uri) {
+ return null;
+ }
+
+ @Override
+ public Uri insert(@NonNull Uri uri, ContentValues values) {
+ return null;
+ }
+
+ @Override
+ public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {
+ return 0;
+ }
+
+ @Override
+ public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ return 0;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/AppSpecificNotificationSettingsRepository.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/AppSpecificNotificationSettingsRepository.java
new file mode 100644
index 0000000..f9afb1c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/AppSpecificNotificationSettingsRepository.java
@@ -0,0 +1,85 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.database;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.entities.AppSpecificNotificationSettingDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.AppSpecificNotificationSetting;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+// TODO: Distinguish by device ID as well to allow for different settings per device.
+// I didn't know how to get a device ID from a settings activity, so I left it as is.
+public class AppSpecificNotificationSettingsRepository {
+ private static final Logger LOG = LoggerFactory.getLogger(AppSpecificNotificationSettingsRepository.class);
+ private GBDevice mGbDevice;
+ public AppSpecificNotificationSettingsRepository(GBDevice gbDevice) {
+ mGbDevice = gbDevice;
+ }
+
+ @Nullable
+ public AppSpecificNotificationSetting getSettingsForAppId(String appId) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ DaoSession session = db.getDaoSession();
+ QueryBuilder<AppSpecificNotificationSetting> qb = session.getAppSpecificNotificationSettingDao().queryBuilder();
+ return qb.where(
+ qb.and(AppSpecificNotificationSettingDao.Properties.PackageId.eq(appId),
+ AppSpecificNotificationSettingDao.Properties.DeviceId.eq(DBHelper.findDevice(mGbDevice, session).getId())
+ )).build().unique();
+ } catch (Exception e) {
+ LOG.error("Failed to get DB handle", e);
+ }
+ return null;
+ }
+
+ private void deleteForAppId(@NonNull String appId) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ DaoSession session = db.getDaoSession();
+ QueryBuilder<AppSpecificNotificationSetting> qb = session.getAppSpecificNotificationSettingDao().queryBuilder();
+ qb.where(
+ qb.and(AppSpecificNotificationSettingDao.Properties.PackageId.eq(appId),
+ AppSpecificNotificationSettingDao.Properties.DeviceId.eq(DBHelper.findDevice(mGbDevice, session).getId())
+ )).buildDelete().executeDeleteWithoutDetachingEntities();
+ } catch (Exception e) {
+ LOG.error("Failed to get DB handle", e);
+ }
+ }
+
+ public void setSettingsForAppId(@NonNull String appId, @Nullable AppSpecificNotificationSetting settings) {
+ if (settings == null) {
+ deleteForAppId(appId);
+ } else {
+ settings.setPackageId(appId);
+ try (DBHandler db = GBApplication.acquireDB()) {
+ DaoSession session = db.getDaoSession();
+ Device dbDevice = DBHelper.findDevice(mGbDevice, session);
+ settings.setDeviceId(dbDevice.getId());
+ session.getAppSpecificNotificationSettingDao().insertOrReplace(settings);
+ } catch (Exception e) {
+ LOG.error("Failed to get DB handle", e);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBAccess.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBAccess.java
new file mode 100644
index 0000000..aa87711
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBAccess.java
@@ -0,0 +1,71 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Petr Van臎k
+
+ 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.database;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.widget.Toast;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public abstract class DBAccess extends AsyncTask {
+ private static final Logger LOG = LoggerFactory.getLogger(DBAccess.class);
+
+ private final String mTask;
+ private final Context mContext;
+ private Exception mError;
+
+ public DBAccess(String task, Context context) {
+ mTask = task;
+ mContext = context;
+ }
+
+ public Context getContext() {
+ return mContext;
+ }
+
+ protected abstract void doInBackground(DBHandler handler);
+
+ @Override
+ protected Object doInBackground(Object[] params) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ doInBackground(db);
+ } catch (Exception e) {
+ LOG.error("Error during DBAccess for {}", mTask, e);
+ mError = e;
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Object o) {
+ if (mError != null) {
+ displayError(mError);
+ }
+ }
+
+
+
+ protected void displayError(Throwable error) {
+ GB.toast(getContext(), getContext().getString(R.string.dbaccess_error_executing, error.getMessage()), Toast.LENGTH_LONG, GB.ERROR, error);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHandler.java
new file mode 100644
index 0000000..7e18d1f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHandler.java
@@ -0,0 +1,53 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, JohnnySun
+
+ 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.database;
+
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoMaster;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+
+/**
+ * Provides low-level access to the database.
+ */
+public interface DBHandler extends AutoCloseable {
+ /**
+ * Closes the database.
+ */
+ void closeDb();
+
+ /**
+ * Opens the database. Note that this is only possible after an explicit
+ * #closeDb(). Initially the db is implicitly open.
+ */
+ void openDb();
+
+ SQLiteOpenHelper getHelper();
+
+ /**
+ * Releases the DB handler. No DB access will be possible before
+ * #openDb() will be called.
+ */
+ void close() throws Exception;
+
+ SQLiteDatabase getDatabase();
+
+ DaoMaster getDaoMaster();
+ DaoSession getDaoSession();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java
new file mode 100644
index 0000000..8333456
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java
@@ -0,0 +1,777 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien
+ Gaignon, Daniel Dakhno, Daniele Gobbetti, Felix Konstantin Maurer, JohnnySun,
+ Jos茅 Rebelo, Petr Van臎k
+
+ 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.database;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+import de.greenrobot.dao.Property;
+import de.greenrobot.dao.query.Query;
+import de.greenrobot.dao.query.QueryBuilder;
+import de.greenrobot.dao.query.WhereCondition;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.ActivityDescription;
+import nodomain.freeyourgadget.gadgetbridge.entities.ActivityDescriptionDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.entities.AlarmDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Contact;
+import nodomain.freeyourgadget.gadgetbridge.entities.ContactDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.DeviceAttributes;
+import nodomain.freeyourgadget.gadgetbridge.entities.DeviceAttributesDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DeviceDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Reminder;
+import nodomain.freeyourgadget.gadgetbridge.entities.ReminderDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Tag;
+import nodomain.freeyourgadget.gadgetbridge.entities.TagDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.entities.UserAttributes;
+import nodomain.freeyourgadget.gadgetbridge.entities.UserDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.WorldClock;
+import nodomain.freeyourgadget.gadgetbridge.entities.WorldClockDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.model.ValidByDate;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+
+
+/**
+ * Provides utility access to some common entities, so you won't need to use
+ * their DAO classes.
+ * <p/>
+ * Maybe this code should actually be in the DAO classes themselves, but then
+ * these should be under revision control instead of 100% generated at build time.
+ */
+public class DBHelper {
+ private static final Logger LOG = LoggerFactory.getLogger(DBHelper.class);
+
+ private final Context context;
+
+ public DBHelper(Context context) {
+ this.context = context;
+ }
+
+ /**
+ * Closes the database and returns its name.
+ * Important: after calling this, you have to DBHandler#openDb() it again
+ * to get it back to work.
+ *
+ * @param dbHandler
+ * @return
+ * @throws IllegalStateException
+ */
+ private String getClosedDBPath(DBHandler dbHandler) throws IllegalStateException {
+ SQLiteDatabase db = dbHandler.getDatabase();
+ String path = db.getPath();
+ dbHandler.closeDb();
+ if (db.isOpen()) { // reference counted, so may still be open
+ throw new IllegalStateException("Database must be closed");
+ }
+ return path;
+ }
+
+ public File exportDB(DBHandler dbHandler, File toDir) throws IllegalStateException, IOException {
+ String dbPath = getClosedDBPath(dbHandler);
+ try {
+ File sourceFile = new File(dbPath);
+ File destFile = new File(toDir, sourceFile.getName());
+ if (destFile.exists()) {
+ File backup = new File(toDir, destFile.getName() + "_" + getDate());
+ destFile.renameTo(backup);
+ } else if (!toDir.exists()) {
+ if (!toDir.mkdirs()) {
+ throw new IOException("Unable to create directory: " + toDir.getAbsolutePath());
+ }
+ }
+
+ FileUtils.copyFile(sourceFile, destFile);
+ return destFile;
+ } finally {
+ dbHandler.openDb();
+ }
+ }
+
+ public void exportDB(DBHandler dbHandler, OutputStream dest) throws IOException {
+ String dbPath = getClosedDBPath(dbHandler);
+ try {
+ File source = new File(dbPath);
+ FileUtils.copyFileToStream(source, dest);
+ } finally {
+ dbHandler.openDb();
+ }
+ }
+
+ private String getDate() {
+ return new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(new Date());
+ }
+
+ public void importDB(DBHandler dbHandler, File fromFile) throws IllegalStateException, IOException {
+ importDB(dbHandler, new FileInputStream(fromFile));
+ }
+
+ public void importDB(DBHandler dbHandler, InputStream inputStream) throws IllegalStateException, IOException {
+ String dbPath = getClosedDBPath(dbHandler);
+ try {
+ File toFile = new File(dbPath);
+ FileUtils.copyStreamToFile(inputStream, toFile);
+ } finally {
+ dbHandler.openDb();
+ }
+ }
+
+ public void validateDB(SQLiteOpenHelper dbHandler) throws IOException {
+ try (SQLiteDatabase db = dbHandler.getReadableDatabase()) {
+ if (!db.isDatabaseIntegrityOk()) {
+ throw new IOException("Database integrity is not OK");
+ }
+ }
+ }
+
+ public static void dropTable(String tableName, SQLiteDatabase db) {
+ String statement = "DROP TABLE IF EXISTS '" + tableName + "'";
+ db.execSQL(statement);
+ }
+
+ public boolean existsDB(String dbName) {
+ File path = context.getDatabasePath(dbName);
+ return path != null && path.exists();
+ }
+
+ public static boolean existsColumn(String tableName, String columnName, SQLiteDatabase db) {
+ try (Cursor res = db.rawQuery("PRAGMA table_info('" + tableName + "')", null)) {
+ int index = res.getColumnIndex("name");
+ if (index < 1) {
+ return false; // something's really wrong
+ }
+ while (res.moveToNext()) {
+ String cn = res.getString(index);
+ if (columnName.equals(cn)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Looks up the user entity in the database. If a user exists already, it will
+ * be updated with the current preferences values. If no user exists yet, it will
+ * be created in the database.
+ *
+ * Note: so far there is only ever a single user; there is no multi-user support yet
+ * @param session
+ * @return the User entity
+ */
+ @NonNull
+ public static User getUser(DaoSession session) {
+ ActivityUser prefsUser = new ActivityUser();
+ UserDao userDao = session.getUserDao();
+ User user;
+ List<User> users = userDao.loadAll();
+ if (users.isEmpty()) {
+ user = createUser(prefsUser, session);
+ } else {
+ user = users.get(0); // TODO: multiple users support?
+ ensureUserUpToDate(user, prefsUser, session);
+ }
+ ensureUserAttributes(user, prefsUser, session);
+
+ return user;
+ }
+
+ @NonNull
+ public static UserAttributes getUserAttributes(User user) {
+ List<UserAttributes> list = user.getUserAttributesList();
+ if (list.isEmpty()) {
+ throw new IllegalStateException("user has no attributes");
+ }
+ return list.get(0);
+ }
+
+ @NonNull
+ private static User createUser(ActivityUser prefsUser, DaoSession session) {
+ User user = new User();
+ ensureUserUpToDate(user, prefsUser, session);
+
+ return user;
+ }
+
+ private static void ensureUserUpToDate(User user, ActivityUser prefsUser, DaoSession session) {
+ if (!isUserUpToDate(user, prefsUser)) {
+ user.setName(prefsUser.getName());
+ user.setBirthday(prefsUser.getUserBirthday());
+ user.setGender(prefsUser.getGender());
+
+ if (user.getId() == null) {
+ session.getUserDao().insert(user);
+ } else {
+ session.getUserDao().update(user);
+ }
+ }
+ }
+
+ public static boolean isUserUpToDate(User user, ActivityUser prefsUser) {
+ if (!Objects.equals(user.getName(), prefsUser.getName())) {
+ return false;
+ }
+ if (!Objects.equals(user.getBirthday(), prefsUser.getUserBirthday())) {
+ return false;
+ }
+ if (user.getGender() != prefsUser.getGender()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static void ensureUserAttributes(User user, ActivityUser prefsUser, DaoSession session) {
+ List<UserAttributes> userAttributes = user.getUserAttributesList();
+ UserAttributes[] previousUserAttributes = new UserAttributes[1];
+ if (hasUpToDateUserAttributes(userAttributes, prefsUser, previousUserAttributes)) {
+ return;
+ }
+
+ Calendar now = DateTimeUtils.getCalendarUTC();
+ invalidateUserAttributes(previousUserAttributes[0], now, session);
+
+ UserAttributes attributes = new UserAttributes();
+ attributes.setValidFromUTC(now.getTime());
+ attributes.setHeightCM(prefsUser.getHeightCm());
+ attributes.setWeightKG(prefsUser.getWeightKg());
+ attributes.setSleepGoalHPD(prefsUser.getSleepDurationGoal());
+ attributes.setStepsGoalSPD(prefsUser.getStepsGoal());
+ attributes.setUserId(user.getId());
+ session.getUserAttributesDao().insert(attributes);
+
+// sort order is important, so we re-fetch from the db
+// userAttributes.add(attributes);
+ user.resetUserAttributesList();
+ }
+
+ private static void invalidateUserAttributes(UserAttributes userAttributes, Calendar now, DaoSession session) {
+ if (userAttributes != null) {
+ Calendar invalid = (Calendar) now.clone();
+ invalid.add(Calendar.MINUTE, -1);
+ userAttributes.setValidToUTC(invalid.getTime());
+ session.getUserAttributesDao().update(userAttributes);
+ }
+ }
+
+ private static boolean hasUpToDateUserAttributes(List<UserAttributes> userAttributes, ActivityUser prefsUser, UserAttributes[] outPreviousUserAttributes) {
+ for (UserAttributes attr : userAttributes) {
+ if (!isValidNow(attr)) {
+ continue;
+ }
+ if (isEqual(attr, prefsUser)) {
+ return true;
+ } else {
+ outPreviousUserAttributes[0] = attr;
+ }
+ }
+ return false;
+ }
+
+ // TODO: move this into db queries?
+ private static boolean isValidNow(ValidByDate element) {
+ Calendar cal = DateTimeUtils.getCalendarUTC();
+ Date nowUTC = cal.getTime();
+ return isValid(element, nowUTC);
+ }
+
+ private static boolean isValid(ValidByDate element, Date nowUTC) {
+ Date validFromUTC = element.getValidFromUTC();
+ Date validToUTC = element.getValidToUTC();
+ if (nowUTC.before(validFromUTC)) {
+ return false;
+ }
+ if (validToUTC != null && nowUTC.after(validToUTC)) {
+ return false;
+ }
+ return true;
+ }
+
+ private static boolean isEqual(UserAttributes attr, ActivityUser prefsUser) {
+ if (prefsUser.getHeightCm() != attr.getHeightCM()) {
+ LOG.info("user height changed to " + prefsUser.getHeightCm() + " from " + attr.getHeightCM());
+ return false;
+ }
+ if (prefsUser.getWeightKg() != attr.getWeightKG()) {
+ LOG.info("user changed to " + prefsUser.getWeightKg() + " from " + attr.getWeightKG());
+ return false;
+ }
+ if (!Integer.valueOf(prefsUser.getSleepDurationGoal()).equals(attr.getSleepGoalHPD())) {
+ LOG.info("user sleep goal changed to " + prefsUser.getSleepDurationGoal() + " from " + attr.getSleepGoalHPD());
+ return false;
+ }
+ if (!Integer.valueOf(prefsUser.getStepsGoal()).equals(attr.getStepsGoalSPD())) {
+ LOG.info("user steps goal changed to " + prefsUser.getStepsGoal() + " from " + attr.getStepsGoalSPD());
+ return false;
+ }
+ return true;
+ }
+
+ private static boolean isEqual(DeviceAttributes attr, GBDevice gbDevice) {
+ if (!Objects.equals(attr.getFirmwareVersion1(), gbDevice.getFirmwareVersion())) {
+ return false;
+ }
+ if (!Objects.equals(attr.getFirmwareVersion2(), gbDevice.getFirmwareVersion2())) {
+ return false;
+ }
+ if (!Objects.equals(attr.getVolatileIdentifier(), gbDevice.getVolatileAddress())) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Finds the corresponding Device entity for the given GBDevice.
+ * @param gbDevice
+ * @param session
+ * @return the corresponding Device entity, or null if none
+ */
+ @Nullable
+ public static Device findDevice(GBDevice gbDevice, DaoSession session) {
+ DeviceDao deviceDao = session.getDeviceDao();
+ Query<Device> query = deviceDao.queryBuilder().where(DeviceDao.Properties.Identifier.eq(gbDevice.getAddress())).build();
+ List<Device> devices = query.list();
+ if (devices.size() > 0) {
+ return devices.get(0);
+ }
+ return null;
+ }
+
+ public static void updateDeviceMacAddress(final DaoSession session, final String oldAddress, final String newAddress) {
+ final DeviceDao deviceDao = session.getDeviceDao();
+ final Query<Device> query = deviceDao.queryBuilder().where(DeviceDao.Properties.Identifier.eq(oldAddress)).build();
+ final List<Device> devices = query.list();
+ if (devices.isEmpty()) {
+ LOG.warn("Failed to find device with address {}", oldAddress);
+ return;
+ }
+
+ final Device device = devices.get(0);
+ device.setIdentifier(newAddress);
+ session.getDeviceDao().update(device);
+ }
+
+ /**
+ * Returns all active (that is, not old, archived ones) from the database.
+ * (currently the active handling is not available)
+ * @param daoSession
+ */
+ public static List<Device> getActiveDevices(DaoSession daoSession) {
+ return daoSession.getDeviceDao().loadAll();
+ }
+
+ /**
+ * Looks up in the database the Device entity corresponding to the GBDevice. If a device
+ * exists already, it will be updated with the current preferences values. If no device exists
+ * yet, it will be created in the database.
+ *
+ * @param session
+ * @return the device entity corresponding to the given GBDevice
+ */
+ public static Device getDevice(GBDevice gbDevice, DaoSession session) {
+ Device device = findDevice(gbDevice, session);
+ if (device == null) {
+ device = createDevice(gbDevice, session);
+ } else {
+ ensureDeviceUpToDate(device, gbDevice, session);
+ }
+ if (gbDevice.isInitialized()) {
+ ensureDeviceAttributes(device, gbDevice, session);
+ }
+
+ return device;
+ }
+
+ @NonNull
+ public static DeviceAttributes getDeviceAttributes(Device device) {
+ List<DeviceAttributes> list = device.getDeviceAttributesList();
+ if (list.isEmpty()) {
+ throw new IllegalStateException("device has no attributes");
+ }
+ return list.get(0);
+ }
+
+ private static void ensureDeviceUpToDate(Device device, GBDevice gbDevice, DaoSession session) {
+ if (!isDeviceUpToDate(device, gbDevice)) {
+ device.setIdentifier(gbDevice.getAddress());
+ device.setName(gbDevice.getName());
+ device.setAlias(gbDevice.getAlias());
+ DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
+ device.setManufacturer(coordinator.getManufacturer());
+ device.setTypeName(gbDevice.getType().name());
+ device.setModel(gbDevice.getModel());
+
+ if (device.getId() == null) {
+ session.getDeviceDao().insert(device);
+ } else {
+ session.getDeviceDao().update(device);
+ }
+ }
+ }
+
+ private static boolean isDeviceUpToDate(Device device, GBDevice gbDevice) {
+ if (!Objects.equals(device.getIdentifier(), gbDevice.getAddress())) {
+ return false;
+ }
+ if (!Objects.equals(device.getName(), gbDevice.getName())) {
+ return false;
+ }
+ if (!Objects.equals(device.getAlias(), gbDevice.getAlias())) {
+ return false;
+ }
+ DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
+ if (!Objects.equals(device.getManufacturer(), coordinator.getManufacturer())) {
+ return false;
+ }
+ if(!gbDevice.getType().name().equals(device.getTypeName())){
+ return false;
+ }
+ if (!Objects.equals(device.getModel(), gbDevice.getModel())) {
+ return false;
+ }
+ return true;
+ }
+
+ private static Device createDevice(GBDevice gbDevice, DaoSession session) {
+ Device device = new Device();
+ ensureDeviceUpToDate(device, gbDevice, session);
+
+ return device;
+ }
+
+ private static void ensureDeviceAttributes(Device device, GBDevice gbDevice, DaoSession session) {
+ List<DeviceAttributes> deviceAttributes = device.getDeviceAttributesList();
+ DeviceAttributes[] previousDeviceAttributes = new DeviceAttributes[1];
+ if (hasUpToDateDeviceAttributes(deviceAttributes, gbDevice, previousDeviceAttributes)) {
+ return;
+ }
+
+ Calendar now = DateTimeUtils.getCalendarUTC();
+ invalidateDeviceAttributes(previousDeviceAttributes[0], now, session);
+
+ DeviceAttributes attributes = new DeviceAttributes();
+ attributes.setDeviceId(device.getId());
+ attributes.setValidFromUTC(now.getTime());
+ attributes.setFirmwareVersion1(gbDevice.getFirmwareVersion());
+ attributes.setFirmwareVersion2(gbDevice.getFirmwareVersion2());
+ attributes.setVolatileIdentifier(gbDevice.getVolatileAddress());
+ DeviceAttributesDao attributesDao = session.getDeviceAttributesDao();
+ attributesDao.insert(attributes);
+
+// sort order is important, so we re-fetch from the db
+// deviceAttributes.add(attributes);
+ device.resetDeviceAttributesList();
+ }
+
+ private static void invalidateDeviceAttributes(DeviceAttributes deviceAttributes, Calendar now, DaoSession session) {
+ if (deviceAttributes != null) {
+ Calendar invalid = (Calendar) now.clone();
+ invalid.add(Calendar.MINUTE, -1);
+ deviceAttributes.setValidToUTC(invalid.getTime());
+ session.getDeviceAttributesDao().update(deviceAttributes);
+ }
+ }
+
+ private static boolean hasUpToDateDeviceAttributes(List<DeviceAttributes> deviceAttributes, GBDevice gbDevice, DeviceAttributes[] outPreviousAttributes) {
+ for (DeviceAttributes attr : deviceAttributes) {
+ if (!isValidNow(attr)) {
+ continue;
+ }
+ if (isEqual(attr, gbDevice)) {
+ return true;
+ } else {
+ outPreviousAttributes[0] = attr;
+ }
+ }
+ return false;
+ }
+
+ @NonNull
+ public static List<ActivityDescription> findActivityDecriptions(@NonNull User user, int tsFrom, int tsTo, @NonNull DaoSession session) {
+ Property tsFromProperty = ActivityDescriptionDao.Properties.TimestampFrom;
+ Property tsToProperty = ActivityDescriptionDao.Properties.TimestampTo;
+ Property userIdProperty = ActivityDescriptionDao.Properties.UserId;
+ QueryBuilder<ActivityDescription> qb = session.getActivityDescriptionDao().queryBuilder();
+ qb.where(userIdProperty.eq(user.getId()), isAtLeastPartiallyInRange(qb, tsFromProperty, tsToProperty, tsFrom, tsTo));
+ List<ActivityDescription> descriptions = qb.build().list();
+ return descriptions;
+ }
+
+ /**
+ * Returns a condition that matches when the range of the entity (tsFromProperty..tsToProperty)
+ * is completely or partially inside the range tsFrom..tsTo.
+ * @param qb the query builder to use
+ * @param tsFromProperty the property indicating the start of the entity's range
+ * @param tsToProperty the property indicating the end of the entity's range
+ * @param tsFrom the timestamp indicating the start of the range to match
+ * @param tsTo the timestamp indicating the end of the range to match
+ * @param <T> the query builder's type parameter
+ * @return the range WhereCondition
+ */
+ private static <T> WhereCondition isAtLeastPartiallyInRange(QueryBuilder<T> qb, Property tsFromProperty, Property tsToProperty, int tsFrom, int tsTo) {
+ return qb.and(tsFromProperty.lt(tsTo), tsToProperty.gt(tsFrom));
+ }
+
+ @NonNull
+ public static ActivityDescription createActivityDescription(@NonNull User user, int tsFrom, int tsTo, @NonNull DaoSession session) {
+ ActivityDescription desc = new ActivityDescription();
+ desc.setUser(user);
+ desc.setTimestampFrom(tsFrom);
+ desc.setTimestampTo(tsTo);
+ session.getActivityDescriptionDao().insertOrReplace(desc);
+
+ return desc;
+ }
+
+ @NonNull
+ public static Tag getTag(@NonNull User user, @NonNull String name, @NonNull DaoSession session) {
+ TagDao tagDao = session.getTagDao();
+ QueryBuilder<Tag> qb = tagDao.queryBuilder();
+ Query<Tag> query = qb.where(TagDao.Properties.UserId.eq(user.getId()), TagDao.Properties.Name.eq(name)).build();
+ List<Tag> tags = query.list();
+ if (tags.size() > 0) {
+ return tags.get(0);
+ }
+ return createTag(user, name, null, session);
+ }
+
+ static Tag createTag(@NonNull User user, @NonNull String name, @Nullable String description, @NonNull DaoSession session) {
+ Tag tag = new Tag();
+ tag.setUserId(user.getId());
+ tag.setName(name);
+ tag.setDescription(description);
+ session.getTagDao().insertOrReplace(tag);
+ return tag;
+ }
+
+ /**
+ * Returns all user-configurable alarms for the given user and device. The list is sorted by
+ * {@link Alarm#position}. Calendar events that may also be modeled as alarms are not stored
+ * in the database and hence not returned by this method.
+ * @param gbDevice the device for which the alarms shall be loaded
+ * @return the list of alarms for the given device
+ */
+ @NonNull
+ public static List<Alarm> getAlarms(@NonNull GBDevice gbDevice) {
+ DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
+ GBPrefs prefs = new GBPrefs(new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())));
+
+ int reservedSlots = prefs.getInt(DeviceSettingsPreferenceConst.PREF_RESERVER_ALARMS_CALENDAR, 0);
+ int alarmSlots = coordinator.getAlarmSlotCount(gbDevice);
+
+ try (DBHandler db = GBApplication.acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ User user = getUser(daoSession);
+ Device dbDevice = DBHelper.findDevice(gbDevice, daoSession);
+ if (dbDevice != null) {
+ AlarmDao alarmDao = daoSession.getAlarmDao();
+ Long deviceId = dbDevice.getId();
+ QueryBuilder<Alarm> qb = alarmDao.queryBuilder();
+ qb.where(
+ AlarmDao.Properties.UserId.eq(user.getId()),
+ AlarmDao.Properties.DeviceId.eq(deviceId)).orderAsc(AlarmDao.Properties.Position).limit(alarmSlots - reservedSlots);
+ return qb.build().list();
+ }
+ } catch (Exception e) {
+ LOG.warn("Error reading alarms from db", e);
+ }
+ return Collections.emptyList();
+ }
+
+ public static void store(Alarm alarm) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ DaoSession daoSession = db.getDaoSession();
+ daoSession.insertOrReplace(alarm);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database", e);
+ }
+ }
+
+ /**
+ * Returns all user-configurable reminders for the given user and device. The list is sorted by
+ * {@link Reminder#getDate}. Calendar events that may also be modeled as reminders are not stored
+ * in the database and hence not returned by this method.
+ * @param gbDevice the device for which the alarms shall be loaded
+ * @return the list of reminders for the given device
+ */
+ @NonNull
+ public static List<Reminder> getReminders(@NonNull GBDevice gbDevice) {
+ final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
+
+ final int reservedSlots = GBApplication.getDevicePrefs(gbDevice).getReservedReminderCalendarSlots();
+ final int reminderSlots = coordinator.getReminderSlotCount(gbDevice);
+
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final User user = getUser(daoSession);
+ final Device dbDevice = DBHelper.findDevice(gbDevice, daoSession);
+ if (dbDevice != null) {
+ final ReminderDao reminderDao = daoSession.getReminderDao();
+ final Long deviceId = dbDevice.getId();
+ final QueryBuilder<Reminder> qb = reminderDao.queryBuilder();
+ qb.where(
+ ReminderDao.Properties.UserId.eq(user.getId()),
+ ReminderDao.Properties.DeviceId.eq(deviceId)).orderAsc(ReminderDao.Properties.Date).limit(reminderSlots - reservedSlots);
+ return qb.build().list();
+ }
+ } catch (final Exception e) {
+ LOG.error("Error reading reminders from db", e);
+ }
+
+ return Collections.emptyList();
+ }
+
+ @NonNull
+ public static List<WorldClock> getWorldClocks(@NonNull GBDevice gbDevice) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final User user = getUser(daoSession);
+ final Device dbDevice = DBHelper.findDevice(gbDevice, daoSession);
+ if (dbDevice != null) {
+ final WorldClockDao worldClockDao = daoSession.getWorldClockDao();
+ final Long deviceId = dbDevice.getId();
+ final QueryBuilder<WorldClock> qb = worldClockDao.queryBuilder();
+ qb.where(
+ WorldClockDao.Properties.UserId.eq(user.getId()),
+ WorldClockDao.Properties.DeviceId.eq(deviceId)).orderAsc(WorldClockDao.Properties.WorldClockId);
+ return qb.build().list();
+ }
+ } catch (final Exception e) {
+ LOG.error("Error reading world clocks from db", e);
+ }
+
+ return Collections.emptyList();
+ }
+
+ @NonNull
+ public static List<Contact> getContacts(@NonNull GBDevice gbDevice) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final User user = getUser(daoSession);
+ final Device dbDevice = DBHelper.findDevice(gbDevice, daoSession);
+ if (dbDevice != null) {
+ final ContactDao contactDao = daoSession.getContactDao();
+ final Long deviceId = dbDevice.getId();
+ final QueryBuilder<Contact> qb = contactDao.queryBuilder();
+ qb.where(
+ ContactDao.Properties.UserId.eq(user.getId()),
+ ContactDao.Properties.DeviceId.eq(deviceId)).orderAsc(ContactDao.Properties.Name);
+ return qb.build().list();
+ }
+ } catch (final Exception e) {
+ LOG.error("Error reading contacts from db", e);
+ }
+
+ return Collections.emptyList();
+ }
+
+ public static void store(final Reminder reminder) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ daoSession.insertOrReplace(reminder);
+ } catch (final Exception e) {
+ LOG.error("Error acquiring database", e);
+ }
+ }
+
+ public static void store(final WorldClock worldClock) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ daoSession.insertOrReplace(worldClock);
+ } catch (final Exception e) {
+ LOG.error("Error acquiring database", e);
+ }
+ }
+
+ public static void store(final Contact contact) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ daoSession.insertOrReplace(contact);
+ } catch (final Exception e) {
+ LOG.error("Error acquiring database", e);
+ }
+ }
+
+ public static void delete(final Reminder reminder) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ daoSession.delete(reminder);
+ } catch (final Exception e) {
+ LOG.error("Error acquiring database", e);
+ }
+ }
+
+ public static void delete(final WorldClock worldClock) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ daoSession.delete(worldClock);
+ } catch (final Exception e) {
+ LOG.error("Error acquiring database", e);
+ }
+ }
+
+ public static void delete(final Contact contact) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ daoSession.delete(contact);
+ } catch (final Exception e) {
+ LOG.error("Error acquiring database", e);
+ }
+ }
+
+ public static void clearSession() {
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ DaoSession session = dbHandler.getDaoSession();
+ session.clear();
+ } catch (Exception e) {
+ LOG.warn("Unable to acquire database to clear the session", e);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBOpenHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBOpenHelper.java
new file mode 100644
index 0000000..a958e00
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBOpenHelper.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.database;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.schema.SchemaMigration;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoMaster;
+
+public class DBOpenHelper extends DaoMaster.OpenHelper {
+ private final String updaterClassNamePrefix;
+ private final Context context;
+
+ public DBOpenHelper(Context context, String dbName, SQLiteDatabase.CursorFactory factory) {
+ super(context, dbName, factory);
+ updaterClassNamePrefix = dbName + "Update_";
+ this.context = context;
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ DaoMaster.createAllTables(db, true);
+ new SchemaMigration(updaterClassNamePrefix).onUpgrade(db, oldVersion, newVersion);
+ }
+
+ @Override
+ public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ DaoMaster.createAllTables(db, true);
+ new SchemaMigration(updaterClassNamePrefix).onDowngrade(db, oldVersion, newVersion);
+ }
+
+ public Context getContext() {
+ return context;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBUpdateScript.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBUpdateScript.java
new file mode 100644
index 0000000..3dc3940
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBUpdateScript.java
@@ -0,0 +1,33 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.database;
+
+import android.database.sqlite.SQLiteDatabase;
+
+/**
+ * Interface for updating a database schema.
+ * Implementors provide the update from the prior schema
+ * version to this version, and the downgrade from this schema
+ * version to the next lower version.
+ * <p/>
+ * Implementations must have a public, no-arg constructor.
+ */
+public interface DBUpdateScript {
+ void upgradeSchema(SQLiteDatabase database);
+
+ void downgradeSchema(SQLiteDatabase database);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicExporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicExporter.java
new file mode 100644
index 0000000..31eb33c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/PeriodicExporter.java
@@ -0,0 +1,149 @@
+/* Copyright (C) 2018-2024 Carsten Pfeiffer, Felix Konstantin Maurer,
+ Ganblejs, Jos茅 Rebelo, Petr Van臎k
+
+ 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.database;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.SystemClock;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.OutputStream;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+/**
+ * Created by maufl on 1/4/18.
+ */
+
+public class PeriodicExporter extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(PeriodicExporter.class);
+
+ public static final String ACTION_DATABASE_EXPORT_SUCCESS = "nodomain.freeyourgadget.gadgetbridge.action.DATABASE_EXPORT_SUCCESS";
+ public static final String ACTION_DATABASE_EXPORT_FAIL = "nodomain.freeyourgadget.gadgetbridge.action.DATABASE_EXPORT_FAIL";
+
+ public static void enablePeriodicExport(Context context) {
+ Prefs prefs = GBApplication.getPrefs();
+ GBApplication gbApp = GBApplication.app();
+ long autoExportScheduled = gbApp.getAutoExportScheduledTimestamp();
+ boolean autoExportEnabled = prefs.getBoolean(GBPrefs.AUTO_EXPORT_ENABLED, false);
+ Integer autoExportInterval = prefs.getInt(GBPrefs.AUTO_EXPORT_INTERVAL, 0);
+ scheduleAlarm(context, autoExportInterval, autoExportEnabled && autoExportScheduled == 0);
+ }
+
+ public static void scheduleAlarm(Context context, Integer autoExportInterval, boolean autoExportEnabled) {
+ Intent i = new Intent(context, PeriodicExporter.class);
+ i.setPackage(BuildConfig.APPLICATION_ID);
+ PendingIntent pi = PendingIntentUtils.getBroadcast(context, 0, i, 0, false);
+ AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+ am.cancel(pi);
+ if (!autoExportEnabled) {
+ LOG.info("Not scheduling periodic export, either already scheduled or not enabled");
+ return;
+ }
+ int exportPeriod = autoExportInterval * 60 * 60 * 1000;
+ if (exportPeriod == 0) {
+ LOG.info("Not scheduling periodic export, interval set to 0");
+ return;
+ }
+ LOG.info("Scheduling periodic export");
+ GBApplication gbApp = GBApplication.app();
+ gbApp.setAutoExportScheduledTimestamp(System.currentTimeMillis() + exportPeriod);
+ am.setInexactRepeating(
+ AlarmManager.ELAPSED_REALTIME,
+ SystemClock.elapsedRealtime() + exportPeriod,
+ exportPeriod,
+ pi
+ );
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ LOG.info("Received command to export DB");
+ createRefreshTask("Export database", context).execute();
+ }
+
+ protected RefreshTask createRefreshTask(String task, Context context) {
+ return new RefreshTask(task, context);
+ }
+
+ public class RefreshTask extends DBAccess {
+ Context localContext;
+
+ public RefreshTask(String task, Context context) {
+ super(task, context);
+ localContext = context;
+ }
+
+ @Override
+ protected void doInBackground(DBHandler handler) {
+ LOG.info("Exporting DB in a background thread");
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ DBHelper helper = new DBHelper(localContext);
+ String dst = GBApplication.getPrefs().getString(GBPrefs.AUTO_EXPORT_LOCATION, null);
+ if (dst == null) {
+ LOG.warn("Unable to export DB, export location not set");
+ broadcastSuccess(false);
+ return;
+ }
+ Uri dstUri = Uri.parse(dst);
+ try (OutputStream out = localContext.getContentResolver().openOutputStream(dstUri)) {
+ helper.exportDB(dbHandler, out);
+ GBApplication gbApp = GBApplication.app();
+ gbApp.setLastAutoExportTimestamp(System.currentTimeMillis());
+ }
+
+ broadcastSuccess(true);
+
+ LOG.info("DB export completed");
+ } catch (Exception ex) {
+ GB.updateExportFailedNotification(localContext.getString(R.string.notif_export_failed_title), localContext);
+ LOG.info("Exception while exporting DB: ", ex);
+ broadcastSuccess(false);
+ }
+ }
+
+ private void broadcastSuccess(final boolean success) {
+ if (!GBApplication.getPrefs().getBoolean("intent_api_broadcast_export", false)) {
+ return;
+ }
+
+ LOG.info("Broadcasting database export success={}", success);
+
+ final String action = success ? ACTION_DATABASE_EXPORT_SUCCESS : ACTION_DATABASE_EXPORT_FAIL;
+ final Intent exportedNotifyIntent = new Intent(action);
+ localContext.sendBroadcast(exportedNotifyIntent);
+ }
+
+ @Override
+ protected void onPostExecute(Object o) {
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_14.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_14.java
new file mode 100644
index 0000000..6eef4ea
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_14.java
@@ -0,0 +1,42 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleHealthActivitySampleDao;
+
+/*
+ * adds heart rate column to health table
+ */
+
+public class GadgetbridgeUpdate_14 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(PebbleHealthActivitySampleDao.TABLENAME, PebbleHealthActivitySampleDao.Properties.HeartRate.columnName, db)) {
+ String ADD_COLUMN_HEART_RATE = "ALTER TABLE " + PebbleHealthActivitySampleDao.TABLENAME + " ADD COLUMN "
+ + PebbleHealthActivitySampleDao.Properties.HeartRate.columnName + " INTEGER NOT NULL DEFAULT 0;";
+ db.execSQL(ADD_COLUMN_HEART_RATE);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_15.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_15.java
new file mode 100644
index 0000000..5a50ec9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_15.java
@@ -0,0 +1,42 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.DeviceAttributesDao;
+
+/*
+ * adds heart rate column to health table
+ */
+
+public class GadgetbridgeUpdate_15 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(DeviceAttributesDao.TABLENAME, DeviceAttributesDao.Properties.VolatileIdentifier.columnName, db)) {
+ String ADD_COLUMN_VOLATILE_IDENTIFIER = "ALTER TABLE " + DeviceAttributesDao.TABLENAME + " ADD COLUMN "
+ + DeviceAttributesDao.Properties.VolatileIdentifier.columnName + " TEXT;";
+ db.execSQL(ADD_COLUMN_VOLATILE_IDENTIFIER);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_17.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_17.java
new file mode 100644
index 0000000..9e50ead
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_17.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2017-2024 protomors
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.No1F1ActivitySampleDao;
+
+public class GadgetbridgeUpdate_17 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(No1F1ActivitySampleDao.TABLENAME, No1F1ActivitySampleDao.Properties.HeartRate.columnName, db)) {
+ String ADD_COLUMN_HEART_RATE = "ALTER TABLE " + No1F1ActivitySampleDao.TABLENAME + " ADD COLUMN "
+ + No1F1ActivitySampleDao.Properties.HeartRate.columnName + " INTEGER NOT NULL DEFAULT 0;";
+ db.execSQL(ADD_COLUMN_HEART_RATE);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_22.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_22.java
new file mode 100644
index 0000000..c740c4f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_22.java
@@ -0,0 +1,39 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.AlarmDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.No1F1ActivitySampleDao;
+
+public class GadgetbridgeUpdate_22 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(AlarmDao.TABLENAME, AlarmDao.Properties.Unused.columnName, db)) {
+ String ADD_COLUMN_UNUSED = "ALTER TABLE " + AlarmDao.TABLENAME + " ADD COLUMN "
+ + AlarmDao.Properties.Unused.columnName + " INTEGER NOT NULL DEFAULT 0;";
+ db.execSQL(ADD_COLUMN_UNUSED);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_23.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_23.java
new file mode 100644
index 0000000..79b88ac
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_23.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2020-2024 Dmitry Markin
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.AlarmDao;
+
+public class GadgetbridgeUpdate_23 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(AlarmDao.TABLENAME, AlarmDao.Properties.Snooze.columnName, db)) {
+ // Setting default value of SNOOZE column to 1 (true), so that existing MiBand2 alarms
+ // behave as before
+ String ADD_COLUMN_SNOOZE = "ALTER TABLE " + AlarmDao.TABLENAME + " ADD COLUMN "
+ + AlarmDao.Properties.Snooze.columnName + " INTEGER NOT NULL DEFAULT 1;";
+ db.execSQL(ADD_COLUMN_SNOOZE);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_24.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_24.java
new file mode 100644
index 0000000..420cd2c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_24.java
@@ -0,0 +1,35 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.AlarmDao;
+
+public class GadgetbridgeUpdate_24 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ // Probably it isbetter to hardcode then using constants since constants could change after refactoring and potentially break migration
+ db.execSQL("UPDATE DEVICE SET Type=170 where (Type=80 and Manufacturer like 'Fossil%')");
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_26.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_26.java
new file mode 100644
index 0000000..0ef3682
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_26.java
@@ -0,0 +1,43 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.AlarmDao;
+
+public class GadgetbridgeUpdate_26 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(AlarmDao.TABLENAME, AlarmDao.Properties.Title.columnName, db)) {
+ String ADD_COLUMN_TITLE = "ALTER TABLE " + AlarmDao.TABLENAME + " ADD COLUMN "
+ + AlarmDao.Properties.Title.columnName + " TEXT";
+ db.execSQL(ADD_COLUMN_TITLE);
+ }
+ if (!DBHelper.existsColumn(AlarmDao.TABLENAME, AlarmDao.Properties.Description.columnName, db)) {
+ String ADD_COLUMN_TITLE = "ALTER TABLE " + AlarmDao.TABLENAME + " ADD COLUMN "
+ + AlarmDao.Properties.Description.columnName + " TEXT";
+ db.execSQL(ADD_COLUMN_TITLE);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_27.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_27.java
new file mode 100644
index 0000000..00cf6e4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_27.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.DeviceDao;
+
+public class GadgetbridgeUpdate_27 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(DeviceDao.TABLENAME, DeviceDao.Properties.Alias.columnName, db)) {
+ String ADD_COLUMN_ALIAS = "ALTER TABLE " + DeviceDao.TABLENAME + " ADD COLUMN "
+ + DeviceDao.Properties.Alias.columnName + " TEXT";
+ db.execSQL(ADD_COLUMN_ALIAS);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_29.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_29.java
new file mode 100644
index 0000000..711dea4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_29.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
+
+public class GadgetbridgeUpdate_29 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(BaseActivitySummaryDao.TABLENAME, BaseActivitySummaryDao.Properties.SummaryData.columnName, db)) {
+ String ADD_COLUMN_SUMMARY_DATA = "ALTER TABLE " + BaseActivitySummaryDao.TABLENAME + " ADD COLUMN "
+ + BaseActivitySummaryDao.Properties.SummaryData.columnName + " TEXT";
+ db.execSQL(ADD_COLUMN_SUMMARY_DATA);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_30.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_30.java
new file mode 100644
index 0000000..195ae3a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_30.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
+
+public class GadgetbridgeUpdate_30 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(BaseActivitySummaryDao.TABLENAME, BaseActivitySummaryDao.Properties.RawSummaryData.columnName, db)) {
+ String ADD_COLUMN_RAW_SUMMARY_DATA = "ALTER TABLE " + BaseActivitySummaryDao.TABLENAME + " ADD COLUMN "
+ + BaseActivitySummaryDao.Properties.RawSummaryData.columnName + " BLOB";
+ db.execSQL(ADD_COLUMN_RAW_SUMMARY_DATA);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_35.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_35.java
new file mode 100644
index 0000000..5253d90
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_35.java
@@ -0,0 +1,54 @@
+/* Copyright (C) 2021-2024 Petr Van臎k
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.BatteryLevelDao;
+
+public class GadgetbridgeUpdate_35 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(BatteryLevelDao.TABLENAME, BatteryLevelDao.Properties.BatteryIndex.columnName, db)) {
+ String MOVE_DATA_TO_TEMP_TABLE = "ALTER TABLE battery_level RENAME TO battery_levels_temp;";
+ db.execSQL(MOVE_DATA_TO_TEMP_TABLE);
+
+ String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS \"BATTERY_LEVEL\" (\"TIMESTAMP\" INTEGER NOT NULL ," +
+ "\"DEVICE_ID\" INTEGER NOT NULL ,\"LEVEL\" INTEGER NOT NULL ,\"BATTERY_INDEX\" INTEGER NOT NULL ," +
+ "PRIMARY KEY (\"TIMESTAMP\" ,\"DEVICE_ID\" ,\"BATTERY_INDEX\" ) ON CONFLICT REPLACE) WITHOUT ROWID;";
+ db.execSQL(CREATE_TABLE);
+
+ String MIGATE_DATA = "insert into " + BatteryLevelDao.TABLENAME
+ + " (" + BatteryLevelDao.Properties.Timestamp.columnName + ","
+ + BatteryLevelDao.Properties.DeviceId.columnName + ","
+ + BatteryLevelDao.Properties.BatteryIndex.columnName + ","
+ + BatteryLevelDao.Properties.Level.columnName + ") "
+ + " select Timestamp, Device_ID, 0, Level from battery_levels_temp;";
+ db.execSQL(MIGATE_DATA);
+
+ String DROP_TEMP_TABLE = "drop table if exists battery_levels_temp";
+ db.execSQL(DROP_TEMP_TABLE);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_42.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_42.java
new file mode 100644
index 0000000..29dfe43
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_42.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.DeviceDao;
+
+public class GadgetbridgeUpdate_42 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(DeviceDao.TABLENAME, DeviceDao.Properties.ParentFolder.columnName, db)) {
+ String ADD_COLUMN_CPONTAINED_FOLDER = "ALTER TABLE " + DeviceDao.TABLENAME + " ADD COLUMN "
+ + DeviceDao.Properties.ParentFolder.columnName + " TEXT";
+ db.execSQL(ADD_COLUMN_CPONTAINED_FOLDER);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_44.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_44.java
new file mode 100644
index 0000000..76c11df
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_44.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
+
+public class GadgetbridgeUpdate_44 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(BaseActivitySummaryDao.TABLENAME, BaseActivitySummaryDao.Properties.RawDetailsPath.columnName, db)) {
+ final String statement = "ALTER TABLE " + BaseActivitySummaryDao.TABLENAME + " ADD COLUMN "
+ + BaseActivitySummaryDao.Properties.RawDetailsPath.columnName + " TEXT";
+ db.execSQL(statement);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_45.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_45.java
new file mode 100644
index 0000000..465ef36
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_45.java
@@ -0,0 +1,44 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.WorldClockDao;
+
+public class GadgetbridgeUpdate_45 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(WorldClockDao.TABLENAME, WorldClockDao.Properties.Code.columnName, db)) {
+ final String statement = "ALTER TABLE " + WorldClockDao.TABLENAME + " ADD COLUMN "
+ + WorldClockDao.Properties.Code.columnName + " TEXT";
+ db.execSQL(statement);
+ }
+
+ if (!DBHelper.existsColumn(WorldClockDao.TABLENAME, WorldClockDao.Properties.Enabled.columnName, db)) {
+ final String statement = "ALTER TABLE " + WorldClockDao.TABLENAME + " ADD COLUMN "
+ + WorldClockDao.Properties.Enabled.columnName + " BOOLEAN DEFAULT TRUE";
+ db.execSQL(statement);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_51.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_51.java
new file mode 100644
index 0000000..557e1f1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_51.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.BangleJSActivitySampleDao;
+
+public class GadgetbridgeUpdate_51 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(BangleJSActivitySampleDao.TABLENAME, BangleJSActivitySampleDao.Properties.RawIntensity.columnName, db)) {
+ final String sqlAddColumnRawIntensity = "ALTER TABLE " + BangleJSActivitySampleDao.TABLENAME + " ADD COLUMN "
+ + BangleJSActivitySampleDao.Properties.RawIntensity.columnName + " INTEGER DEFAULT -1 NOT NULL";
+ db.execSQL(sqlAddColumnRawIntensity);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_62.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_62.java
new file mode 100644
index 0000000..9ec2d78
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_62.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.DeviceDao;
+
+public class GadgetbridgeUpdate_62 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(DeviceDao.TABLENAME, DeviceDao.Properties.TypeName.columnName, db)) {
+ String ADD_COLUMN_CPONTAINED_FOLDER = "ALTER TABLE " + DeviceDao.TABLENAME + " ADD COLUMN "
+ + DeviceDao.Properties.TypeName.columnName + " TEXT NOT NULL DEFAULT \"\"";
+ db.execSQL(ADD_COLUMN_CPONTAINED_FOLDER);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_66.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_66.java
new file mode 100644
index 0000000..7aa15a0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_66.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiSleepTimeSampleDao;
+
+public class GadgetbridgeUpdate_66 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ final List<String> newColumns = Arrays.asList(
+ XiaomiSleepTimeSampleDao.Properties.TotalDuration.columnName,
+ XiaomiSleepTimeSampleDao.Properties.DeepSleepDuration.columnName,
+ XiaomiSleepTimeSampleDao.Properties.LightSleepDuration.columnName,
+ XiaomiSleepTimeSampleDao.Properties.RemSleepDuration.columnName,
+ XiaomiSleepTimeSampleDao.Properties.AwakeDuration.columnName
+ );
+
+ for (final String newColumn : newColumns) {
+ if (!DBHelper.existsColumn(XiaomiSleepTimeSampleDao.TABLENAME, newColumn, db)) {
+ final String SQL_ALTER_TABLE = String.format(
+ Locale.ROOT,
+ "ALTER TABLE %s ADD COLUMN %s INTEGER",
+ XiaomiSleepTimeSampleDao.TABLENAME,
+ newColumn
+ );
+ db.execSQL(SQL_ALTER_TABLE);
+ }
+ }
+ }
+
+ @Override
+ public void downgradeSchema(SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_71.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_71.java
new file mode 100644
index 0000000..384242b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_71.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.AlarmDao;
+
+public class GadgetbridgeUpdate_71 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(AlarmDao.TABLENAME, AlarmDao.Properties.SmartWakeupInterval.columnName, db)) {
+ final String statement = "ALTER TABLE " + AlarmDao.TABLENAME + " ADD COLUMN \""
+ + AlarmDao.Properties.SmartWakeupInterval.columnName + "\" INTEGER";
+ db.execSQL(statement);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_72.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_72.java
new file mode 100644
index 0000000..21bc21d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_72.java
@@ -0,0 +1,43 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutDataSampleDao;
+
+public class GadgetbridgeUpdate_72 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(HuaweiWorkoutDataSampleDao.TABLENAME, HuaweiWorkoutDataSampleDao.Properties.Calories.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutDataSampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutDataSampleDao.Properties.Calories.columnName + "\" INTEGER NOT NULL DEFAULT -1";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutDataSampleDao.TABLENAME, HuaweiWorkoutDataSampleDao.Properties.CyclingPower.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutDataSampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutDataSampleDao.Properties.CyclingPower.columnName + "\" INTEGER NOT NULL DEFAULT -1";
+ db.execSQL(statement);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_73.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_73.java
new file mode 100644
index 0000000..fd486c1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_73.java
@@ -0,0 +1,43 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutDataSampleDao;
+
+public class GadgetbridgeUpdate_73 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(HuaweiWorkoutDataSampleDao.TABLENAME, HuaweiWorkoutDataSampleDao.Properties.Frequency.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutDataSampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutDataSampleDao.Properties.Frequency.columnName + "\" INTEGER NOT NULL DEFAULT -1";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutDataSampleDao.TABLENAME, HuaweiWorkoutDataSampleDao.Properties.Altitude.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutDataSampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutDataSampleDao.Properties.Altitude.columnName + "\" INTEGER DEFAULT NULL";
+ db.execSQL(statement);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_79.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_79.java
new file mode 100644
index 0000000..4663b61
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_79.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySampleDao;
+
+public class GadgetbridgeUpdate_79 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.GpxFileLocation.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.GpxFileLocation.columnName + "\" TEXT";
+ db.execSQL(statement);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_80.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_80.java
new file mode 100644
index 0000000..077dcd2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_80.java
@@ -0,0 +1,44 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySampleDao;
+
+public class GadgetbridgeUpdate_80 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(GarminActivitySampleDao.TABLENAME, GarminActivitySampleDao.Properties.DistanceCm.columnName, db)) {
+ final String statement = "ALTER TABLE " + GarminActivitySampleDao.TABLENAME + " ADD COLUMN \""
+ + GarminActivitySampleDao.Properties.DistanceCm.columnName + "\" INTEGER NOT NULL DEFAULT -1";
+ db.execSQL(statement);
+ }
+
+ if (!DBHelper.existsColumn(GarminActivitySampleDao.TABLENAME, GarminActivitySampleDao.Properties.ActiveCalories.columnName, db)) {
+ final String statement = "ALTER TABLE " + GarminActivitySampleDao.TABLENAME + " ADD COLUMN \""
+ + GarminActivitySampleDao.Properties.ActiveCalories.columnName + "\" INTEGER NOT NULL DEFAULT -1";
+ db.execSQL(statement);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_83.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_83.java
new file mode 100644
index 0000000..17c995b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_83.java
@@ -0,0 +1,47 @@
+package nodomain.freeyourgadget.gadgetbridge.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutPaceSampleDao;
+
+public class GadgetbridgeUpdate_83 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(HuaweiWorkoutPaceSampleDao.TABLENAME, HuaweiWorkoutPaceSampleDao.Properties.PaceIndex.columnName, db) && !DBHelper.existsColumn(HuaweiWorkoutPaceSampleDao.TABLENAME, HuaweiWorkoutPaceSampleDao.Properties.PointIndex.columnName, db)) {
+ String MOVE_DATA_TO_TEMP_TABLE = "ALTER TABLE " + HuaweiWorkoutPaceSampleDao.TABLENAME + " RENAME TO " +HuaweiWorkoutPaceSampleDao.TABLENAME + "_temp;";
+ db.execSQL(MOVE_DATA_TO_TEMP_TABLE);
+
+ String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS \"" + HuaweiWorkoutPaceSampleDao.TABLENAME +
+ "\" (\""+HuaweiWorkoutPaceSampleDao.Properties.WorkoutId.columnName+"\" INTEGER NOT NULL ," +
+ "\""+ HuaweiWorkoutPaceSampleDao.Properties.PaceIndex.columnName +"\" INTEGER NOT NULL ," +
+ "\""+ HuaweiWorkoutPaceSampleDao.Properties.Distance.columnName +"\" INTEGER NOT NULL ," +
+ "\""+ HuaweiWorkoutPaceSampleDao.Properties.Type.columnName +"\" INTEGER NOT NULL ," +
+ "\""+ HuaweiWorkoutPaceSampleDao.Properties.Pace.columnName +"\" INTEGER NOT NULL ," +
+ "\""+ HuaweiWorkoutPaceSampleDao.Properties.PointIndex.columnName +"\" INTEGER NOT NULL ," +
+ "\""+ HuaweiWorkoutPaceSampleDao.Properties.Correction.columnName +"\" INTEGER ," +
+ "PRIMARY KEY (\""+HuaweiWorkoutPaceSampleDao.Properties.WorkoutId.columnName+"\" ,\""+ HuaweiWorkoutPaceSampleDao.Properties.PaceIndex.columnName +"\" ,\""+ HuaweiWorkoutPaceSampleDao.Properties.Distance.columnName +"\", \""+ HuaweiWorkoutPaceSampleDao.Properties.Type.columnName +"\") ON CONFLICT REPLACE) WITHOUT ROWID;";
+ db.execSQL(CREATE_TABLE);
+
+ String MIGATE_DATA = "INSERT INTO " + HuaweiWorkoutPaceSampleDao.TABLENAME
+ + " (" +HuaweiWorkoutPaceSampleDao.Properties.WorkoutId.columnName+ ","
+ + HuaweiWorkoutPaceSampleDao.Properties.PaceIndex.columnName + ","
+ + HuaweiWorkoutPaceSampleDao.Properties.Distance.columnName+ ","
+ + HuaweiWorkoutPaceSampleDao.Properties.Type.columnName + ","
+ + HuaweiWorkoutPaceSampleDao.Properties.Pace.columnName + ","
+ + HuaweiWorkoutPaceSampleDao.Properties.PointIndex.columnName + ","
+ + HuaweiWorkoutPaceSampleDao.Properties.Correction.columnName + ") "
+ + " SELECT WORKOUT_ID, -1, DISTANCE, TYPE, PACE, 0, CORRECTION FROM " +HuaweiWorkoutPaceSampleDao.TABLENAME + "_temp;";
+
+ db.execSQL(MIGATE_DATA);
+
+ String DROP_TEMP_TABLE = "drop table if exists " +HuaweiWorkoutPaceSampleDao.TABLENAME + "_temp;";
+ db.execSQL(DROP_TEMP_TABLE);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_84.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_84.java
new file mode 100644
index 0000000..22ffd9f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_84.java
@@ -0,0 +1,79 @@
+package nodomain.freeyourgadget.gadgetbridge.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySampleDao;
+
+public class GadgetbridgeUpdate_84 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.MaxAltitude.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.MaxAltitude.columnName + "\" INTEGER";
+ db.execSQL(statement);
+ }
+
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.MinAltitude.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.MinAltitude.columnName + "\" INTEGER";
+ db.execSQL(statement);
+ }
+
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.ElevationGain.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.ElevationGain.columnName + "\" INTEGER";
+ db.execSQL(statement);
+ }
+
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.ElevationLoss.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.ElevationLoss.columnName + "\" INTEGER";
+ db.execSQL(statement);
+ }
+
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.WorkoutLoad.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.WorkoutLoad.columnName + "\" INTEGER NOT NULL DEFAULT 0;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.WorkoutAerobicEffect.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.WorkoutAerobicEffect.columnName + "\" INTEGER NOT NULL DEFAULT 0;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.WorkoutAnaerobicEffect.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.WorkoutAnaerobicEffect.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RecoveryTime.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RecoveryTime.columnName + "\" INTEGER NOT NULL DEFAULT 0;";
+ db.execSQL(statement);
+ }
+
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.MinHeartRatePeak.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.MinHeartRatePeak.columnName + "\" INTEGER NOT NULL DEFAULT 0;";
+ db.execSQL(statement);
+ }
+
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.MaxHeartRatePeak.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.MaxHeartRatePeak.columnName + "\" INTEGER NOT NULL DEFAULT 0;";
+ db.execSQL(statement);
+ }
+
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RecoveryHeartRates.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RecoveryHeartRates.columnName + "\" BLOB";
+ db.execSQL(statement);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_85.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_85.java
new file mode 100644
index 0000000..7579729
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_85.java
@@ -0,0 +1,22 @@
+package nodomain.freeyourgadget.gadgetbridge.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySampleDao;
+
+public class GadgetbridgeUpdate_85 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.SwimType.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.SwimType.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_89.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_89.java
new file mode 100644
index 0000000..384e0a3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_89.java
@@ -0,0 +1,93 @@
+package nodomain.freeyourgadget.gadgetbridge.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySampleDao;
+
+public class GadgetbridgeUpdate_89 implements DBUpdateScript {
+ @Override
+ public void upgradeSchema(final SQLiteDatabase db) {
+
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.MaxMET.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.MaxMET.columnName + "\" INTEGER NOT NULL DEFAULT 0;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.HrZoneType.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.HrZoneType.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone1Min.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone1Min.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone2Min.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone2Min.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone3Min.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone3Min.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone4Min.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone4Min.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone5Min.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone5Min.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone5Max.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone5Max.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone1Time.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone1Time.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone2Time.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone2Time.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone3Time.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone3Time.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone4Time.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone4Time.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone5Time.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.RunPaceZone5Time.columnName + "\" INTEGER NOT NULL DEFAULT -1;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.AlgType.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.AlgType.columnName + "\" INTEGER NOT NULL DEFAULT 0;";
+ db.execSQL(statement);
+ }
+ if (!DBHelper.existsColumn(HuaweiWorkoutSummarySampleDao.TABLENAME, HuaweiWorkoutSummarySampleDao.Properties.TrainingPoints.columnName, db)) {
+ final String statement = "ALTER TABLE " + HuaweiWorkoutSummarySampleDao.TABLENAME + " ADD COLUMN \""
+ + HuaweiWorkoutSummarySampleDao.Properties.TrainingPoints.columnName + "\" INTEGER NOT NULL DEFAULT 0;";
+ db.execSQL(statement);
+ }
+ }
+
+ @Override
+ public void downgradeSchema(final SQLiteDatabase db) {
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/SchemaMigration.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/SchemaMigration.java
new file mode 100644
index 0000000..5eda0d6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/SchemaMigration.java
@@ -0,0 +1,80 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.database.schema;
+
+import android.database.sqlite.SQLiteDatabase;
+import android.widget.Toast;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class SchemaMigration {
+ private static final Logger LOG = LoggerFactory.getLogger(SchemaMigration.class);
+ private final String classNamePrefix;
+
+ public SchemaMigration(String updaterClassNamePrefix) {
+ classNamePrefix = updaterClassNamePrefix;
+ }
+
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ LOG.info("ActivityDatabase: schema upgrade requested from " + oldVersion + " to " + newVersion);
+ try {
+ for (int i = oldVersion + 1; i <= newVersion; i++) {
+ DBUpdateScript updater = getUpdateScript(db, i);
+ if (updater != null) {
+ LOG.info("upgrading activity database to version " + i);
+ updater.upgradeSchema(db);
+ }
+ }
+ LOG.info("activity database is now at version " + newVersion);
+ } catch (RuntimeException ex) {
+ GB.toast("Error upgrading database.", Toast.LENGTH_SHORT, GB.ERROR, ex);
+ throw ex; // reject upgrade
+ }
+ }
+
+ public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ LOG.info("ActivityDatabase: schema downgrade requested from " + oldVersion + " to " + newVersion);
+ try {
+ for (int i = oldVersion; i >= newVersion; i--) {
+ DBUpdateScript updater = getUpdateScript(db, i);
+ if (updater != null) {
+ LOG.info("downgrading activity database to version " + (i - 1));
+ updater.downgradeSchema(db);
+ }
+ }
+ LOG.info("activity database is now at version " + newVersion);
+ } catch (RuntimeException ex) {
+ GB.toast("Error downgrading database.", Toast.LENGTH_SHORT, GB.ERROR, ex);
+ throw ex; // reject downgrade
+ }
+ }
+
+ private DBUpdateScript getUpdateScript(SQLiteDatabase db, int version) {
+ try {
+ Class<?> updateClass = getClass().getClassLoader().loadClass(getClass().getPackage().getName() + "." + classNamePrefix + version);
+ return (DBUpdateScript) updateClass.newInstance();
+ } catch (ClassNotFoundException e) {
+ return null;
+ } catch (InstantiationException | IllegalAccessException e) {
+ throw new RuntimeException("Error instantiating DBUpdate class for version " + version, e);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEvent.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEvent.java
new file mode 100644
index 0000000..2c77fde
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEvent.java
@@ -0,0 +1,27 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti
+
+ 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.deviceevents;
+
+
+public abstract class GBDeviceEvent {
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + ": ";
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppInfo.java
new file mode 100644
index 0000000..afc4259
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppInfo.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.deviceevents;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+
+public class GBDeviceEventAppInfo extends GBDeviceEvent {
+ public GBDeviceApp apps[];
+ public byte freeSlot = -1;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppManagement.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppManagement.java
new file mode 100644
index 0000000..190edfa
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppManagement.java
@@ -0,0 +1,42 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa
+
+ 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.deviceevents;
+
+import java.util.UUID;
+
+public class GBDeviceEventAppManagement extends GBDeviceEvent {
+ public Event event = Event.UNKNOWN;
+ public EventType type = EventType.UNKNOWN;
+ public int token = -1;
+ public UUID uuid = null;
+
+ public enum EventType {
+ UNKNOWN,
+ INSTALL,
+ DELETE,
+ START,
+ STOP,
+ }
+
+ public enum Event {
+ UNKNOWN,
+ SUCCESS,
+ ACKNOWLEDGE,
+ FAILURE,
+ REQUEST,
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppMessage.java
new file mode 100644
index 0000000..0bab08d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventAppMessage.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.deviceevents;
+
+import java.util.UUID;
+
+public class GBDeviceEventAppMessage extends GBDeviceEvent {
+ public static int TYPE_APPMESSAGE = 0;
+ public static int TYPE_ACK = 1;
+ public static int TYPE_NACK = 2;
+
+ public int type;
+ public UUID appUUID;
+ public int id;
+ public String message;
+
+ @Override
+ public String toString() {
+ return "GBDeviceEventAppMessage{" +
+ "type=" + type +
+ ", appUUID=" + appUUID +
+ ", message='" + message + '\'' +
+ ", id=" + id +
+ '}';
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventBatteryInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventBatteryInfo.java
new file mode 100644
index 0000000..88376ee
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventBatteryInfo.java
@@ -0,0 +1,39 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Daniele Gobbetti, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.deviceevents;
+
+
+import java.util.GregorianCalendar;
+
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
+
+public class GBDeviceEventBatteryInfo extends GBDeviceEvent {
+ public GregorianCalendar lastChargeTime = null;
+ public BatteryState state = BatteryState.UNKNOWN;
+ public int batteryIndex = 0;
+ public int level = 50;
+ public int numCharges = -1;
+ public float voltage = -1f;
+
+ public boolean extendedInfoAvailable() {
+ if (numCharges != -1 && lastChargeTime != null) {
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java
new file mode 100644
index 0000000..596e1dd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2015-2024 Andreas B枚hler, Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+
+public class GBDeviceEventCallControl extends GBDeviceEvent {
+ public Event event = Event.UNKNOWN;
+
+ public GBDeviceEventCallControl() {
+ }
+
+ public GBDeviceEventCallControl(final Event event) {
+ this.event = event;
+ }
+
+ public enum Event {
+ UNKNOWN,
+ ACCEPT,
+ END,
+ INCOMING,
+ OUTGOING,
+ REJECT,
+ START,
+ IGNORE,
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCameraRemote.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCameraRemote.java
new file mode 100644
index 0000000..cf522dd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCameraRemote.java
@@ -0,0 +1,57 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.deviceevents;
+
+public class GBDeviceEventCameraRemote extends GBDeviceEvent {
+ public Event event = Event.UNKNOWN;
+
+ public enum Event {
+ UNKNOWN,
+ OPEN_CAMERA,
+ TAKE_PICTURE,
+ CLOSE_CAMERA,
+ EXCEPTION
+ }
+
+ static public int eventToInt(Event event) {
+ switch (event) {
+ case UNKNOWN:
+ return 0;
+ case OPEN_CAMERA:
+ return 1;
+ case TAKE_PICTURE:
+ return 2;
+ case CLOSE_CAMERA:
+ return 3;
+ }
+ return -1;
+ }
+
+ static public Event intToEvent(int event) {
+ switch (event) {
+ case 0:
+ return Event.UNKNOWN;
+ case 1:
+ return Event.OPEN_CAMERA;
+ case 2:
+ return Event.TAKE_PICTURE;
+ case 3:
+ return Event.CLOSE_CAMERA;
+ }
+ return Event.EXCEPTION;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventDisplayMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventDisplayMessage.java
new file mode 100644
index 0000000..1fbc87f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventDisplayMessage.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.deviceevents;
+
+public class GBDeviceEventDisplayMessage {
+ public String message;
+ public int duration;
+ public int severity;
+
+ /**
+ * An event for displaying a message to the user. How the message is displayed
+ * is a detail of the current activity, which needs to listen to the Intent
+ * GB.ACTION_DISPLAY_MESSAGE.
+ *
+ * @param message
+ * @param duration
+ * @param severity
+ */
+ public GBDeviceEventDisplayMessage(String message, int duration, int severity) {
+ this.message = message;
+ this.duration = duration;
+ this.severity = severity;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFindPhone.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFindPhone.java
new file mode 100644
index 0000000..92aeac6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFindPhone.java
@@ -0,0 +1,31 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+public class GBDeviceEventFindPhone extends GBDeviceEvent {
+ public Event event = Event.UNKNOWN;
+
+ public enum Event {
+ UNKNOWN,
+ START,
+ START_VIBRATE,
+ STOP,
+ VIBRATE,
+ RING,
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFmFrequency.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFmFrequency.java
new file mode 100644
index 0000000..7c02a46
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFmFrequency.java
@@ -0,0 +1,25 @@
+/* Copyright (C) 2018-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+public class GBDeviceEventFmFrequency extends GBDeviceEvent {
+ public final float frequency;
+
+ public GBDeviceEventFmFrequency(final float frequency) {
+ this.frequency = frequency;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventLEDColor.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventLEDColor.java
new file mode 100644
index 0000000..e476759
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventLEDColor.java
@@ -0,0 +1,25 @@
+/* Copyright (C) 2018-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+public class GBDeviceEventLEDColor extends GBDeviceEvent {
+ public final int color;
+
+ public GBDeviceEventLEDColor(final int color) {
+ this.color = color;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventMusicControl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventMusicControl.java
new file mode 100644
index 0000000..df9d118
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventMusicControl.java
@@ -0,0 +1,35 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Petr Van臎k
+
+ 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.deviceevents;
+
+
+public class GBDeviceEventMusicControl extends GBDeviceEvent {
+ public Event event = Event.UNKNOWN;
+
+ public enum Event {
+ UNKNOWN,
+ PLAY,
+ PAUSE,
+ PLAYPAUSE,
+ NEXT,
+ PREVIOUS,
+ VOLUMEUP,
+ VOLUMEDOWN,
+ FORWARD,
+ REWIND
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventNotificationControl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventNotificationControl.java
new file mode 100644
index 0000000..be05245
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventNotificationControl.java
@@ -0,0 +1,34 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Daniele Gobbetti
+
+ 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.deviceevents;
+
+public class GBDeviceEventNotificationControl extends GBDeviceEvent {
+ public long handle;
+ public String phoneNumber;
+ public String reply;
+ public String title;
+ public Event event = Event.UNKNOWN;
+
+ public enum Event {
+ UNKNOWN,
+ DISMISS,
+ DISMISS_ALL,
+ OPEN,
+ MUTE,
+ REPLY,
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventScreenshot.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventScreenshot.java
new file mode 100644
index 0000000..b67b9a5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventScreenshot.java
@@ -0,0 +1,29 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+public class GBDeviceEventScreenshot extends GBDeviceEvent {
+ private final byte[] data;
+
+ public GBDeviceEventScreenshot(final byte[] data) {
+ this.data = data;
+ }
+
+ public byte[] getData() {
+ return data;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSendBytes.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSendBytes.java
new file mode 100644
index 0000000..8a4243a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSendBytes.java
@@ -0,0 +1,28 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+public class GBDeviceEventSendBytes extends GBDeviceEvent {
+ public byte[] encodedBytes;
+
+ public GBDeviceEventSendBytes() {
+ }
+
+ public GBDeviceEventSendBytes(final byte[] encodedBytes) {
+ this.encodedBytes = encodedBytes;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSilentMode.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSilentMode.java
new file mode 100644
index 0000000..e057843
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSilentMode.java
@@ -0,0 +1,29 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+public class GBDeviceEventSilentMode extends GBDeviceEvent {
+ private final boolean enabled;
+
+ public GBDeviceEventSilentMode(final boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSleepStateDetection.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSleepStateDetection.java
new file mode 100644
index 0000000..b4fcdca
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventSleepStateDetection.java
@@ -0,0 +1,30 @@
+/* Copyright (C) 2023-2024 Yoran Vulker
+
+ 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.deviceevents;
+
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.model.SleepState;
+
+public class GBDeviceEventSleepStateDetection extends GBDeviceEvent {
+ public SleepState sleepState;
+
+ @Override
+ public String toString() {
+ return super.toString() + String.format(Locale.ROOT, "sleepState=%s", sleepState);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdateDeviceInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdateDeviceInfo.java
new file mode 100644
index 0000000..d1951c1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdateDeviceInfo.java
@@ -0,0 +1,32 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.model.ItemWithDetails;
+
+public class GBDeviceEventUpdateDeviceInfo extends GBDeviceEvent {
+ public ItemWithDetails item;
+
+ public GBDeviceEventUpdateDeviceInfo(final ItemWithDetails item) {
+ this.item = item;
+ }
+
+ public GBDeviceEventUpdateDeviceInfo(final String name, final String details) {
+ this(new GenericItem(name, details));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdateDeviceState.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdateDeviceState.java
new file mode 100644
index 0000000..572d556
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdateDeviceState.java
@@ -0,0 +1,27 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GBDeviceEventUpdateDeviceState extends GBDeviceEvent {
+ public GBDevice.State state;
+
+ public GBDeviceEventUpdateDeviceState(final GBDevice.State state) {
+ this.state = state;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java
new file mode 100644
index 0000000..8691c30
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java
@@ -0,0 +1,94 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+import android.content.SharedPreferences;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+public class GBDeviceEventUpdatePreferences extends GBDeviceEvent {
+ private static final Logger LOG = LoggerFactory.getLogger(GBDeviceEventUpdatePreferences.class);
+
+ public final Map<String, Object> preferences;
+
+ public GBDeviceEventUpdatePreferences() {
+ this.preferences = new HashMap<>();
+ }
+
+ public GBDeviceEventUpdatePreferences(final Map<String, Object> preferences) {
+ this.preferences = preferences;
+ }
+
+ public GBDeviceEventUpdatePreferences(final String key, final Object value) {
+ this.preferences = new HashMap<>();
+ this.preferences.put(key, value);
+ }
+
+ public GBDeviceEventUpdatePreferences withPreference(final String key, final Object value) {
+ this.preferences.put(key, value);
+
+ return this;
+ }
+
+ public GBDeviceEventUpdatePreferences withPreferences(final Map<String, Object> preferences) {
+ this.preferences.putAll(preferences);
+
+ return this;
+ }
+
+ /**
+ * Update a {@link SharedPreferences} instance with the preferences in the event.
+ *
+ * @param prefs the SharedPreferences object to update.
+ */
+ public void update(final SharedPreferences prefs) {
+ final SharedPreferences.Editor editor = prefs.edit();
+
+ for (String key : preferences.keySet()) {
+ final Object value = preferences.get(key);
+
+ LOG.debug("Updating {} = {}", key, value);
+
+ if (value == null) {
+ editor.remove(key);
+ } else if (value instanceof Short) {
+ editor.putInt(key, (Short) value);
+ } else if (value instanceof Integer) {
+ editor.putInt(key, (Integer) value);
+ } else if (value instanceof Boolean) {
+ editor.putBoolean(key, (Boolean) value);
+ } else if (value instanceof String) {
+ editor.putString(key, (String) value);
+ } else if (value instanceof Float) {
+ editor.putFloat(key, (Float) value);
+ } else if (value instanceof Long) {
+ editor.putLong(key, (Long) value);
+ } else if (value instanceof Set) {
+ editor.putStringSet(key, (Set) value);
+ } else {
+ LOG.warn("Unknown preference value type {} for {}", value.getClass(), key);
+ }
+ }
+
+ editor.apply();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventVersionInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventVersionInfo.java
new file mode 100644
index 0000000..0f60765
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventVersionInfo.java
@@ -0,0 +1,39 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class GBDeviceEventVersionInfo extends GBDeviceEvent {
+ public String fwVersion = "N/A";
+ public String fwVersion2 = null;
+ public String hwVersion = "N/A";
+
+ public GBDeviceEventVersionInfo() {
+ if (GBApplication.getContext() != null) {
+ // Only get from context if there is one (eg. not in unit tests)
+ this.fwVersion = GBApplication.getContext().getString(R.string.n_a);
+ this.hwVersion = GBApplication.getContext().getString(R.string.n_a);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return super.toString() + "fwVersion: " + fwVersion + "; fwVersion2: " + fwVersion2 + "; hwVersion: " + hwVersion;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventWearState.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventWearState.java
new file mode 100644
index 0000000..2c2e992
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventWearState.java
@@ -0,0 +1,30 @@
+/* Copyright (C) 2023-2024 Yoran Vulker
+
+ 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.deviceevents;
+
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.model.WearingState;
+
+public class GBDeviceEventWearState extends GBDeviceEvent {
+ public WearingState wearingState = WearingState.UNKNOWN;
+
+ @Override
+ public String toString() {
+ return super.toString() + String.format(Locale.ROOT, "wearingState=%s", wearingState);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceMusicData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceMusicData.java
new file mode 100644
index 0000000..9f7d268
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceMusicData.java
@@ -0,0 +1,15 @@
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceMusic;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceMusicPlaylist;
+
+public class GBDeviceMusicData extends GBDeviceEvent {
+ public int type = 0; // 1 - sync start, 2 - music list, 10 - end sync
+ public List<GBDeviceMusic> list = null;
+ public List<GBDeviceMusicPlaylist> playlists = null;
+ public String deviceInfo = null;
+ public int maxMusicCount = 0;
+ public int maxPlaylistCount = 0;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceMusicUpdate.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceMusicUpdate.java
new file mode 100644
index 0000000..000b92a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceMusicUpdate.java
@@ -0,0 +1,11 @@
+package nodomain.freeyourgadget.gadgetbridge.deviceevents;
+
+import java.util.ArrayList;
+
+public class GBDeviceMusicUpdate extends GBDeviceEvent {
+ public boolean success = false;
+ public int operation = -1;
+ public int playlistIndex = -1;
+ public String playlistName;
+ public ArrayList<Integer> musicIds = null;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/pebble/GBDeviceEventDataLogging.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/pebble/GBDeviceEventDataLogging.java
new file mode 100644
index 0000000..0889150
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/pebble/GBDeviceEventDataLogging.java
@@ -0,0 +1,33 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa
+
+ 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.deviceevents.pebble;
+
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
+
+public class GBDeviceEventDataLogging extends GBDeviceEvent {
+ public static final int COMMAND_RECEIVE_DATA = 1;
+ public static final int COMMAND_FINISH_SESSION = 2;
+
+ public int command;
+ public UUID appUUID;
+ public long timestamp;
+ public long tag;
+ public byte pebbleDataType;
+ public Object[] data;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLClassicDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLClassicDeviceCoordinator.java
new file mode 100644
index 0000000..64cda98
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLClassicDeviceCoordinator.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno
+
+ 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.devices;
+
+public abstract class AbstractBLClassicDeviceCoordinator extends AbstractDeviceCoordinator {
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BT_CLASSIC;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLEDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLEDeviceCoordinator.java
new file mode 100644
index 0000000..565837c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLEDeviceCoordinator.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices;
+
+public abstract class AbstractBLEDeviceCoordinator extends AbstractDeviceCoordinator {
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BLE;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java
new file mode 100644
index 0000000..eff63dc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java
@@ -0,0 +1,912 @@
+/* Copyright (C) 2015-2024 akasaka / Genjitsu Labs, Alicia Hormann, Andreas
+ Shimokawa, Arjan Schrijver, Carsten Pfeiffer, Daniel Dakhno, Daniele Gobbetti,
+ Davis Mosenkovs, Dmitry Markin, Jos茅 Rebelo, Matthieu Baerts, Nephiel,
+ Petr Van臎k, Taavi Eom盲e, Johannes Krude
+
+ 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.devices;
+
+import static nodomain.freeyourgadget.gadgetbridge.GBApplication.getPrefs;
+
+import android.app.Activity;
+import android.bluetooth.BluetoothClass;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import de.greenrobot.dao.query.QueryBuilder;
+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.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.entities.AlarmDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.BatteryLevelDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.DeviceAttributesDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.AbstractNotificationPattern;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.model.BodyEnergySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
+import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.SleepScoreSample;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+import nodomain.freeyourgadget.gadgetbridge.model.WeightSample;
+import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractDeviceCoordinator.class);
+
+ private Pattern supportedDeviceName = null;
+
+ /**
+ * This method should return a Regexp pattern that will matched against a found device
+ * to check whether this coordinator supports that device.
+ * If more sophisticated logic is needed to determine device support, the supports(GBDeviceCandidate)
+ * should be overridden.
+ *
+ * @return Pattern
+ */
+ protected Pattern getSupportedDeviceName() {
+ return null;
+ }
+
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ if (supportedDeviceName == null) {
+ supportedDeviceName = getSupportedDeviceName();
+ }
+ if (supportedDeviceName == null) {
+ LOG.error("{} should either override getSupportedDeviceName or supports(GBDeviceCandidate)", getClass());
+ return false;
+ }
+
+ return supportedDeviceName.matcher(candidate.getName()).matches();
+ }
+
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BOTH;
+ }
+
+ @Override
+ public boolean isConnectable(){
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public GBDevice createDevice(GBDeviceCandidate candidate, DeviceType deviceType) {
+ GBDevice gbDevice = new GBDevice(candidate.getDevice().getAddress(), candidate.getName(), null, null, deviceType);
+ for (BatteryConfig batteryConfig : getBatteryConfig(gbDevice)) {
+ gbDevice.setBatteryIcon(batteryConfig.getBatteryIcon(), batteryConfig.getBatteryIndex());
+ gbDevice.setBatteryLabel(batteryConfig.getBatteryLabel(), batteryConfig.getBatteryIndex());
+ }
+ return gbDevice;
+ }
+
+ @Override
+ public final void deleteDevice(final GBDevice gbDevice) throws GBException {
+ LOG.info("will try to delete device: {}", gbDevice.getName());
+ if (gbDevice.isConnected() || gbDevice.isConnecting()) {
+ GBApplication.deviceService(gbDevice).disconnect();
+ }
+ Prefs prefs = getPrefs();
+
+ Set<String> lastDeviceAddresses = prefs.getStringSet(GBPrefs.LAST_DEVICE_ADDRESSES, Collections.emptySet());
+ if (lastDeviceAddresses.contains(gbDevice.getAddress())) {
+ LOG.debug("#1605 removing last device (one of last devices)");
+ lastDeviceAddresses = new HashSet<String>(lastDeviceAddresses);
+ lastDeviceAddresses.remove(gbDevice.getAddress());
+ prefs.getPreferences().edit().putStringSet(GBPrefs.LAST_DEVICE_ADDRESSES, lastDeviceAddresses).apply();
+ }
+
+ GBApplication.deleteDeviceSpecificSharedPrefs(gbDevice.getAddress());
+
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ DaoSession session = dbHandler.getDaoSession();
+ Device device = DBHelper.findDevice(gbDevice, session);
+ if (device != null) {
+ deleteDevice(gbDevice, device, session);
+ QueryBuilder<?> qb = session.getDeviceAttributesDao().queryBuilder();
+ qb.where(DeviceAttributesDao.Properties.DeviceId.eq(device.getId())).buildDelete().executeDeleteWithoutDetachingEntities();
+ QueryBuilder<?> batteryLevelQueryBuilder = session.getBatteryLevelDao().queryBuilder();
+ batteryLevelQueryBuilder.where(BatteryLevelDao.Properties.DeviceId.eq(device.getId())).buildDelete().executeDeleteWithoutDetachingEntities();
+ QueryBuilder<?> alarmDeviceQueryBuilder = session.getAlarmDao().queryBuilder();
+ alarmDeviceQueryBuilder.where(AlarmDao.Properties.DeviceId.eq(device.getId())).buildDelete().executeDeleteWithoutDetachingEntities();
+ session.getDeviceDao().delete(device);
+ } else {
+ LOG.info("device to delete not found in db: {}", gbDevice);
+ }
+ } catch (Exception e) {
+ throw new GBException("Error deleting device: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Hook for subclasses to perform device-specific deletion logic, e.g. db cleanup.
+ *
+ * @param gbDevice the GBDevice
+ * @param device the corresponding database Device
+ * @param session the session to use
+ * @throws GBException if there was an error deleting device-specific resources
+ */
+ protected abstract void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException;
+
+ @Override
+ public boolean allowFetchActivityData(GBDevice device) {
+ return device.isInitialized() && !device.isBusy() && supportsActivityDataFetching();
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(final GBDevice device, final DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends StressSample> getStressSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends BodyEnergySample> getBodyEnergySampleProvider(final GBDevice device, final DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HrvSummarySample> getHrvSummarySampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HrvValueSample> getHrvValueSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public Vo2MaxSampleProvider<? extends Vo2MaxSample> getVo2MaxSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public int[] getStressRanges() {
+ // 0-39 = relaxed
+ // 40-59 = mild
+ // 60-79 = moderate
+ // 80-100 = high
+ return new int[]{0, 40, 60, 80};
+ }
+
+ @Override
+ public TimeSampleProvider<? extends TemperatureSample> getTemperatureSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<CyclingSample> getCyclingSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HeartRateSample> getHeartRateMaxSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HeartRateSample> getHeartRateRestingSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HeartRateSample> getHeartRateManualSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends PaiSample> getPaiSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends RespiratoryRateSample> getRespiratoryRateSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends WeightSample> getWeightSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends RestingMetabolicRateSample> getRestingMetabolicRateProvider(final GBDevice device, final DaoSession session) {
+ return new DefaultRestingMetabolicRateProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends SleepScoreSample> getSleepScoreProvider(final GBDevice device, final DaoSession session) {
+ return null;
+ }
+
+ @Override
+ @Nullable
+ public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
+ return null;
+ }
+
+ public boolean isHealthWearable(BluetoothDevice device) {
+ BluetoothClass bluetoothClass;
+ try {
+ bluetoothClass = device.getBluetoothClass();
+ } catch (SecurityException se) {
+ LOG.warn("missing bluetooth permission: ", se);
+ return false;
+ }
+ if (bluetoothClass == null) {
+ LOG.warn("unable to determine bluetooth device class of {}", device);
+ return false;
+ }
+ if (bluetoothClass.getMajorDeviceClass() == BluetoothClass.Device.Major.WEARABLE
+ || bluetoothClass.getMajorDeviceClass() == BluetoothClass.Device.Major.UNCATEGORIZED) {
+ int deviceClasses =
+ BluetoothClass.Device.HEALTH_BLOOD_PRESSURE
+ | BluetoothClass.Device.HEALTH_DATA_DISPLAY
+ | BluetoothClass.Device.HEALTH_PULSE_RATE
+ | BluetoothClass.Device.HEALTH_WEIGHING
+ | BluetoothClass.Device.HEALTH_UNCATEGORIZED
+ | BluetoothClass.Device.HEALTH_PULSE_OXIMETER
+ | BluetoothClass.Device.HEALTH_GLUCOSE;
+
+ return (bluetoothClass.getDeviceClass() & deviceClasses) != 0;
+ }
+ return false;
+ }
+
+ @Override
+ public File getAppCacheDir() throws IOException {
+ return null;
+ }
+
+ @Override
+ public File getWritableExportDirectory(final GBDevice device) throws IOException {
+ File dir;
+ dir = new File(FileUtils.getExternalFilesDir() + File.separator + device.getAddress());
+ if (!dir.isDirectory()) {
+ if (!dir.mkdir()) {
+ throw new IOException("Cannot create device specific directory for " + device.getName());
+ }
+ }
+ return dir;
+ }
+
+ @Override
+ public String getAppCacheSortFilename() {
+ return null;
+ }
+
+ @Override
+ public String getAppFileExtension() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsAppListFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFlashing() {
+ return false;
+ }
+
+ @Nullable
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(final GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(GBDevice device, int alarmPosition) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSmartWakeupInterval(GBDevice device, int alarmPosition) {
+ return false;
+ }
+
+ @Override
+ public boolean forcedSmartWakeup(GBDevice device, int alarmPosition) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsAppReordering() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsCachedAppManagement(final GBDevice device) {
+ try {
+ return supportsAppsManagement(device) && getAppCacheDir() != null;
+ } catch (final Exception e) {
+ // we failed, but still tried, so it's supported..
+ LOG.error("Failed to get app cache dir", e);
+ return true;
+ }
+ }
+
+ @Override
+ public boolean supportsInstalledAppManagement(final GBDevice device) {
+ return supportsAppsManagement(device);
+ }
+
+ @Override
+ public boolean supportsWatchfaceManagement(final GBDevice device) {
+ return supportsAppsManagement(device);
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getWatchfaceDesignerActivity() {
+ return null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_ASK;
+ }
+
+ @Override
+ public boolean suggestUnbindBeforePair() {
+ return true;
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsBodyEnergy() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsHrvMeasurement() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsVO2Max() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsVO2MaxCycling() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsVO2MaxRunning() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActiveCalories() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTabs() {
+ return supportsActivityTracking();
+ }
+ @Override
+ public boolean supportsSleepMeasurement() {
+ return supportsActivityTracking();
+ }
+ @Override
+ public boolean supportsStepCounter() {
+ return supportsActivityTracking();
+ }
+ @Override
+ public boolean supportsSpeedzones() {
+ return supportsActivityTracking();
+ }
+
+ @Override
+ public boolean supportsTemperatureMeasurement() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsContinuousTemperature() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSpo2(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsHeartRateStats() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsPai() {
+ return false;
+ }
+
+ @Override
+ public int getPaiName() {
+ return R.string.menuitem_pai;
+ }
+
+ @Override
+ public boolean supportsPaiTime() {
+ return supportsPai();
+ }
+
+ @Override
+ public boolean supportsPaiLow() {
+ return supportsPai();
+ }
+
+ @Override
+ public int getPaiTarget() {
+ return 100;
+ }
+
+ @Override
+ public boolean supportsRespiratoryRate() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsDayRespiratoryRate() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSleepRespiratoryRate() {
+ return supportsRespiratoryRate();
+ }
+
+ @Override
+ public boolean supportsWeightMeasurement() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsAlarmSnoozing() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsAlarmTitle(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmTitleLimit(GBDevice device) {
+ return -1;
+ }
+
+ @Override
+ public boolean supportsAlarmDescription(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsLedColor() {
+ return false;
+ }
+
+ @Override
+ public int getMaximumReminderMessageLength() {
+ return 0;
+ }
+
+ @Override
+ public int getReminderSlotCount(final GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean getRemindersHaveTime() {
+ return true;
+ }
+
+ @Override
+ public boolean getReserveReminderSlotsForCalendar() {
+ return false;
+ }
+
+ @Override
+ public int getCannedRepliesSlotCount(final GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 0;
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ return 10;
+ }
+
+ @Override
+ public boolean supportsDisabledWorldClocks() {
+ return false;
+ }
+
+ @Override
+ public int getContactsSlotCount(final GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsRgbLedColor() {
+ return false;
+ }
+
+ @NonNull
+ @Override
+ public int[] getColorPresets() {
+ return new int[0];
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsHeartRateRestingMeasurement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsManualHeartRateMeasurement(final GBDevice device) {
+ return supportsHeartRateMeasurement(device);
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsCyclingData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRemSleep() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsAwakeSleep() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSleepScore() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ 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];
+ ConnectionType connectionType = getConnectionType();
+
+ if (connectionType.usesBluetoothLE()) {
+ settings = ArrayUtils.insert(0, settings, R.xml.devicesettings_reconnect_ble);
+ }
+ if (connectionType.usesBluetoothClassic()) {
+ settings = ArrayUtils.insert(0, settings, R.xml.devicesettings_reconnect_bl_classic);
+ }
+
+ return settings;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[0];
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(GBDevice device) {
+ final int[] settings = getSupportedDeviceSpecificSettings(device);
+ if (settings == null || settings.length == 0) {
+ return null;
+ }
+
+ return new DeviceSpecificSettings(settings);
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificAuthenticationSettings() {
+ return new int[0];
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(GBDevice device) {
+ return null;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(GBDevice device) {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getCalibrationActivity() {
+ return null;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 1;
+ } //multiple battery support, default is 1, maximum is 3, 0 will disable the battery in UI
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ final BatteryConfig[] batteryConfigs = new BatteryConfig[getBatteryCount()];
+ for (int i = 0; i < getBatteryCount(); i++) {
+ batteryConfigs[i] = new BatteryConfig(i);
+ }
+ return batteryConfigs;
+ }
+
+ @Override
+ public boolean addBatteryPollingSettings() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsPowerOff() {
+ return false;
+ }
+
+ @Override
+ public PasswordCapabilityImpl.Mode getPasswordCapability() {
+ return PasswordCapabilityImpl.Mode.NONE;
+ }
+
+ @Override
+ public List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
+ return Arrays.asList(
+ HeartRateCapability.MeasurementInterval.OFF,
+ HeartRateCapability.MeasurementInterval.MINUTES_1,
+ HeartRateCapability.MeasurementInterval.MINUTES_5,
+ HeartRateCapability.MeasurementInterval.MINUTES_10,
+ HeartRateCapability.MeasurementInterval.MINUTES_30,
+ HeartRateCapability.MeasurementInterval.HOUR_1
+ );
+ }
+
+ @Override
+ public boolean supportsWidgets(final GBDevice device) {
+ return false;
+ }
+
+ @Nullable
+ @Override
+ public WidgetManager getWidgetManager(final GBDevice device) {
+ return null;
+ }
+
+ public boolean supportsNavigation() {
+ return false;
+ }
+
+ @Override
+ public int getOrderPriority() {
+ return 0;
+ }
+
+ @Override
+ public EnumSet<ServiceDeviceSupport.Flags> getInitialFlags() {
+ return EnumSet.of(ServiceDeviceSupport.Flags.BUSY_CHECKING);
+ }
+
+ @Override
+ @DrawableRes
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_default;
+ }
+
+ @Override
+ @DrawableRes
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_default_disabled;
+ }
+
+ @Override
+ public boolean supportsNotificationVibrationPatterns() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsNotificationVibrationRepetitionPatterns() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsNotificationLedPatterns() {
+ return false;
+ }
+
+ @Override
+ public AbstractNotificationPattern[] getNotificationVibrationPatterns() {
+ return new AbstractNotificationPattern[0];
+ }
+
+ @Override
+ public AbstractNotificationPattern[] getNotificationVibrationRepetitionPatterns() {
+ return new AbstractNotificationPattern[0];
+ }
+
+ @Override
+ public AbstractNotificationPattern[] getNotificationLedPatterns() {
+ return new AbstractNotificationPattern[0];
+ }
+
+ @Override
+ public boolean validateAuthKey(final String authKey) {
+ return !(authKey.getBytes().length < 34 || !authKey.startsWith("0x"));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleProvider.java
new file mode 100644
index 0000000..b226fa9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleProvider.java
@@ -0,0 +1,399 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
+ Dakhno, Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Set;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import de.greenrobot.dao.query.QueryBuilder;
+import de.greenrobot.dao.query.WhereCondition;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+
+/**
+ * Base class for all sample providers. A Sample provider is device specific and provides
+ * access to the device specific samples. There are both read and write operations.
+ * @param <T> the sample type
+ */
+public abstract class AbstractSampleProvider<T extends AbstractActivitySample> implements SampleProvider<T> {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractSampleProvider.class);
+
+ private static final WhereCondition[] NO_CONDITIONS = new WhereCondition[0];
+ private final DaoSession mSession;
+ private final GBDevice mDevice;
+
+ protected AbstractSampleProvider(GBDevice device, DaoSession session) {
+ mDevice = device;
+ mSession = session;
+ }
+
+ public GBDevice getDevice() {
+ return mDevice;
+ }
+
+ public DaoSession getSession() {
+ return mSession;
+ }
+
+ @NonNull
+ @Override
+ public List<T> getAllActivitySamples(int timestamp_from, int timestamp_to) {
+ return getGBActivitySamples(timestamp_from, timestamp_to);
+ }
+
+ @NonNull
+ @Override
+ public List<T> getAllActivitySamplesHighRes(int timestamp_from, int timestamp_to) {
+ return getGBActivitySamplesHighRes(timestamp_from, timestamp_to);
+ }
+
+ @Override
+ public boolean hasHighResData() {
+ return false;
+ }
+
+ @NonNull
+ @Override
+ @Deprecated // use getAllActivitySamples
+ public List<T> getActivitySamples(int timestamp_from, int timestamp_to) {
+ if (getRawKindSampleProperty() != null) {
+ return getGBActivitySamples(timestamp_from, timestamp_to);
+ } else {
+ return getActivitySamplesByActivityFilter(timestamp_from, timestamp_to, Collections.singleton(ActivityKind.ACTIVITY));
+ }
+ }
+
+ @Override
+ public void addGBActivitySample(T activitySample) {
+ getSampleDao().insertOrReplace(activitySample);
+ }
+
+ @Override
+ public void addGBActivitySamples(T[] activitySamples) {
+ getSampleDao().insertOrReplaceInTx(activitySamples);
+ }
+
+ @Nullable
+ @Override
+ public T getLatestActivitySample() {
+ QueryBuilder<T> qb = getSampleDao().queryBuilder();
+ Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+ Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(deviceProperty.eq(dbDevice.getId())).orderDesc(getTimestampSampleProperty()).limit(1);
+ List<T> samples = qb.build().list();
+ if (samples.isEmpty()) {
+ return null;
+ }
+ T sample = samples.get(0);
+ sample.setProvider(this);
+ return sample;
+ }
+
+ @Nullable
+ @Override
+ public T getLatestActivitySample(final int until) {
+ QueryBuilder<T> qb = getSampleDao().queryBuilder();
+ Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+ Property deviceProperty = getDeviceIdentifierSampleProperty();
+ Property timestampProperty = getTimestampSampleProperty();
+ qb.where(timestampProperty.le(until))
+ .where(deviceProperty.eq(dbDevice.getId()))
+ .orderDesc(timestampProperty).limit(1);
+ List<T> samples = qb.build().list();
+ if (samples.isEmpty()) {
+ return null;
+ }
+ T sample = samples.get(0);
+ sample.setProvider(this);
+ return sample;
+ }
+
+ @Nullable
+ @Override
+ public T getFirstActivitySample() {
+ QueryBuilder<T> qb = getSampleDao().queryBuilder();
+ Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+ Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(deviceProperty.eq(dbDevice.getId())).orderAsc(getTimestampSampleProperty()).limit(1);
+ List<T> samples = qb.build().list();
+ if (samples.isEmpty()) {
+ return null;
+ }
+ T sample = samples.get(0);
+ sample.setProvider(this);
+ return sample;
+ }
+
+ /**
+ * Get the activity samples between two timestamps (inclusive). Exactly one every minute.
+ * @param timestamp_from Start timestamp
+ * @param timestamp_to End timestamp
+ * @return Exactly one sample for every minute
+ */
+ protected List<T> getGBActivitySamples(int timestamp_from, int timestamp_to) {
+ QueryBuilder<T> qb = getSampleDao().queryBuilder();
+ Property timestampProperty = getTimestampSampleProperty();
+ Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no samples
+ return Collections.emptyList();
+ }
+ Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(deviceProperty.eq(dbDevice.getId()), timestampProperty.ge(timestamp_from))
+ .where(timestampProperty.le(timestamp_to));
+ List<T> samples = qb.build().list();
+ for (T sample : samples) {
+ sample.setProvider(this);
+ }
+ detachFromSession();
+ return samples;
+ }
+
+ /**
+ * Get the activity samples between two timestamps (inclusive).
+ * Differs from {@link #getGBActivitySamples(int, int)} in that it supplies as many samples as
+ * available.
+ * It assumes {@link #getGBActivitySamples(int, int)} returns the highest resolution data unless
+ * this is overwritten.
+ * @param timestamp_from Start timestamp
+ * @param timestamp_to End timestamp
+ * @return All the samples between start and end timestamp (inclusive)
+ */
+ protected List<T> getGBActivitySamplesHighRes(int timestamp_from, int timestamp_to) {
+ return getGBActivitySamples(timestamp_from, timestamp_to);
+ }
+
+ /**
+ * Detaches all samples of this type from the session. Changes to them may not be
+ * written back to the database.
+ * <p>
+ * Subclasses should call this method after performing custom queries.
+ */
+ protected void detachFromSession() {
+ getSampleDao().detachAll();
+ }
+
+ private List<T> getActivitySamplesByActivityFilter(int timestamp_from, int timestamp_to, Set<ActivityKind> activityFilter) {
+ List<T> samples = getAllActivitySamples(timestamp_from, timestamp_to);
+ List<T> filteredSamples = new ArrayList<>();
+
+ for (T sample : samples) {
+ if (activityFilter.contains(sample.getKind())) {
+ filteredSamples.add(sample);
+ }
+ }
+ return filteredSamples;
+ }
+
+ public abstract AbstractDao<T,?> getSampleDao();
+
+ @Nullable
+ protected abstract Property getRawKindSampleProperty();
+
+ @NonNull
+ protected abstract Property getTimestampSampleProperty();
+
+ @NonNull
+ protected abstract Property getDeviceIdentifierSampleProperty();
+
+ public void convertCumulativeSteps(final List<T> samples, final Property stepsSampleProperty) {
+ // Fix over-counting at the turn of day
+ final T lastSample = getLastSampleWithStepsBefore(samples.get(0).getTimestamp(), stepsSampleProperty);
+ if (lastSample != null && sameDay(lastSample, samples.get(0))) {
+ if (samples.get(0).getSteps() > 0) {
+ samples.get(0).setSteps(samples.get(0).getSteps() - lastSample.getSteps());
+ }
+
+ if (samples.get(0).getDistanceCm() > 0) {
+ samples.get(0).setDistanceCm(samples.get(0).getDistanceCm() - lastSample.getDistanceCm());
+ }
+
+ if (samples.get(0).getActiveCalories() > 0) {
+ samples.get(0).setActiveCalories(samples.get(0).getActiveCalories() - lastSample.getActiveCalories());
+ }
+ }
+
+ // This slightly breaks activity recognition, because we don't have per-minute granularity...
+ int prevSteps = samples.get(0).getSteps();
+ int prevDistance = samples.get(0).getDistanceCm();
+ int prevActiveCalories = samples.get(0).getActiveCalories();
+ samples.get(0).setTimestamp((samples.get(0).getTimestamp() / 60) * 60);
+ int bak;
+
+ for (int i = 1; i < samples.size(); i++) {
+ final T s1 = samples.get(i - 1);
+ final T s2 = samples.get(i);
+ s2.setTimestamp((s2.getTimestamp() / 60) * 60);
+
+ if (!sameDay(s1, s2)) {
+ // went past midnight - reset steps
+ prevSteps = s2.getSteps() > 0 ? s2.getSteps() : 0;
+ prevDistance = s2.getDistanceCm() > 0 ? s2.getDistanceCm() : 0;
+ prevActiveCalories = s2.getActiveCalories() > 0 ? s2.getActiveCalories() : 0;
+ } else {
+ // New value for the current day - subtract the previous seen sample
+
+ if (s2.getSteps() > 0) {
+ bak = s2.getSteps();
+ s2.setSteps(s2.getSteps() - prevSteps);
+ prevSteps = bak;
+ }
+ if (s2.getDistanceCm() > 0) {
+ bak = s2.getDistanceCm();
+ s2.setDistanceCm(s2.getDistanceCm() - prevDistance);
+ prevDistance = bak;
+ }
+ if (s2.getActiveCalories() > 0) {
+ bak = s2.getActiveCalories();
+ s2.setActiveCalories(s2.getActiveCalories() - prevActiveCalories);
+ prevActiveCalories = bak;
+ }
+ }
+ }
+ }
+
+ @Nullable
+ public T getLastSampleWithStepsBefore(final int timestampTo, final Property stepsSampleProperty) {
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+
+ final List<T> samples = getSampleDao().queryBuilder()
+ .where(
+ getDeviceIdentifierSampleProperty().eq(dbDevice.getId()),
+ getTimestampSampleProperty().le(timestampTo),
+ stepsSampleProperty.gt(-1)
+ ).orderDesc(getTimestampSampleProperty())
+ .limit(1)
+ .list();
+
+ return !samples.isEmpty() ? samples.get(0) : null;
+ }
+
+ public boolean sameDay(final T s1, final T s2) {
+ final Calendar cal = Calendar.getInstance();
+
+ cal.setTimeInMillis(s1.getTimestamp() * 1000L - 1000L);
+ final LocalDate d1 = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH));
+
+ cal.setTimeInMillis(s2.getTimestamp() * 1000L - 1000L);
+ final LocalDate d2 = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH));
+
+ return d1.equals(d2);
+ }
+
+ protected List<T> fillGaps(final List<T> samples, final int timestamp_from, final int timestamp_to) {
+ if (samples.isEmpty()) {
+ return samples;
+ }
+
+ final long nanoStart = System.nanoTime();
+
+ final List<T> ret = new LinkedList<>(samples);
+
+ //ret.sort(Comparator.comparingLong(T::getTimestamp));
+
+ final int firstTimestamp = ret.get(0).getTimestamp();
+ if (firstTimestamp - timestamp_from > 60) {
+ // Gap at the start
+ for (int ts = timestamp_from; ts <= firstTimestamp + 60; ts += 60) {
+ ret.add(0, createDummySample(ts));
+ }
+ }
+
+ final int lastTimestamp = ret.get(ret.size() - 1).getTimestamp();
+ // Do not generate fake samples into the future
+ final long minTo = Math.min(timestamp_to, System.currentTimeMillis() / 1000L);
+ if (minTo - lastTimestamp > 60) {
+ // Gap at the end
+ for (int ts = lastTimestamp + 60; ts <= minTo; ts += 60) {
+ ret.add(createDummySample(ts));
+ }
+ }
+
+ final ListIterator<T> it = ret.listIterator();
+ T previousSample = it.next();
+
+ while (it.hasNext()) {
+ final T sample = it.next();
+ if (sample.getTimestamp() - previousSample.getTimestamp() > 60) {
+ LOG.trace("Filling gap between {} and {}", Instant.ofEpochSecond(previousSample.getTimestamp() + 60), Instant.ofEpochSecond(sample.getTimestamp()));
+ for (int ts = previousSample.getTimestamp() + 60; ts < sample.getTimestamp(); ts += 60) {
+ it.add(createDummySample(ts));
+ }
+ }
+ previousSample = sample;
+ }
+
+ final long nanoEnd = System.nanoTime();
+
+ final long executionTime = (nanoEnd - nanoStart) / 1000000;
+
+ final int dummyCount = ret.size() - samples.size();
+ LOG.trace("Filled gaps with {} samples in {}ms", dummyCount, executionTime);
+
+ return ret;
+ }
+
+ private T createDummySample(final int ts) {
+ final T dummySample = createActivitySample();
+ dummySample.setTimestamp(ts);
+ dummySample.setRawKind(ActivityKind.UNKNOWN.getCode());
+ dummySample.setRawIntensity(ActivitySample.NOT_MEASURED);
+ dummySample.setSteps(ActivitySample.NOT_MEASURED);
+ dummySample.setHeartRate(ActivitySample.NOT_MEASURED);
+ dummySample.setDistanceCm(ActivitySample.NOT_MEASURED);
+ dummySample.setActiveCalories(ActivitySample.NOT_MEASURED);
+ dummySample.setProvider(this);
+ return dummySample;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleToTimeSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleToTimeSampleProvider.java
new file mode 100644
index 0000000..005d880
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleToTimeSampleProvider.java
@@ -0,0 +1,104 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.TimeSample;
+
+/**
+ * Wraps a {@link SampleProvider} into a {@link TimeSampleProvider}.
+ */
+public abstract class AbstractSampleToTimeSampleProvider<T extends TimeSample, S extends AbstractActivitySample> implements TimeSampleProvider<T> {
+ private final SampleProvider<S> mSampleProvider;
+ private final DaoSession mSession;
+ private final GBDevice mDevice;
+
+ protected AbstractSampleToTimeSampleProvider(final SampleProvider<S> sampleProvider, final GBDevice device, final DaoSession session) {
+ mSampleProvider = sampleProvider;
+ mDevice = device;
+ mSession = session;
+ }
+
+ @Nullable
+ protected abstract T convertSample(final S sample);
+
+ public GBDevice getDevice() {
+ return mDevice;
+ }
+
+ public DaoSession getSession() {
+ return mSession;
+ }
+
+ @NonNull
+ @Override
+ public List<T> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<S> upstreamSamples = mSampleProvider.getAllActivitySamples((int) (timestampFrom / 1000L), (int) (timestampTo / 1000L));
+ final List<T> ret = new ArrayList<>();
+ for (final S sample : upstreamSamples) {
+ final T converted = convertSample(sample);
+ if (converted != null) {
+ ret.add(converted);
+ }
+ }
+ return ret;
+ }
+
+ @Override
+ public void addSample(final T timeSample) {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Override
+ public void addSamples(final List<T> timeSamples) {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Override
+ public T createSample() {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Nullable
+ @Override
+ public T getLatestSample() {
+ final S latestSample = mSampleProvider.getLatestActivitySample();
+ return convertSample(latestSample);
+ }
+
+ @Nullable
+ @Override
+ public T getLatestSample(final long until) {
+ final S latestSample = mSampleProvider.getLatestActivitySample((int) (until / 1000L));
+ return convertSample(latestSample);
+ }
+
+ @Nullable
+ @Override
+ public T getFirstSample() {
+ final S firstSample = mSampleProvider.getFirstActivitySample();
+ return convertSample(firstSample);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractTimeSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractTimeSampleProvider.java
new file mode 100644
index 0000000..c4ea621
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractTimeSampleProvider.java
@@ -0,0 +1,199 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractTimeSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+/**
+ * Base class for all time sample providers. A Sample provider is device specific and provides
+ * access to the device specific samples. There are both read and write operations.
+ *
+ * @param <T> the sample type
+ */
+public abstract class AbstractTimeSampleProvider<T extends AbstractTimeSample> implements TimeSampleProvider<T> {
+ private final DaoSession mSession;
+ private final GBDevice mDevice;
+
+ protected AbstractTimeSampleProvider(final GBDevice device, final DaoSession session) {
+ mDevice = device;
+ mSession = session;
+ }
+
+ public GBDevice getDevice() {
+ return mDevice;
+ }
+
+ public DaoSession getSession() {
+ return mSession;
+ }
+
+ @NonNull
+ @Override
+ public List<T> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final QueryBuilder<T> qb = getSampleDao().queryBuilder();
+ final Property timestampProperty = getTimestampSampleProperty();
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no samples
+ return Collections.emptyList();
+ }
+ final Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(deviceProperty.eq(dbDevice.getId()), timestampProperty.ge(timestampFrom))
+ .where(timestampProperty.le(timestampTo));
+ final List<T> samples = qb.build().list();
+ detachFromSession();
+ return samples;
+ }
+
+ @Override
+ public void addSample(final T activitySample) {
+ getSampleDao().insertOrReplace(activitySample);
+ }
+
+ @Override
+ public void addSamples(final List<T> activitySamples) {
+ getSampleDao().insertOrReplaceInTx(activitySamples);
+ }
+
+ @Nullable
+ @Override
+ public T getLatestSample() {
+ final QueryBuilder<T> qb = getSampleDao().queryBuilder();
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+ final Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(deviceProperty.eq(dbDevice.getId())).orderDesc(getTimestampSampleProperty()).limit(1);
+ final List<T> samples = qb.build().list();
+ if (samples.isEmpty()) {
+ return null;
+ }
+ return samples.get(0);
+ }
+
+ @Nullable
+ @Override
+ public T getLatestSample(final long until) {
+ final QueryBuilder<T> qb = getSampleDao().queryBuilder();
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+ final Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(getTimestampSampleProperty().le(until))
+ .where(deviceProperty.eq(dbDevice.getId()))
+ .orderDesc(getTimestampSampleProperty()).limit(1);
+ final List<T> samples = qb.build().list();
+ if (samples.isEmpty()) {
+ return null;
+ }
+ return samples.get(0);
+ }
+
+ @Nullable
+ public T getLastSampleBefore(final long timestampTo) {
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+
+ final Property deviceIdSampleProp = getDeviceIdentifierSampleProperty();
+ final Property timestampSampleProp = getTimestampSampleProperty();
+ final List<T> samples = getSampleDao().queryBuilder()
+ .where(deviceIdSampleProp.eq(dbDevice.getId()),
+ timestampSampleProp.le(timestampTo))
+ .orderDesc(getTimestampSampleProperty())
+ .limit(1)
+ .list();
+
+ return !samples.isEmpty() ? samples.get(0) : null;
+ }
+
+ @Nullable
+ public T getNextSampleAfter(final long timestampFrom) {
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+
+ final Property deviceIdSampleProp = getDeviceIdentifierSampleProperty();
+ final Property timestampSampleProp = getTimestampSampleProperty();
+ final List<T> samples = getSampleDao().queryBuilder()
+ .where(deviceIdSampleProp.eq(dbDevice.getId()),
+ timestampSampleProp.ge(timestampFrom))
+ .orderAsc(getTimestampSampleProperty())
+ .limit(1)
+ .list();
+
+ return !samples.isEmpty() ? samples.get(0) : null;
+ }
+
+ @Nullable
+ @Override
+ public T getFirstSample() {
+ final QueryBuilder<T> qb = getSampleDao().queryBuilder();
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+ final Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(deviceProperty.eq(dbDevice.getId())).orderAsc(getTimestampSampleProperty()).limit(1);
+ final List<T> samples = qb.build().list();
+ if (samples.isEmpty()) {
+ return null;
+ }
+ return samples.get(0);
+ }
+
+ /**
+ * Detaches all samples of this type from the session. Changes to them may not be
+ * written back to the database.
+ * <p>
+ * Subclasses should call this method after performing custom queries.
+ */
+ protected void detachFromSession() {
+ getSampleDao().detachAll();
+ }
+
+ @NonNull
+ public abstract AbstractDao<T, ?> getSampleDao();
+
+ @NonNull
+ protected abstract Property getTimestampSampleProperty();
+
+ @NonNull
+ protected abstract Property getDeviceIdentifierSampleProperty();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DefaultRestingMetabolicRateProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DefaultRestingMetabolicRateProvider.java
new file mode 100644
index 0000000..756d786
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DefaultRestingMetabolicRateProvider.java
@@ -0,0 +1,190 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
+
+/**
+ * Provides a default resting metabolic rate, for devices that do not provide it. Currently it uses the
+ * Mifflin St Jeor equation.
+ * TODO: use the user data at that timestamp, and make the algorithm configurable.
+ * TODO: maybe let the user also configure their own static value
+ */
+public class DefaultRestingMetabolicRateProvider extends AbstractTimeSampleProvider<RestingMetabolicRateSample> {
+ private final DaoSession mSession;
+ private final GBDevice mDevice;
+
+ public DefaultRestingMetabolicRateProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ mDevice = device;
+ mSession = session;
+ }
+
+ public GBDevice getDevice() {
+ return mDevice;
+ }
+
+ public DaoSession getSession() {
+ return mSession;
+ }
+
+ @NonNull
+ @Override
+ public List<RestingMetabolicRateSample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ return Collections.singletonList(getLatestSample());
+ }
+
+ @Override
+ public void addSample(final RestingMetabolicRateSample timeSample) {
+ throw new UnsupportedOperationException("addSample not supported");
+ }
+
+ @Override
+ public void addSamples(final List<RestingMetabolicRateSample> timeSamples) {
+ throw new UnsupportedOperationException("addSamples not supported");
+ }
+
+ @Override
+ public RestingMetabolicRateSample createSample() {
+ return new DefaultRestingMetabolicRateSample(System.currentTimeMillis());
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<RestingMetabolicRateSample, ?> getSampleDao() {
+ throw new UnsupportedOperationException("getSampleDao not supported");
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ throw new UnsupportedOperationException("getTimestampSampleProperty not supported");
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ throw new UnsupportedOperationException("getDeviceIdentifierSampleProperty not supported");
+ }
+
+ @Nullable
+ @Override
+ public RestingMetabolicRateSample getLatestSample() {
+ return new DefaultRestingMetabolicRateSample(System.currentTimeMillis());
+ }
+
+ @Nullable
+ public RestingMetabolicRateSample getLastSampleBefore(final long timestampTo) {
+ return new DefaultRestingMetabolicRateSample(timestampTo);
+ }
+
+ @Nullable
+ public RestingMetabolicRateSample getNextSampleAfter(final long timestampFrom) {
+ return new DefaultRestingMetabolicRateSample(timestampFrom);
+ }
+
+ @Nullable
+ @Override
+ public RestingMetabolicRateSample getFirstSample() {
+ return getLatestSample();
+ }
+
+ public static class DefaultRestingMetabolicRateSample extends RestingMetabolicRateSample {
+ private long timestamp;
+ private final int rate;
+
+ public DefaultRestingMetabolicRateSample(final long timestamp) {
+ this.timestamp = timestamp;
+ ActivityUser activityUser = new ActivityUser();
+ final int weightKg = activityUser.getWeightKg();
+ final int heightCm = activityUser.getHeightCm();
+ final int age = activityUser.getAge();
+ final int s;
+ switch (activityUser.getGender()) {
+ case ActivityUser.GENDER_FEMALE:
+ s = -161;
+ break;
+ case ActivityUser.GENDER_MALE:
+ s = 5;
+ break;
+ default:
+ s = (5 - 161) / 2; // average it?
+ break;
+ }
+ // Mifflin St Jeor equation
+ this.rate = (int) Math.round(10.0d * weightKg + 6.25 * heightCm - 5d * age + s);
+ }
+
+ @Override
+ public int getRestingMetabolicRate() {
+ return rate;
+ }
+
+ @Override
+ public void setTimestamp(final long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public long getUserId() {
+ return 0;
+ }
+
+ @Override
+ public void setUserId(final long userId) {
+
+ }
+
+ @Override
+ public long getDeviceId() {
+ return 0;
+ }
+
+ @Override
+ public void setDeviceId(final long deviceId) {
+
+ }
+
+ @Override
+ public void setDevice(final Device device) {
+
+ }
+
+ @Override
+ public void setUser(final User user) {
+
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java
new file mode 100644
index 0000000..5b1d7fe
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java
@@ -0,0 +1,818 @@
+/* Copyright (C) 2015-2024 akasaka / Genjitsu Labs, Alicia Hormann, Andreas
+ B枚hler, Andreas Shimokawa, Arjan Schrijver, Carsten Pfeiffer, Damien Gaignon,
+ Daniel Dakhno, Daniele Gobbetti, Dmitry Markin, JohnnySun, Jos茅 Rebelo,
+ Matthieu Baerts, Nephiel, Petr Van臎k, Uwe Hermann, Johannes Krude
+
+ 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.devices;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.File;
+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;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager;
+import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.AbstractNotificationPattern;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.model.BodyEnergySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
+import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.SleepScoreSample;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+import nodomain.freeyourgadget.gadgetbridge.model.WeightSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
+
+/**
+ * This interface is implemented at least once for every supported gadget device.
+ * It allows Gadgetbridge to generically deal with different kinds of devices
+ * without actually knowing the details of any device.
+ * <p/>
+ * Instances will be created as needed and asked whether they support a given
+ * device. If a coordinator answers true, it will be used to assist in handling
+ * the given device.
+ */
+public interface DeviceCoordinator {
+ String EXTRA_DEVICE_CANDIDATE = "nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate.EXTRA_DEVICE_CANDIDATE";
+ /**
+ * Do not attempt to bond after discovery.
+ */
+ int BONDING_STYLE_NONE = 0;
+ /**
+ * Bond after discovery.
+ * This is not recommended, as there are mobile devices on which bonding does not work.
+ * Prefer to use #BONDING_STYLE_ASK instead.
+ */
+ int BONDING_STYLE_BOND = 1;
+ /**
+ * Let the user decide whether to bond or not after discovery.
+ * Prefer this over #BONDING_STYLE_BOND
+ */
+ int BONDING_STYLE_ASK = 2;
+
+ /**
+ * A secret key has to be entered before connecting
+ */
+ int BONDING_STYLE_REQUIRE_KEY = 3;
+
+ /**
+ * Lazy pairing, i.e. device initiated pairing is requested
+ */
+ int BONDING_STYLE_LAZY = 4;
+
+ enum ConnectionType{
+ BLE(false, true),
+ BT_CLASSIC(true, false),
+ BOTH(true, true)
+ ;
+ boolean usesBluetoothClassic, usesBluetoothLE;
+
+ ConnectionType(boolean usesBluetoothClassic, boolean usesBluetoothLE) {
+ this.usesBluetoothClassic = usesBluetoothClassic;
+ this.usesBluetoothLE = usesBluetoothLE;
+ }
+
+ public boolean usesBluetoothLE(){
+ return usesBluetoothLE;
+ }
+
+ public boolean usesBluetoothClassic(){
+ return usesBluetoothClassic;
+ }
+ }
+
+ /**
+ * Returns the type of connection, Classic of BLE
+ *
+ * @return ConnectionType
+ */
+ ConnectionType getConnectionType();
+
+ /**
+ * Returns false is the Device is not connectable,
+ * only scannable, like beacons
+ *
+ * @return boolean
+ */
+ boolean isConnectable();
+
+ /**
+ * Checks whether this coordinator handles the given candidate.
+ *
+ * @param candidate
+ * @return true if this coordinator handles the given candidate.
+ */
+ boolean supports(GBDeviceCandidate candidate);
+
+ /**
+ * Returns a list of scan filters that shall be used to discover devices supported
+ * by this coordinator.
+ * @return the list of scan filters, may be empty
+ */
+ @NonNull
+ Collection<? extends ScanFilter> createBLEScanFilters();
+
+ GBDevice createDevice(GBDeviceCandidate candidate, DeviceType type);
+
+ /**
+ * Deletes all information, including all related database content about the
+ * given device.
+ * @throws GBException
+ */
+ void deleteDevice(GBDevice device) throws GBException;
+
+ /**
+ * Returns the Activity class to be started in order to perform a pairing of a
+ * given device after its discovery.
+ *
+ * @return the activity class for pairing/initial authentication, or null if none
+ */
+ @Nullable
+ Class<? extends Activity> getPairingActivity();
+
+ @Nullable
+ Class<? extends Activity> getCalibrationActivity();
+
+ /**
+ * Returns true if activity data fetching is supported by the device
+ * (with this coordinator).
+ * This enables the sync button in control center and the device can thus be asked to send the data
+ * (as opposed the device pushing the data to us by itself)
+ *
+ * @return
+ */
+ boolean supportsActivityDataFetching();
+
+ /**
+ * Returns true if activity tracking is supported by the device
+ * (with this coordinator).
+ * This enables the ActivityChartsActivity.
+ *
+ * @return
+ */
+ boolean supportsActivityTracking();
+
+ /**
+ * Returns true if cycling data is supported by the device
+ * (with this coordinator).
+ * This enables the ChartsActivity.
+ *
+ * @return
+ */
+ boolean supportsCyclingData();
+
+ /**
+ * Indicates whether the device supports recording dedicated activity tracks, like
+ * walking, hiking, running, swimming, etc. and retrieving the recorded
+ * data. This is different from the constant activity tracking since the tracks are
+ * usually recorded with additional features, like e.g. GPS.
+ */
+ boolean supportsActivityTracks();
+
+ /**
+ * Returns true if stress measurement and fetching is supported by the device
+ * (with this coordinator).
+ */
+ boolean supportsStressMeasurement();
+
+ boolean supportsBodyEnergy();
+ boolean supportsHrvMeasurement();
+ boolean supportsVO2Max();
+ boolean supportsVO2MaxCycling();
+ boolean supportsVO2MaxRunning();
+ boolean supportsSleepMeasurement();
+ boolean supportsStepCounter();
+ boolean supportsSpeedzones();
+ boolean supportsActivityTabs();
+ boolean supportsActiveCalories();
+
+ /**
+ * Returns true if measurement and fetching of body temperature is supported by the device
+ * (with this coordinator).
+ */
+ boolean supportsTemperatureMeasurement();
+
+ /**
+ * Returns true if continuous temperature measurement used in device
+ * (with this coordinator).
+ */
+ boolean supportsContinuousTemperature();
+
+ /**
+ * Returns true if SpO2 measurement and fetching is supported by the device
+ * (with this coordinator).
+ */
+ boolean supportsSpo2(GBDevice device);
+
+ /**
+ * Returns true if heart rate stats (max, resting, manual) measurement and fetching is supported
+ * by the device (with this coordinator).
+ */
+ boolean supportsHeartRateStats();
+
+ /**
+ * Returns true if PAI (Personal Activity Intelligence) measurement and fetching is supported by
+ * the device (with this coordinator).
+ */
+ boolean supportsPai();
+
+ /**
+ * Returns the device-specific name for PAI (eg. Vitality Score).
+ */
+ @StringRes
+ int getPaiName();
+
+ /**
+ * Returns true if the device is capable of providing the time contribution for each PAI type
+ * (light, moderate, high).
+ */
+ boolean supportsPaiTime();
+
+ /**
+ * Returns true if the device is capable of providing the time contribution for light PAI type.
+ */
+ boolean supportsPaiLow();
+
+ /**
+ * Returns the PAI target - usually 100.
+ */
+ int getPaiTarget();
+
+ /**
+ * Indicates whether the device supports respiratory rate tracking.
+ */
+ boolean supportsRespiratoryRate();
+
+ /**
+ * Indicates whether the device tracks respiratory rate during the day, will be false
+ * if only during the night.
+ */
+ boolean supportsDayRespiratoryRate();
+
+ /**
+ * Returns true if sleep respiratory rate measurement and fetching is supported by
+ * the device (with this coordinator).
+ */
+ boolean supportsSleepRespiratoryRate();
+
+ /**
+ * Returns true if measurement and fetching of body weight is supported by the device
+ * (with this coordinator).
+ */
+ boolean supportsWeightMeasurement();
+
+ /**
+ * Returns true if activity data fetching is supported AND possible at this
+ * very moment. This will consider the device state (being connected/disconnected/busy...)
+ * etc.
+ *
+ * @param device
+ * @return
+ */
+ boolean allowFetchActivityData(GBDevice device);
+
+ /**
+ * Returns the sample provider for the device being supported.
+ *
+ * @return
+ */
+ SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for stress data, for the device being supported.
+ */
+ TimeSampleProvider<? extends StressSample> getStressSampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for body energy data, for the device being supported.
+ */
+ TimeSampleProvider<? extends BodyEnergySample> getBodyEnergySampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for HRV summary, for the device being supported.
+ */
+ TimeSampleProvider<? extends HrvSummarySample> getHrvSummarySampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for HRV values, for the device being supported.
+ */
+ TimeSampleProvider<? extends HrvValueSample> getHrvValueSampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for VO2 max values, for the device being supported.
+ */
+ TimeSampleProvider<? extends Vo2MaxSample> getVo2MaxSampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the stress ranges (relaxed, mild, moderate, high), so that stress can be categorized.
+ */
+ int[] getStressRanges();
+
+ /**
+ * Returns the sample provider for temperature data, for the device being supported.
+ */
+ TimeSampleProvider<? extends TemperatureSample> getTemperatureSampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for SpO2 data, for the device being supported.
+ */
+ TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for Cycling data, for the device being supported.
+ */
+ TimeSampleProvider<CyclingSample> getCyclingSampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for max HR data, for the device being supported.
+ */
+ TimeSampleProvider<? extends HeartRateSample> getHeartRateMaxSampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for resting HR data, for the device being supported.
+ */
+ TimeSampleProvider<? extends HeartRateSample> getHeartRateRestingSampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for manual HR data, for the device being supported.
+ */
+ TimeSampleProvider<? extends HeartRateSample> getHeartRateManualSampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for PAI data, for the device being supported.
+ */
+ TimeSampleProvider<? extends PaiSample> getPaiSampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for sleep respiratory rate data, for the device being supported.
+ */
+ TimeSampleProvider<? extends RespiratoryRateSample> getRespiratoryRateSampleProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the sample provider for weight data, for the device being supported.
+ */
+ TimeSampleProvider<? extends WeightSample> getWeightSampleProvider(GBDevice device, DaoSession session);
+
+ TimeSampleProvider<? extends RestingMetabolicRateSample> getRestingMetabolicRateProvider(GBDevice device, DaoSession session);
+
+ TimeSampleProvider<? extends SleepScoreSample> getSleepScoreProvider(GBDevice device, DaoSession session);
+
+ /**
+ * Returns the {@link ActivitySummaryParser} for the device being supported.
+ *
+ * @return
+ */
+ ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context);
+
+ /**
+ * Returns true if this device/coordinator supports installing files like firmware,
+ * watchfaces, gps, resources, fonts...
+ *
+ * @return
+ */
+ boolean supportsFlashing();
+
+ /**
+ * Finds an install handler for the given uri that can install the given
+ * uri on the device being managed.
+ *
+ * @param uri
+ * @param context
+ * @return the install handler or null if that uri cannot be installed on the device
+ */
+ InstallHandler findInstallHandler(Uri uri, Context context);
+
+ /**
+ * Returns true if this device/coordinator supports taking screenshots.
+ *
+ * @return
+ */
+ boolean supportsScreenshots(GBDevice device);
+
+ /**
+ * Returns the number of alarms this device/coordinator supports
+ * Shall return 0 also if it is not possible to set alarms via
+ * protocol, but only on the smart device itself.
+ *
+ * @return
+ */
+ int getAlarmSlotCount(GBDevice device);
+
+ /**
+ * Returns true if this device/coordinator supports an alarm with smart wakeup for the current position
+ * @param alarmPosition Position of the alarm
+ */
+ boolean supportsSmartWakeup(GBDevice device, int alarmPosition);
+
+ /**
+ * Returns true if the smart alarm at the specified position supports setting an interval for this device/coordinator
+ * @param alarmPosition Position of the alarm
+ */
+ boolean supportsSmartWakeupInterval(GBDevice device, int alarmPosition);
+
+ /**
+ * Returns true if the alarm at the specified position *must* be a smart alarm for this device/coordinator
+ * @param alarmPosition Position of the alarm
+ * @return True if it must be a smart alarm, false otherwise
+ */
+ boolean forcedSmartWakeup(GBDevice device, int alarmPosition);
+
+ /**
+ * Returns true if this device/coordinator supports alarm snoozing
+ * @return
+ */
+ boolean supportsAlarmSnoozing();
+
+ /**
+ * Returns true if this device/coordinator supports alarm titles
+ * @return
+ */
+ boolean supportsAlarmTitle(GBDevice device);
+
+ /**
+ * Returns the character limit for the alarm title, negative if no limit.
+ * @return
+ */
+ int getAlarmTitleLimit(GBDevice device);
+
+ /**
+ * Returns true if this device/coordinator supports alarm descriptions
+ * @return
+ */
+ boolean supportsAlarmDescription(GBDevice device);
+
+ /**
+ * Returns true if the given device supports heart rate measurements.
+ * @return
+ */
+ boolean supportsHeartRateMeasurement(GBDevice device);
+
+ /**
+ * Returns true if the given device supports resting heart rate measurements.
+ */
+ boolean supportsHeartRateRestingMeasurement(GBDevice device);
+
+ /**
+ * Returns true if the device supports triggering manual one-shot heart rate measurements.
+ */
+ boolean supportsManualHeartRateMeasurement(GBDevice device);
+
+ /**
+ * Returns the readable name of the manufacturer.
+ */
+ String getManufacturer();
+
+ /**
+ * Returns true if this device/coordinator supports managing device apps.
+ *
+ * @return
+ */
+ boolean supportsAppsManagement(GBDevice device);
+
+ boolean supportsCachedAppManagement(GBDevice device);
+ boolean supportsInstalledAppManagement(GBDevice device);
+ boolean supportsWatchfaceManagement(GBDevice device);
+
+ /**
+ * Returns the Activity class that will be used to manage device apps.
+ *
+ * @return
+ */
+ Class<? extends Activity> getAppsManagementActivity();
+
+ /**
+ * Returns the Activity class that will be used to design watchfaces.
+ *
+ * @return
+ */
+ Class<? extends Activity> getWatchfaceDesignerActivity();
+
+ /**
+ * Returns the device app cache directory.
+ */
+ File getAppCacheDir() throws IOException;
+
+ /**
+ * Returns the dedicated writable export directory for this device.
+ */
+ File getWritableExportDirectory(GBDevice device) throws IOException;
+
+ /**
+ * Returns a String containing the device app sort order filename.
+ */
+ String getAppCacheSortFilename();
+
+ /**
+ * Returns a String containing the file extension for watch apps.
+ */
+ String getAppFileExtension();
+
+ /**
+ * Indicated whether the device supports fetching a list of its apps.
+ */
+ boolean supportsAppListFetching();
+
+ /**
+ * Indicates whether the device supports reordering of apps.
+ */
+ boolean supportsAppReordering();
+
+ /**
+ * Returns how/if the given device should be bonded before connecting to it.
+ */
+ int getBondingStyle();
+
+ /**
+ * Whether it is recommended to unbind the device before pairing due to compatibility issues. Returns false
+ * if the device is known to pair without issues even when already bound in Android bluetooth settings.
+ */
+ boolean suggestUnbindBeforePair();
+
+ /**
+ * Returns true if this device is in an experimental state / not tested.
+ */
+ boolean isExperimental();
+
+ /**
+ * Indicates whether the device has some kind of calender we can sync to.
+ * Also used for generated sunrise/sunset events
+ */
+ boolean supportsCalendarEvents();
+
+ /**
+ * Indicates whether the device supports getting a stream of live data.
+ * This can be live HR, steps etc.
+ */
+ boolean supportsRealtimeData();
+
+ /**
+ * Indicates whether the device supports REM sleep tracking.
+ */
+ boolean supportsRemSleep();
+
+ /**
+ * Indicates whether the device supports Awake sleep tracking.
+ */
+ boolean supportsAwakeSleep();
+
+ /**
+ * Indicates whether the device supports determining a sleep score in a 0-100 range.
+ */
+ boolean supportsSleepScore();
+
+ /**
+ * Indicates whether the device supports current weather and/or weather
+ * forecast display.
+ */
+ boolean supportsWeather();
+
+ /**
+ * Indicates whether the device supports being found by vibrating,
+ * making some sound or lighting up
+ */
+ boolean supportsFindDevice();
+
+ /**
+ * Indicates whether the device supports displaying music information
+ * like artist, title, album, play state etc.
+ */
+ boolean supportsMusicInfo();
+
+ /**
+ * Indicates whether the device supports features required by Sleep As Android
+ */
+ boolean supportsSleepAsAndroid();
+
+ /**
+ * Indicates the maximum reminder message length.
+ */
+ int getMaximumReminderMessageLength();
+
+ /**
+ * Indicates the maximum number of reminder slots available in the device.
+ */
+ int getReminderSlotCount(GBDevice device);
+
+ /**
+ * Indicates whether reminders have a time of day.
+ */
+ boolean getRemindersHaveTime();
+
+ /**
+ * Indicates whether some reminder slots are used for calendar events.
+ */
+ boolean getReserveReminderSlotsForCalendar();
+
+ /**
+ * Indicates the maximum number of canned replies available in the device.
+ */
+ int getCannedRepliesSlotCount(GBDevice device);
+
+ /**
+ * Indicates the maximum number of slots available for world clocks in the device.
+ */
+ int getWorldClocksSlotCount();
+
+ /**
+ * Indicates the maximum label length for a world clock in the device.
+ */
+ int getWorldClocksLabelLength();
+
+ /**
+ * Indicates whether the device supports disabled world clocks that can be enabled through
+ * a menu on the device.
+ */
+ boolean supportsDisabledWorldClocks();
+
+ /**
+ * Indicates the maximum number of slots available for contacts in the device.
+ */
+ int getContactsSlotCount(GBDevice device);
+
+ /**
+ * Indicates whether the device has an led which supports custom colors
+ */
+ boolean supportsLedColor();
+
+ /**
+ * Indicates whether the device's led supports any RGB color,
+ * or only preset colors
+ */
+ boolean supportsRgbLedColor();
+
+ /**
+ * Returns the preset colors supported by the device, if any, in ARGB, with alpha = 255
+ */
+ @NonNull
+ int[] getColorPresets();
+
+ /**
+ * Indicates whether the device supports unicode emojis.
+ */
+ boolean supportsUnicodeEmojis();
+
+ /**
+ * Returns the set of supported sleep as Android features
+ * @return Set
+ */
+ Set<SleepAsAndroidFeature> getSleepAsAndroidFeatures();
+
+ /**
+ * Returns device specific settings related to connection
+ *
+ * @return int[]
+ */
+ int[] getSupportedDeviceSpecificConnectionSettings();
+
+ /**
+ * Returns device specific settings related to the Auth key
+ * @return int[]
+ */
+ int[] getSupportedDeviceSpecificAuthenticationSettings();
+
+ /**
+ * Indicates which device specific settings the device supports (not per device type or family, but unique per device).
+ *
+ * @deprecated use getDeviceSpecificSettings
+ */
+ @Deprecated
+ int[] getSupportedDeviceSpecificSettings(GBDevice device);
+
+ /**
+ * Returns the device-specific settings supported by this specific device. See
+ * {@link DeviceSpecificSettings} for more information
+ */
+ @Nullable
+ DeviceSpecificSettings getDeviceSpecificSettings(GBDevice device);
+
+ /**
+ * Returns the {@link DeviceSpecificSettingsCustomizer}, allowing for the customization of the devices specific settings screen.
+ */
+ DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(GBDevice device);
+
+ /**
+ * Indicates which device specific language the device supports
+ */
+ String[] getSupportedLanguageSettings(GBDevice device);
+
+ /**
+ *
+ * Multiple battery support: Indicates how many batteries the device has.
+ * 1 is default, 3 is maximum at the moment (as per UI layout)
+ * 0 will disable the battery from the UI
+ */
+ int getBatteryCount();
+
+ BatteryConfig[] getBatteryConfig(GBDevice device);
+
+ boolean addBatteryPollingSettings();
+
+ boolean supportsPowerOff();
+
+ PasswordCapabilityImpl.Mode getPasswordCapability();
+
+ List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals();
+
+ /**
+ * Whether the device supports screens with configurable widgets.
+ */
+ boolean supportsWidgets(GBDevice device);
+
+ /**
+ * Gets the {@link WidgetManager} for this device. Must not be null if supportsWidgets is true.
+ */
+ @Nullable
+ WidgetManager getWidgetManager(GBDevice device);
+
+ boolean supportsNavigation();
+
+ int getOrderPriority();
+
+ @NonNull
+ Class<? extends DeviceSupport> getDeviceSupportClass();
+
+ EnumSet<ServiceDeviceSupport.Flags> getInitialFlags();
+
+ @StringRes
+ int getDeviceNameResource();
+
+ @DrawableRes
+ int getDefaultIconResource();
+
+ @DrawableRes
+ int getDisabledIconResource();
+
+ /**
+ * Whether the device supports a variety of vibration patterns for notifications.
+ */
+ boolean supportsNotificationVibrationPatterns();
+ /**
+ * Whether the device supports a variety of vibration pattern repetitions for notifications.
+ */
+ boolean supportsNotificationVibrationRepetitionPatterns();
+
+ /**
+ * Whether the device supports a variety of LED patterns for notifications.
+ */
+ boolean supportsNotificationLedPatterns();
+ /**
+ * What vibration pattern repetitions for notifications are supported by the device.
+ */
+ AbstractNotificationPattern[] getNotificationVibrationPatterns();
+ /**
+ * What vibration pattern repetitions for notifications are supported by the device.
+ * Technote: this is not an int or a range because some devices (e.g. Wena 3) only allow
+ * a very specific set of value combinations here.
+ */
+ AbstractNotificationPattern[] getNotificationVibrationRepetitionPatterns();
+ /**
+ * What LED patterns for notifications are supported by the device.
+ */
+ AbstractNotificationPattern[] getNotificationLedPatterns();
+
+ boolean validateAuthKey(String authKey);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceManager.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceManager.java
new file mode 100644
index 0000000..43b3450
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceManager.java
@@ -0,0 +1,196 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
+ Dakhno, Daniele Gobbetti, Gordon Williams, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+
+import androidx.core.content.ContextCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.Collator;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+/**
+ * Provides access to the list of devices managed by Gadgetbridge.
+ * Changes to the devices (e.g. connection state) or the list of devices
+ * are broadcasted via #ACTION_DEVICE_CHANGED
+ */
+public class DeviceManager {
+ private static final Logger LOG = LoggerFactory.getLogger(DeviceManager.class);
+
+ public static final String BLUETOOTH_DEVICE_ACTION_ALIAS_CHANGED = "android.bluetooth.device.action.ALIAS_CHANGED";
+ /**
+ * Intent action to notify that the list of devices has changed.
+ */
+ public static final String ACTION_DEVICES_CHANGED
+ = "nodomain.freeyourgadget.gadgetbridge.devices.devicemanager.action.devices_changed";
+ /**
+ * Intent action to notify this class that the list of devices shall be refreshed.
+ */
+ public static final String ACTION_REFRESH_DEVICELIST
+ = "nodomain.freeyourgadget.gadgetbridge.devices.devicemanager.action.set_version";
+ private final Context context;
+ /**
+ * This list is final, it will never be recreated. Only its contents change.
+ * This allows direct access to the list from ListAdapters.
+ */
+ private final List<GBDevice> deviceList = new ArrayList<>();
+ private List<GBDevice> selectedDevices = new ArrayList<>();
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ switch (action) {
+ case ACTION_REFRESH_DEVICELIST: // fall through
+ case BluetoothDevice.ACTION_BOND_STATE_CHANGED:
+ refreshPairedDevices();
+ break;
+ case BluetoothDevice.ACTION_NAME_CHANGED:
+ case BLUETOOTH_DEVICE_ACTION_ALIAS_CHANGED:
+ BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ String newName = intent.getStringExtra(BluetoothDevice.EXTRA_NAME);
+ if (device!=null)
+ updateDeviceName(device, newName);
+ break;
+ case GBDevice.ACTION_DEVICE_CHANGED:
+ GBDevice dev = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (dev.getAddress() != null) {
+ int index = deviceList.indexOf(dev); // search by address
+ if (index >= 0) {
+ deviceList.get(index).copyFromDevice(dev);
+ } else {
+ deviceList.add(dev);
+ }
+ if (dev.isInitialized()) {
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ DBHelper.getDevice(dev, dbHandler.getDaoSession()); // implicitly creates the device in database if not present, and updates device attributes
+ } catch (Exception ignore) {
+ }
+ }
+ }
+ updateSelectedDevice(dev);
+ refreshPairedDevices();
+ break;
+ }
+ }
+ };
+
+ public DeviceManager(Context context) {
+ this.context = context;
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(DeviceManager.ACTION_REFRESH_DEVICELIST);
+ filterLocal.addAction(GBDevice.ACTION_DEVICE_CHANGED);
+ filterLocal.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
+ LocalBroadcastManager.getInstance(context).registerReceiver(mReceiver, filterLocal);
+
+ IntentFilter filterGlobal = new IntentFilter();
+ filterGlobal.addAction(BluetoothDevice.ACTION_NAME_CHANGED);
+ filterGlobal.addAction(BLUETOOTH_DEVICE_ACTION_ALIAS_CHANGED);
+ filterGlobal.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
+ ContextCompat.registerReceiver(context, mReceiver, filterGlobal, ContextCompat.RECEIVER_EXPORTED);
+
+ refreshPairedDevices();
+ }
+
+ private void updateDeviceName(BluetoothDevice device, String newName) {
+ for (GBDevice dev : deviceList) {
+ if (device.getAddress().equals(dev.getAddress())) {
+ if (!dev.getName().equals(newName)) {
+ dev.setName(newName);
+ notifyDevicesChanged();
+ return;
+ }
+ }
+ }
+ }
+
+ private void updateSelectedDevice(GBDevice dev) {
+ selectedDevices.clear();
+ for(GBDevice device : deviceList){
+ if(device.isInitialized()){
+ selectedDevices.add(device);
+ }
+ }
+ GB.updateNotification(selectedDevices, context);
+ }
+
+ private void refreshPairedDevices() {
+ Set<GBDevice> availableDevices = DeviceHelper.getInstance().getAvailableDevices(context);
+ deviceList.retainAll(availableDevices);
+ for (GBDevice availableDevice : availableDevices) {
+ if (!deviceList.contains(availableDevice)) {
+ deviceList.add(availableDevice);
+ }
+ }
+
+ Collections.sort(deviceList, new Comparator<GBDevice>() {
+ @Override
+ public int compare(GBDevice lhs, GBDevice rhs) {
+ if (rhs.getStateOrdinal() - lhs.getStateOrdinal() == 0) {
+ return Collator.getInstance().compare(lhs.getAliasOrName(), rhs.getAliasOrName());
+ }
+ return (rhs.getStateOrdinal() - lhs.getStateOrdinal());
+ }
+ });
+ notifyDevicesChanged();
+ }
+
+ /**
+ * The returned list is final, it will never be recreated. Only its contents change.
+ * This allows direct access to the list from ListAdapters.
+ */
+ public List<GBDevice> getDevices() {
+ return Collections.unmodifiableList(deviceList);
+ }
+
+ public GBDevice getDeviceByAddress(String address){
+ for(GBDevice device : deviceList){
+ if(device.getAddress().compareToIgnoreCase(address) == 0){
+ return device;
+ }
+ }
+ return null;
+ }
+
+ public List<GBDevice> getSelectedDevices() {
+ return selectedDevices;
+ }
+
+ private void notifyDevicesChanged() {
+ LocalBroadcastManager.getInstance(context).sendBroadcast(new Intent(ACTION_DEVICES_CHANGED));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java
new file mode 100644
index 0000000..a9be0fb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java
@@ -0,0 +1,162 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Jos茅 Rebelo, Julien Pivotto, Kasha, Sebastian Kranz, Steffen
+ Liebergeld, Uwe Hermann
+
+ 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.devices;
+
+import android.location.Location;
+import android.net.Uri;
+import android.os.Bundle;
+
+import java.util.ArrayList;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCameraRemote;
+import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.Contact;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NavigationInfoSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.Reminder;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.WorldClock;
+
+/**
+ * Specifies all events that Gadgetbridge intends to send to the gadget device.
+ * Implementations can decide to ignore events that they do not support.
+ * Implementations need to send/encode event to the connected device.
+ */
+public interface EventHandler {
+ void onNotification(NotificationSpec notificationSpec);
+
+ void onDeleteNotification(int id);
+
+ void onSetTime();
+
+ void onSetAlarms(ArrayList<? extends Alarm> alarms);
+
+ void onSetReminders(ArrayList<? extends Reminder> reminders);
+
+ void onSetLoyaltyCards(ArrayList<LoyaltyCard> cards);
+
+ void onSetWorldClocks(ArrayList<? extends WorldClock> clocks);
+
+ void onSetContacts(ArrayList<? extends Contact> contacts);
+
+ void onSetCallState(CallSpec callSpec);
+
+ void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec);
+
+ void onSetMusicState(MusicStateSpec stateSpec);
+
+ void onSetMusicInfo(MusicSpec musicSpec);
+
+ /**
+ * Sets the current phone media volume.
+ *
+ * @param volume the volume percentage (0 to 100).
+ */
+ void onSetPhoneVolume(final float volume);
+
+ void onChangePhoneSilentMode(int ringerMode);
+
+ void onSetNavigationInfo(NavigationInfoSpec navigationInfoSpec);
+
+ void onEnableRealtimeSteps(boolean enable);
+
+ void onInstallApp(Uri uri);
+
+ void onAppInfoReq();
+
+ void onAppStart(UUID uuid, boolean start);
+
+ void onAppDownload(UUID uuid);
+
+ void onAppDelete(UUID uuid);
+
+ void onAppConfiguration(UUID appUuid, String config, Integer id);
+
+ void onAppReorder(UUID[] uuids);
+
+ void onFetchRecordedData(int dataTypes);
+
+ void onReset(int flags);
+
+ void onHeartRateTest();
+
+ void onEnableRealtimeHeartRateMeasurement(boolean enable);
+
+ void onFindDevice(boolean start);
+
+ void onFindPhone(boolean start);
+
+ void onSetConstantVibration(int integer);
+
+ void onScreenshotReq();
+
+ void onEnableHeartRateSleepSupport(boolean enable);
+
+ void onSetHeartRateMeasurementInterval(int seconds);
+
+ void onAddCalendarEvent(CalendarEventSpec calendarEventSpec);
+
+ void onDeleteCalendarEvent(byte type, long id);
+
+ /**
+ * Sets the given option in the device, typically with values from the preferences.
+ * The config name is device specific.
+ * @param config the device specific option to set on the device
+ */
+ void onSendConfiguration(String config);
+
+ /**
+ * Gets the given option from the device, sets the values in the preferences.
+ * The config name is device specific.
+ * @param config the device specific option to get from the device
+ */
+ void onReadConfiguration(String config);
+
+ void onTestNewFunction();
+
+ void onSendWeather(ArrayList<WeatherSpec> weatherSpecs);
+
+ void onSetFmFrequency(float frequency);
+
+ /**
+ * Set the device's led color.
+ * @param color the new color, in ARGB, with alpha = 255
+ */
+ void onSetLedColor(int color);
+
+ void onPowerOff();
+
+ void onSetGpsLocation(Location location);
+
+ void onSleepAsAndroidAction(String action, Bundle extras);
+
+ void onCameraStatusChange(GBDeviceEventCameraRemote.Event event, String filename);
+
+
+ void onMusicListReq();
+
+ void onMusicOperation(int operation, int playlistIndex, String playlistName, ArrayList<Integer> musicIds);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/HeartPulseSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/HeartPulseSampleProvider.java
new file mode 100644
index 0000000..33b27c2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/HeartPulseSampleProvider.java
@@ -0,0 +1,55 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.entities.HeartPulseSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HeartPulseSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class HeartPulseSampleProvider extends AbstractTimeSampleProvider<HeartPulseSample> {
+ public HeartPulseSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<HeartPulseSample, ?> getSampleDao() {
+ return getSession().getHeartPulseSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HeartPulseSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HeartPulseSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public HeartPulseSample createSample() {
+ return new HeartPulseSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/InstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/InstallHandler.java
new file mode 100644
index 0000000..87556b8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/InstallHandler.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.devices;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+/**
+ * Interface for the UI side of certain kinds of installation of things on the
+ * gadget device. The actual element to install will be passed in the constructor.
+ */
+public interface InstallHandler {
+
+ /**
+ * Returns true if this handler is able to install the element.
+ * #validateInstallation may only be called if this method returned true.
+ */
+ boolean isValid();
+
+ /**
+ * Checks whether the installation of the 'element' on the device is possible
+ * and configures the InstallActivity accordingly (sets helpful texts,
+ * enables/disables the "Install" button, etc.
+ * <p/>
+ * Note: may only be called if #isValid previously returned true.
+ *
+ * @param installActivity the activity to interact with
+ * @param device the device to which the element shall be installed
+ */
+ void validateInstallation(InstallActivity installActivity, GBDevice device);
+
+ /**
+ * Allows device specific code to be executed just before the installation starts
+ */
+ void onStartInstall(GBDevice device);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/PendingFileProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/PendingFileProvider.java
new file mode 100644
index 0000000..729911e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/PendingFileProvider.java
@@ -0,0 +1,100 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+import de.greenrobot.dao.Property;
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.PendingFile;
+import nodomain.freeyourgadget.gadgetbridge.entities.PendingFileDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public final class PendingFileProvider {
+ private final DaoSession mSession;
+ private final GBDevice mDevice;
+
+ public PendingFileProvider(final GBDevice device, final DaoSession session) {
+ mDevice = device;
+ mSession = session;
+ }
+
+ @NonNull
+ public List<PendingFile> getAllPendingFiles() {
+ final QueryBuilder<PendingFile> qb = mSession.getPendingFileDao().queryBuilder();
+ final Device dbDevice = DBHelper.findDevice(mDevice, mSession);
+ if (dbDevice == null) {
+ // no device, no pending files
+ return Collections.emptyList();
+ }
+ final Property deviceProperty = PendingFileDao.Properties.DeviceId;
+ qb.where(deviceProperty.eq(dbDevice.getId()));
+ final List<PendingFile> ret = qb.build().list();
+ mSession.getPendingFileDao().detachAll();
+ return ret;
+ }
+
+ public void removePendingFile(final String path) {
+ final PendingFile pendingFile = findByPath(path);
+ if (pendingFile != null) {
+ pendingFile.delete();
+ }
+ }
+
+ public void addPendingFile(final String path) {
+ final PendingFile existingFile = findByPath(path);
+ if (existingFile != null) {
+ return;
+ }
+
+ final Device device = DBHelper.getDevice(mDevice, mSession);
+
+ final PendingFile pendingFile = new PendingFile();
+ pendingFile.setPath(path);
+ pendingFile.setDevice(device);
+
+ addPendingFile(pendingFile);
+ }
+
+ public void addPendingFile(final PendingFile pendingFile) {
+ mSession.getPendingFileDao().insertOrReplace(pendingFile);
+ }
+
+ @Nullable
+ private PendingFile findByPath(final String path) {
+ final Device device = DBHelper.getDevice(mDevice, mSession);
+
+ final PendingFileDao pendingFileDao = mSession.getPendingFileDao();
+ final QueryBuilder<PendingFile> qb = pendingFileDao.queryBuilder();
+ qb.where(PendingFileDao.Properties.DeviceId.eq(device.getId()));
+ qb.where(PendingFileDao.Properties.Path.eq(path));
+ final List<PendingFile> pendingFiles = qb.build().list();
+ if (!pendingFiles.isEmpty()) {
+ return pendingFiles.get(0);
+ }
+ return null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SampleProvider.java
new file mode 100644
index 0000000..e35e6cd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SampleProvider.java
@@ -0,0 +1,122 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Joa虄o Paulo Barraca, Petr Van臎k
+
+ 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.devices;
+
+import java.util.List;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+/**
+ * Interface to retrieve samples from the database, and also create and add samples to the database.
+ * There are multiple device specific implementations, this interface defines the generic access.
+ *
+ * Note that the provided samples must typically be considered read-only, because they are immediately
+ * removed from the session before they are returned.
+ *
+ * @param <T> the device/provider specific sample type (must extend AbstractActivitySample)
+ */
+public interface SampleProvider<T extends AbstractActivitySample> {
+ // These are only used for SharedPreferences
+ int PROVIDER_PEBBLE_MORPHEUZ = 1;
+ int PROVIDER_PEBBLE_MISFIT = 3;
+ int PROVIDER_PEBBLE_HEALTH = 4;
+
+ ActivityKind normalizeType(int rawType);
+
+ int toRawActivityKind(ActivityKind activityKind);
+
+ float normalizeIntensity(int rawIntensity);
+
+ /**
+ * Returns the list of all samples, of any type, within the given time span.
+ * This returns exactly one sample every minute.
+ * @param timestamp_from the start timestamp
+ * @param timestamp_to the end timestamp
+ * @return the list of samples of any type
+ */
+ @NonNull
+ List<T> getAllActivitySamples(int timestamp_from, int timestamp_to);
+
+ /**
+ * Same as {@link #getAllActivitySamples(int, int)}}, but returns as many samples as possible.
+ * Explicitly does not make a guarantee about how many samples there are per timeframe, which
+ * can also change over time.
+ */
+ List<T> getAllActivitySamplesHighRes(int timestamp_from, int timestamp_to);
+
+ /**
+ * Specifies that the sample provider has higher resolution data. Set to true if the sample
+ * provider can provide more than one sample a minute.
+ */
+ boolean hasHighResData();
+
+ /**
+ * Returns the list of all samples that represent user "activity", within
+ * the given time span. This excludes samples of type sleep, for example.
+ * @param timestamp_from the start timestamp
+ * @param timestamp_to the end timestamp
+ * @return the list of samples of type user activity, e.g. non-sleep
+ */
+ @NonNull
+ List<T> getActivitySamples(int timestamp_from, int timestamp_to);
+
+ /**
+ * Adds the given sample to the database. An existing sample with the same
+ * timestamp will be overwritten.
+ * @param activitySample the sample to add
+ */
+ void addGBActivitySample(T activitySample);
+
+ /**
+ * Adds the given samples to the database. Existing samples with the same
+ * timestamp will be overwritten.
+ * @param activitySamples the samples to add
+ */
+ void addGBActivitySamples(T[] activitySamples);
+
+ /**
+ * Factory method to creates an empty sample of the correct type for this sample provider
+ * @return the newly created "empty" sample
+ */
+ T createActivitySample();
+
+ /**
+ * Returns the activity sample with the highest timestamp. or null if none
+ * @return the latest sample or null
+ */
+ @Nullable
+ T getLatestActivitySample();
+
+ /**
+ * Returns the activity sample with the highest timestamp, until a limit (inclusive). or null if none
+ * @return the latest sample or null
+ */
+ @Nullable
+ T getLatestActivitySample(int until);
+
+ /**
+ * Returns the activity sample with the oldest timestamp or null if none
+ * @return the oldest sample or null
+ */
+ @Nullable
+ T getFirstActivitySample();
+
+}
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 0000000..8092350
--- /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/TimeSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/TimeSampleProvider.java
new file mode 100644
index 0000000..475c646
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/TimeSampleProvider.java
@@ -0,0 +1,99 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.model.TimeSample;
+
+/**
+ * Interface to retrieve samples from the database, and also create and add samples to the database.
+ * There are multiple device specific implementations, this interface defines the generic access.
+ * <p>
+ * Note that the provided samples must typically be considered read-only, because they are immediately
+ * removed from the session before they are returned.
+ * <p>
+ * This differs from SampleProvider by assuming milliseconds for timestamps instead of seconds, as well as
+ * not enforcing an all-in-once ActivitySample interface for data that might be completely unrelated with
+ * activity data.
+ *
+ * @param <T> the device/provider specific sample type (must extend TimeSample).
+ */
+public interface TimeSampleProvider<T extends TimeSample> {
+ /**
+ * Returns the list of all samples, of any type, within the given time span.
+ *
+ * @param timestampFrom the start timestamp, in milliseconds
+ * @param timestampTo the end timestamp, in milliseconds
+ * @return the list of samples of any type
+ */
+ @NonNull
+ List<T> getAllSamples(long timestampFrom, long timestampTo);
+
+ /**
+ * Adds the given sample to the database. An existing sample with the same
+ * timestamp will be overwritten.
+ *
+ * @param timeSample the sample to add
+ */
+ void addSample(T timeSample);
+
+ /**
+ * Adds the given samples to the database. Existing samples with the same
+ * timestamp (and a potential combination of other attributes, depending on the sample implementation)
+ * will be overwritten.
+ *
+ * @param timeSamples the samples to add
+ */
+ void addSamples(List<T> timeSamples);
+
+ /**
+ * Factory method to creates an empty sample of the correct type for this sample provider.
+ *
+ * @return the newly created "empty" sample
+ */
+ T createSample();
+
+ /**
+ * Returns the sample with the highest timestamp, or null if none.
+ *
+ * @return the latest sample, or null if none is found.
+ */
+ @Nullable
+ T getLatestSample();
+
+ /**
+ * Returns the sample with the highest timestamp until a limit, or null if none.
+ *
+ * @param until maximum timestamp of the sample, inclusive
+ *
+ * @return the latest sample, or null if none is found.
+ */
+ @Nullable
+ T getLatestSample(long until);
+
+ /**
+ * Returns the sample with the oldest timestamp, or null if none.
+ *
+ * @return the oldest sample, or null if none is found
+ */
+ @Nullable
+ T getFirstSample();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/UnknownDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/UnknownDeviceCoordinator.java
new file mode 100644
index 0000000..d56035c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/UnknownDeviceCoordinator.java
@@ -0,0 +1,177 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien
+ Gaignon, Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import java.util.Collections;
+import java.util.List;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.unknown.UnknownDeviceSupport;
+
+public class UnknownDeviceCoordinator extends AbstractDeviceCoordinator {
+ private final UnknownSampleProvider sampleProvider;
+
+ private static final class UnknownSampleProvider implements SampleProvider<AbstractActivitySample> {
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.UNKNOWN;
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return 0;
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return 0;
+ }
+
+ @Override
+ public List<AbstractActivitySample> getAllActivitySamples(int timestamp_from, int timestamp_to) {
+ return null;
+ }
+
+ @Override
+ public List<AbstractActivitySample> getAllActivitySamplesHighRes(int timestamp_from, int timestamp_to) {
+ return null;
+ }
+
+ @Override
+ public boolean hasHighResData() {
+ return false;
+ }
+
+ @Override
+ public List<AbstractActivitySample> getActivitySamples(int timestamp_from, int timestamp_to) {
+ return null;
+ }
+
+ @Override
+ public void addGBActivitySample(AbstractActivitySample activitySample) {
+ }
+
+ @Override
+ public void addGBActivitySamples(AbstractActivitySample[] activitySamples) {
+ }
+
+ @Override
+ public AbstractActivitySample createActivitySample() {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public AbstractActivitySample getLatestActivitySample() {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public AbstractActivitySample getLatestActivitySample(final int until) {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public AbstractActivitySample getFirstActivitySample() {
+ return null;
+ }
+
+ }
+
+ public UnknownDeviceCoordinator() {
+ sampleProvider = new UnknownSampleProvider();
+ }
+
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ return false;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return ControlCenterv2.class;
+ }
+
+ @Override
+ public SampleProvider<?> getSampleProvider(GBDevice device, DaoSession session) {
+ return new UnknownSampleProvider();
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Generic";
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return UnknownDeviceSupport.class;
+ }
+
+ @Override
+ @StringRes
+ public int getDeviceNameResource() {
+ return R.string.devicetype_unknown;
+ }
+
+ @Override
+ @DrawableRes
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_unknown;
+ }
+
+ @Override
+ @DrawableRes
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_unknown_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/Vo2MaxSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/Vo2MaxSampleProvider.java
new file mode 100644
index 0000000..bddd6be
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/Vo2MaxSampleProvider.java
@@ -0,0 +1,15 @@
+package nodomain.freeyourgadget.gadgetbridge.devices;
+
+import androidx.annotation.Nullable;
+
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+
+public interface Vo2MaxSampleProvider<T extends Vo2MaxSample> extends TimeSampleProvider<T> {
+ @Nullable
+ T getLatestSample(Vo2MaxSample.Type type, long until);
+
+ @Nullable
+ default T getLatestSample(long until) {
+ return getLatestSample(Vo2MaxSample.Type.ANY, until);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/WorkoutVo2MaxSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/WorkoutVo2MaxSampleProvider.java
new file mode 100644
index 0000000..dd5c2d2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/WorkoutVo2MaxSampleProvider.java
@@ -0,0 +1,273 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+
+/**
+ * An implementation of {@link Vo2MaxSampleProvider} that extracts VO2 Max values from
+ * workouts, for devices that provide them as part of the workout data.
+ */
+public class WorkoutVo2MaxSampleProvider implements Vo2MaxSampleProvider<Vo2MaxSample> {
+ private static final Logger LOG = LoggerFactory.getLogger(WorkoutVo2MaxSampleProvider.class);
+
+ private final GBDevice device;
+ private final DaoSession session;
+
+ public WorkoutVo2MaxSampleProvider(final GBDevice device, final DaoSession session) {
+ this.device = device;
+ this.session = session;
+ }
+
+ @NonNull
+ @Override
+ public List<Vo2MaxSample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao();
+ final Device dbDevice = DBHelper.findDevice(device, session);
+ if (dbDevice == null) {
+ // no device, no samples
+ return Collections.emptyList();
+ }
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+
+ final QueryBuilder<BaseActivitySummary> qb = summaryDao.queryBuilder();
+ qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(dbDevice.getId()))
+ .where(BaseActivitySummaryDao.Properties.StartTime.gt(new Date(timestampFrom)))
+ .where(BaseActivitySummaryDao.Properties.StartTime.lt(new Date(timestampTo)))
+ .orderAsc(BaseActivitySummaryDao.Properties.StartTime);
+
+ final List<BaseActivitySummary> samples = qb.build().list();
+ summaryDao.detachAll();
+ fillSummaryData(coordinator, samples);
+
+ return samples.stream()
+ .map(GarminVo2maxSample::fromActivitySummary)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public void addSample(final Vo2MaxSample timeSample) {
+ throw new UnsupportedOperationException("Read-only sample provider");
+ }
+
+ @Override
+ public void addSamples(final List<Vo2MaxSample> timeSamples) {
+ throw new UnsupportedOperationException("Read-only sample provider");
+ }
+
+ @Override
+ public Vo2MaxSample createSample() {
+ throw new UnsupportedOperationException("Read-only sample provider");
+ }
+
+ @Nullable
+ @Override
+ public Vo2MaxSample getLatestSample(final Vo2MaxSample.Type type, final long until) {
+ final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao();
+ final Device dbDevice = DBHelper.findDevice(device, session);
+ if (dbDevice == null) {
+ // no device, no samples
+ return null;
+ }
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final QueryBuilder<BaseActivitySummary> qb = summaryDao.queryBuilder();
+
+ addWhereFilter(coordinator, qb, type);
+
+ if (until != 0) {
+ qb.where(BaseActivitySummaryDao.Properties.StartTime.le(new Date(until)));
+ }
+
+ qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(dbDevice.getId()))
+ .orderDesc(BaseActivitySummaryDao.Properties.StartTime)
+ .limit(1);
+
+ final List<BaseActivitySummary> samples = qb.build().list();
+ summaryDao.detachAll();
+ fillSummaryData(coordinator, samples);
+
+ return !samples.isEmpty() ? GarminVo2maxSample.fromActivitySummary(samples.get(0)) : null;
+ }
+
+ private static void addWhereFilter(final DeviceCoordinator coordinator,
+ final QueryBuilder<BaseActivitySummary> qb,
+ final Vo2MaxSample.Type type) {
+ final List<Integer> codes = new ArrayList<>();
+
+ if (coordinator.supportsVO2MaxRunning()) {
+ if (type == Vo2MaxSample.Type.ANY || type == Vo2MaxSample.Type.RUNNING) {
+ codes.addAll(Arrays.asList(
+ ActivityKind.INDOOR_RUNNING.getCode(),
+ ActivityKind.OUTDOOR_RUNNING.getCode(),
+ ActivityKind.CROSS_COUNTRY_RUNNING.getCode(),
+ ActivityKind.RUNNING.getCode()
+ ));
+ }
+ }
+ if (coordinator.supportsVO2MaxCycling()) {
+ if (type == Vo2MaxSample.Type.ANY || type == Vo2MaxSample.Type.CYCLING) {
+ codes.addAll(Arrays.asList(
+ ActivityKind.CYCLING.getCode(),
+ ActivityKind.INDOOR_CYCLING.getCode(),
+ ActivityKind.HANDCYCLING.getCode(),
+ ActivityKind.HANDCYCLING_INDOOR.getCode(),
+ ActivityKind.OUTDOOR_CYCLING.getCode()
+ ));
+ }
+ }
+
+ qb.where(BaseActivitySummaryDao.Properties.ActivityKind.in(codes));
+ }
+
+ private void fillSummaryData(final DeviceCoordinator coordinator,
+ final Collection<BaseActivitySummary> summaries) {
+ ActivitySummaryParser activitySummaryParser = coordinator.getActivitySummaryParser(device, GBApplication.getContext());
+ for (final BaseActivitySummary summary : summaries) {
+ if (summary.getSummaryData() == null) {
+ activitySummaryParser.parseBinaryData(summary, true);
+ }
+ }
+ }
+
+ @Nullable
+ @Override
+ public Vo2MaxSample getLatestSample() {
+ return getLatestSample(Vo2MaxSample.Type.ANY, 0);
+ }
+
+ @Nullable
+ @Override
+ public Vo2MaxSample getFirstSample() {
+ final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao();
+ final Device dbDevice = DBHelper.findDevice(device, session);
+ if (dbDevice == null) {
+ // no device, no samples
+ return null;
+ }
+
+ final QueryBuilder<BaseActivitySummary> qb = summaryDao.queryBuilder();
+ qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(dbDevice.getId()))
+ .orderAsc(BaseActivitySummaryDao.Properties.StartTime)
+ .limit(1);
+
+ final List<BaseActivitySummary> samples = qb.build().list();
+ summaryDao.detachAll();
+
+ return !samples.isEmpty() ? GarminVo2maxSample.fromActivitySummary(samples.get(0)) : null;
+ }
+
+
+ public static class GarminVo2maxSample implements Vo2MaxSample {
+ private final long timestamp;
+ private final Type type;
+ private final float value;
+
+ public GarminVo2maxSample(final long timestamp, final Type type, final float value) {
+ this.timestamp = timestamp;
+ this.type = type;
+ this.value = value;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public Type getType() {
+ return type;
+ }
+
+ @Override
+ public float getValue() {
+ return value;
+ }
+
+ @Nullable
+ public static GarminVo2maxSample fromActivitySummary(final BaseActivitySummary summary) {
+ final String summaryDataJson = summary.getSummaryData();
+ if (summaryDataJson == null) {
+ return null;
+ }
+
+ if (!summaryDataJson.contains(ActivitySummaryEntries.MAXIMUM_OXYGEN_UPTAKE)) {
+ return null;
+ }
+
+ final ActivitySummaryData summaryData = ActivitySummaryData.fromJson(summaryDataJson);
+ if (summaryData == null) {
+ return null;
+ }
+
+ final double value = summaryData.getNumber(ActivitySummaryEntries.MAXIMUM_OXYGEN_UPTAKE, 0).doubleValue();
+ if (value == 0) {
+ return null;
+ }
+
+ final Vo2MaxSample.Type type;
+ switch (ActivityKind.fromCode(summary.getActivityKind())) {
+ case INDOOR_RUNNING:
+ case OUTDOOR_RUNNING:
+ case CROSS_COUNTRY_RUNNING:
+ case RUNNING:
+ type = Vo2MaxSample.Type.RUNNING;
+ break;
+ case CYCLING:
+ case INDOOR_CYCLING:
+ case HANDCYCLING:
+ case HANDCYCLING_INDOOR:
+ case MOTORCYCLING:
+ case OUTDOOR_CYCLING:
+ type = Vo2MaxSample.Type.CYCLING;
+ break;
+ default:
+ type = Vo2MaxSample.Type.ANY;
+ }
+ return new GarminVo2maxSample(summary.getStartTime().getTime(), type, (float) value);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/amazfitbip/BipActivitySummary.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/amazfitbip/BipActivitySummary.java
new file mode 100644
index 0000000..b520592
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/amazfitbip/BipActivitySummary.java
@@ -0,0 +1,219 @@
+/* Copyright (C) 2017-2024 Carsten Pfeiffer, Daniele Gobbetti
+
+ 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.devices.amazfitbip;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+
+public class BipActivitySummary extends BaseActivitySummary {
+ private int version;
+ private float distanceMeters;
+ private float ascentMeters;
+ private float descentMeters;
+ private float minAltitude;
+ private float maxAltitude;
+ private int minLatitude;
+ private int maxLatitude;
+ private int minLongitude;
+ private int maxLongitude;
+ private long steps;
+ private long activeTimeSeconds;
+ private float caloriesBurnt;
+ private float maxSpeed;
+ private float minPace;
+ private float maxPace;
+ private float totalStride;
+ private long timeAscent;
+ private long timeDescent;
+ private long timeFlat;
+ private int averageHR;
+ private int averagePace;
+ private int averageStride;
+
+// @Override
+// public long getSteps() {
+// return steps;
+// }
+//
+// @Override
+// public float getDistanceMeters() {
+// return distanceMeters;
+// }
+//
+// @Override
+// public float getAscentMeters() {
+// return ascentMeters;
+// }
+//
+// @Override
+// public float getDescentMeters() {
+// return descentMeters;
+// }
+//
+// @Override
+// public float getMinAltitude() {
+// return minAltitude;
+// }
+//
+// @Override
+// public float getMaxAltitude() {
+// return maxAltitude;
+// }
+//
+// @Override
+// public float getCalories() {
+// return caloriesBurnt;
+// }
+//
+// @Override
+// public float getMaxSpeed() {
+// return maxSpeed;
+// }
+//
+// @Override
+// public float getMinSpeed() {
+// return minPace;
+// }
+//
+// @Override
+// public float getAverageSpeed() {
+// return averagePace;
+// }
+
+ public void setVersion(int version) {
+ this.version = version;
+ }
+
+ public int getVersion() {
+ return version;
+ }
+
+ public void setDistanceMeters(float distanceMeters) {
+ this.distanceMeters = distanceMeters;
+ }
+
+ public void setAscentMeters(float ascentMeters) {
+ this.ascentMeters = ascentMeters;
+ }
+
+ public void setDescentMeters(float descentMeters) {
+ this.descentMeters = descentMeters;
+ }
+
+ public void setMinAltitude(float minAltitude) {
+ this.minAltitude = minAltitude;
+ }
+
+ public void setMaxAltitude(float maxAltitude) {
+ this.maxAltitude = maxAltitude;
+ }
+
+ public void setMinLatitude(int minLatitude) {
+ this.minLatitude = minLatitude;
+ }
+
+ public void setMaxLatitude(int maxLatitude) {
+ this.maxLatitude = maxLatitude;
+ }
+
+ public void setMinLongitude(int minLongitude) {
+ this.minLongitude = minLongitude;
+ }
+
+ public void setMaxLongitude(int maxLongitude) {
+ this.maxLongitude = maxLongitude;
+ }
+
+ public void setSteps(long steps) {
+ this.steps = steps;
+ }
+
+ public void setActiveTimeSeconds(long activeTimeSeconds) {
+ this.activeTimeSeconds = activeTimeSeconds;
+ }
+
+ public void setCaloriesBurnt(float caloriesBurnt) {
+ this.caloriesBurnt = caloriesBurnt;
+ }
+
+ public void setMaxSpeed(float maxSpeed) {
+ this.maxSpeed = maxSpeed;
+ }
+
+ public void setMinPace(float minPace) {
+ this.minPace = minPace;
+ }
+
+ public void setMaxPace(float maxPace) {
+ this.maxPace = maxPace;
+ }
+
+ public void setTotalStride(float totalStride) {
+ this.totalStride = totalStride;
+ }
+
+ public float getTotalStride() {
+ return totalStride;
+ }
+
+ public void setTimeAscent(long timeAscent) {
+ this.timeAscent = timeAscent;
+ }
+
+ public long getTimeAscent() {
+ return timeAscent;
+ }
+
+ public void setTimeDescent(long timeDescent) {
+ this.timeDescent = timeDescent;
+ }
+
+ public long getTimeDescent() {
+ return timeDescent;
+ }
+
+ public void setTimeFlat(long timeFlat) {
+ this.timeFlat = timeFlat;
+ }
+
+ public long getTimeFlat() {
+ return timeFlat;
+ }
+
+ public void setAverageHR(int averageHR) {
+ this.averageHR = averageHR;
+ }
+
+ public int getAverageHR() {
+ return averageHR;
+ }
+
+ public void setAveragePace(int averagePace) {
+ this.averagePace = averagePace;
+ }
+
+ public int getAveragePace() {
+ return averagePace;
+ }
+
+ public void setAverageStride(int averageStride) {
+ this.averageStride = averageStride;
+ }
+
+ public int getAverageStride() {
+ return averageStride;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSConstants.java
new file mode 100644
index 0000000..dade95d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSConstants.java
@@ -0,0 +1,137 @@
+/* Copyright (C) 2022-2024 Noodlez
+
+ 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.devices.asteroidos;
+
+import java.util.UUID;
+
+/**
+ * A class to hold all the constants needed by the AsteroidOS devices
+ */
+public class AsteroidOSConstants {
+ /**
+ * A list of all the known supported codenames
+ */
+ public static final String[] KNOWN_DEVICE_CODENAMES = {
+ "bass", "sturgeon", "catfish", "catfish_ext",
+ "catshark", "lenok", "smelt", "carp",
+ "sparrow", "wren", "anthias", "beluga",
+ "dory", "firefish", "harmony", "inharmony",
+ "narwhal", "ray", "sawfish", "sawshark",
+ "skipjack", "tunny", "mooneye", "swift",
+ "minnow", "sprat", "tetra", "pike", "hoki",
+ "koi", "ayu"
+ };
+
+ /**
+ * AsteroidOS Service Watch Filter UUID
+ */
+ public static final UUID SERVICE_UUID = UUID.fromString("00000000-0000-0000-0000-00A57E401D05");
+
+ /**
+ * Battery level service
+ */
+ public static final UUID BATTERY_SERVICE_UUID = UUID.fromString("0000180F-0000-1000-8000-00805F9B34FB");
+ /**
+ * Battery level characteristic
+ */
+ public static final UUID BATTERY_UUID = UUID.fromString("00002A19-0000-1000-8000-00805F9B34FB");
+
+ /**
+ * Time service
+ */
+ public static final UUID TIME_SERVICE_UUID = UUID.fromString("00005071-0000-0000-0000-00A57E401D05");
+ /**
+ * Time characteristic
+ */
+ public static final UUID TIME_SET_CHAR = UUID.fromString("00005001-0000-0000-0000-00A57E401D05");
+
+ /**
+ * Screenshot service
+ */
+ public static final UUID SCREENSHOT_SERVICE_UUID = UUID.fromString("00006071-0000-0000-0000-00A57E401D05");
+ /**
+ * Screenshot request characteristic
+ */
+ public static final UUID SCREENSHOT_REQUEST_CHAR = UUID.fromString("00006001-0000-0000-0000-00A57E401D05");
+ /**
+ * Screenshot content characteristic
+ */
+ public static final UUID SCREENSHOT_CONTENT_CHAR = UUID.fromString("00006002-0000-0000-0000-00A57E401D05");
+
+ /**
+ * Media service
+ */
+ public static final UUID MEDIA_SERVICE_UUID = UUID.fromString("00007071-0000-0000-0000-00A57E401D05");
+ /**
+ * Media title characteristic
+ */
+ public static final UUID MEDIA_TITLE_CHAR = UUID.fromString("00007001-0000-0000-0000-00A57E401D05");
+ /**
+ * Media album characteristic
+ */
+ public static final UUID MEDIA_ALBUM_CHAR = UUID.fromString("00007002-0000-0000-0000-00A57E401D05");
+ /**
+ * Media artist characteristic
+ */
+ public static final UUID MEDIA_ARTIST_CHAR = UUID.fromString("00007003-0000-0000-0000-00A57E401D05");
+ /**
+ * Media playing status characteristic
+ */
+ public static final UUID MEDIA_PLAYING_CHAR = UUID.fromString("00007004-0000-0000-0000-00A57E401D05");
+ /**
+ * Media command characteristic
+ */
+ public static final UUID MEDIA_COMMANDS_CHAR = UUID.fromString("00007005-0000-0000-0000-00A57E401D05");
+ /**
+ * Media volume characteristic
+ */
+ public static final UUID MEDIA_VOLUME_CHAR = UUID.fromString("00007006-0000-0000-0000-00A57E401D05");
+
+ /**
+ * Weather service
+ */
+ public static final UUID WEATHER_SERVICE_UUID = UUID.fromString("00008071-0000-0000-0000-00A57E401D05");
+ /**
+ * Weather city name characteristic
+ */
+ public static final UUID WEATHER_CITY_CHAR = UUID.fromString("00008001-0000-0000-0000-00A57E401D05");
+ /**
+ * Weather condition codes characteristic
+ */
+ public static final UUID WEATHER_IDS_CHAR = UUID.fromString("00008002-0000-0000-0000-00A57E401D05");
+ /**
+ * Weather minimum temps characteristic
+ */
+ public static final UUID WEATHER_MIN_TEMPS_CHAR = UUID.fromString("00008003-0000-0000-0000-00A57E401D05");
+ /**
+ * Weather maximum temps characteristic
+ */
+ public static final UUID WEATHER_MAX_TEMPS_CHAR = UUID.fromString("00008004-0000-0000-0000-00A57E401D05");
+
+ /**
+ * Notification service
+ */
+ public static final UUID NOTIFICATION_SERVICE_UUID = UUID.fromString("00009071-0000-0000-0000-00A57E401D05");
+ /**
+ * Notification update characteristic
+ */
+ public static final UUID NOTIFICATION_UPDATE_CHAR = UUID.fromString("00009001-0000-0000-0000-00A57E401D05");
+ /**
+ * Notification feedback characteristic
+ */
+ public static final UUID NOTIFICATION_FEEDBACK_CHAR = UUID.fromString("00009002-0000-0000-0000-00A57E401D05");
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSDeviceCoordinator.java
new file mode 100644
index 0000000..951991d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSDeviceCoordinator.java
@@ -0,0 +1,98 @@
+/* Copyright (C) 2022-2024 Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo,
+ Noodlez
+
+ 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.devices.asteroidos;
+
+import android.bluetooth.le.ScanFilter;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.asteroidos.AsteroidOSDeviceSupport;
+
+public class AsteroidOSDeviceCoordinator extends AbstractDeviceCoordinator {
+
+ @Override
+ public String getManufacturer() {
+ return "AsteroidOS";
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid asteroidUUID = ParcelUuid.fromString(AsteroidOSConstants.SERVICE_UUID.toString());
+ ScanFilter serviceFilter = new ScanFilter.Builder().setServiceUuid(asteroidUUID).build();
+
+ List<ScanFilter> filters = new ArrayList<>();
+ filters.add(serviceFilter);
+
+ return filters;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ }
+
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ if (candidate.supportsService(AsteroidOSConstants.SERVICE_UUID))
+ return true;
+ // Some devices still can't see the service
+ for (String codename : AsteroidOSConstants.KNOWN_DEVICE_CODENAMES)
+ if (candidate.getName().equals(codename))
+ return true;
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AsteroidOSDeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_asteroidos;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSMediaCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSMediaCommand.java
new file mode 100644
index 0000000..316efd2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSMediaCommand.java
@@ -0,0 +1,61 @@
+/* Copyright (C) 2022-2024 Noodlez
+
+ 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.devices.asteroidos;
+
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl;
+
+/**
+ * An adapter class for the media commands sent by AsteroidOS
+ */
+public class AsteroidOSMediaCommand {
+ public static final byte COMMAND_PREVIOUS = 0x0;
+ public static final byte COMMAND_NEXT = 0x1;
+ public static final byte COMMAND_PLAY = 0x2;
+ public static final byte COMMAND_PAUSE = 0x3;
+ public static final byte COMMAND_VOLUME = 0x4;
+
+ public byte command;
+ public AsteroidOSMediaCommand(byte value) {
+ command = value;
+ }
+
+ /**
+ * Convert the MediaCommand to a music control event
+ * @return the matching music control event
+ */
+ public GBDeviceEventMusicControl toMusicControlEvent() {
+ GBDeviceEventMusicControl event = new GBDeviceEventMusicControl();
+ switch (command) {
+ case COMMAND_PREVIOUS:
+ event.event = GBDeviceEventMusicControl.Event.PREVIOUS;
+ break;
+ case COMMAND_NEXT:
+ event.event = GBDeviceEventMusicControl.Event.NEXT;
+ break;
+ case COMMAND_PLAY:
+ event.event = GBDeviceEventMusicControl.Event.PLAY;
+ break;
+ case COMMAND_PAUSE:
+ event.event = GBDeviceEventMusicControl.Event.PAUSE;
+ break;
+ case COMMAND_VOLUME:
+ default:
+ event.event = GBDeviceEventMusicControl.Event.UNKNOWN;
+ }
+ return event;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSNotification.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSNotification.java
new file mode 100644
index 0000000..67a3ad2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSNotification.java
@@ -0,0 +1,176 @@
+/* Copyright (C) 2022-2024 Noodlez
+
+ 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.devices.asteroidos;
+
+import androidx.annotation.NonNull;
+
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
+
+/**
+ * An adapter for notifications on AsteroidOS
+ */
+public class AsteroidOSNotification {
+ private String packageName = null;
+ private Integer id;
+ private String applicationName = null;
+ private String body = null;
+ private String summary = null;
+ private String icon = null;
+ private Boolean remove = false;
+
+ /**
+ * The vibration strength of a notification
+ */
+ public enum VibrationStrength {
+ STRONG,
+ NORMAL,
+ RINGTONE,
+ NONE;
+
+ @NonNull
+ @Override
+ public String toString() {
+ return name().toLowerCase(Locale.ROOT);
+ }
+ }
+ private VibrationStrength vibrationStrength = VibrationStrength.NORMAL;
+
+ /**
+ * Creates a notification
+ * @param spec The NotificationSpec to create the notification from
+ */
+ public AsteroidOSNotification(NotificationSpec spec) {
+ this.body = spec.body;
+ this.applicationName = spec.sourceName;
+ this.summary = spec.subject;
+ this.id = spec.getId();
+ this.packageName = spec.sourceAppId;
+ this.icon = this.gbNotificationTypeToIcon(spec.type);
+ }
+
+ /**
+ * Creates a call notification
+ * @param callSpec The callSpec given by the device support
+ */
+ public AsteroidOSNotification(CallSpec callSpec) {
+ switch (callSpec.command) {
+ case CallSpec.CALL_INCOMING:
+ this.applicationName = GBApplication.getContext().getString(R.string.pref_screen_notification_profile_incoming_call);
+ this.summary = callSpec.name;
+ this.body = callSpec.number;
+ this.vibrationStrength = VibrationStrength.RINGTONE;
+ this.id = (callSpec.name + callSpec.number).hashCode();
+ this.icon = "ios-call-outline";
+ break;
+ case CallSpec.CALL_OUTGOING:
+ break;
+ case CallSpec.CALL_REJECT:
+ case CallSpec.CALL_ACCEPT:
+ case CallSpec.CALL_END:
+ case CallSpec.CALL_START:
+ case CallSpec.CALL_UNDEFINED:
+ default:
+ this.id = (callSpec.name + callSpec.number).hashCode();
+ this.remove = true;
+ }
+ }
+
+ /**
+ * Creates a "remove" notification
+ * @param id Notification ID to remove
+ */
+ public AsteroidOSNotification(int id) {
+ this.id = id;
+ this.remove = true;
+ }
+
+ /**
+ * Converts the notification to a string to be sent to the device
+ */
+ @NonNull
+ @Override
+ public String toString() {
+ if (remove) {
+ return "<removed><id>" + this.id + "</id></removed>";
+ }
+ String retString = "";
+ retString += "<insert>";
+ if (id != null)
+ retString += "<id>" + id + "</id>";
+ retString += "<vb>" + vibrationStrength.toString() + "</vb>";
+ if (packageName != null)
+ retString += "<pn>" + packageName + "</pn>";
+ if (applicationName != null)
+ retString += "<an>" + applicationName + "</an>";
+ if (icon != null)
+ retString += "<ai>" + icon + "</ai>";
+ if (summary != null)
+ retString += "<su>" + summary + "</su>";
+ if (body != null)
+ retString += "<bo>" + body + "</bo>";
+ retString += "</insert>";
+ return retString;
+ }
+
+ public String gbNotificationTypeToIcon(NotificationType type) {
+ switch (type) {
+ // Logos
+ case FACEBOOK:
+ return "logo-facebook";
+ case INSTAGRAM:
+ return "logo-instagram";
+ case LINKEDIN:
+ return "logo-linkedin";
+ case SIGNAL:
+ return "logo-signal";
+ case SKYPE:
+ return "logo-skype";
+ case SNAPCHAT:
+ return "logo-snapchat";
+ case TWITTER:
+ return "logo-twitter";
+ case WHATSAPP:
+ return "logo-whatsapp";
+ case YAHOO_MAIL:
+ return "logo-yahoo";
+ // Generic
+ default:
+ switch (type.getGenericType()) {
+ case "generic_email":
+ return "ios-mail-outline";
+ case "generic_navigation":
+ return "ios-navigate-outline";
+ case "generic_sms":
+ return "ios-chatboxes-outline";
+ case "generic_alarm_clock":
+ return "ios-alarm-outline";
+ case "generic_social":
+ return "ios-people-outline";
+ case "generic_chat":
+ return "ios-chatbubbles-outline";
+ default:
+ return "ios-notifications-outline";
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSWeather.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSWeather.java
new file mode 100644
index 0000000..655b0d8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/asteroidos/AsteroidOSWeather.java
@@ -0,0 +1,136 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo, Noodlez
+
+ 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.devices.asteroidos;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+
+
+/**
+ * An adapter class for weather
+ */
+public class AsteroidOSWeather {
+ /**
+ * Provides a day's worth of weather
+ */
+ public static class Day {
+ /**
+ * The minimum temp of the day
+ */
+ public int minTemp;
+ /**
+ * The maximum temp of the day
+ */
+ public int maxTemp;
+ /**
+ * The current OWM weather condition code
+ */
+ public int condition;
+
+ /**
+ * Creates a Day from the forecast given
+ * @param forecast A day in the weather forecast
+ */
+ public Day(WeatherSpec.Daily forecast) {
+ minTemp = forecast.minTemp;
+ maxTemp = forecast.maxTemp;
+ condition = forecast.conditionCode;
+ }
+
+ /**
+ * Creates a Day from the WeatherSpec given
+ * @param spec The weather spec itself
+ */
+ public Day(WeatherSpec spec) {
+ minTemp = spec.todayMinTemp;
+ maxTemp = spec.todayMaxTemp;
+ condition = spec.currentConditionCode;
+ }
+ }
+
+ /**
+ * The days of the weather
+ */
+ public ArrayList<Day> days = new ArrayList<>();
+ /**
+ * The city name of the weather
+ */
+ public String cityName;
+
+
+ /**
+ * Creates an AsteroidOSWeather from the WeatherSpec given
+ * @param spec The WeatherSpec given to the device support class
+ */
+ public AsteroidOSWeather(WeatherSpec spec) {
+ cityName = spec.location;
+ days.add(new Day(spec));
+ for (int i = 1; i < spec.forecasts.size(); i++) {
+ days.add(new Day(spec.forecasts.get(i - 1)));
+ }
+ }
+
+ /**
+ * Returns a byte array of the city name
+ * @return a byte array of the city name
+ */
+ public byte[] getCityName() {
+ return cityName.getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Parses the days' weather conditions and returns them in a format AsteroidOS can handle
+ * @return a byte array to be sent to the device
+ */
+ public byte[] getWeatherConditions() {
+ ByteArrayOutputStream stream = new ByteArrayOutputStream();
+ for (Day day : days) {
+ stream.write((byte) (day.condition >> 8));
+ stream.write((byte) (day.condition));
+ }
+ return stream.toByteArray();
+ }
+
+ /**
+ * Parses the days' min temps and returns them in a format AsteroidOS can handle
+ * @return a byte array to be sent to the device
+ */
+ public byte[] getMinTemps() {
+ ByteArrayOutputStream stream = new ByteArrayOutputStream();
+ for (Day day : days) {
+ stream.write((byte) (day.minTemp >> 8));
+ stream.write((byte) (day.minTemp));
+ }
+ return stream.toByteArray();
+ }
+
+ /**
+ * Parses the days' max temps and returns them in a format AsteroidOS can handle
+ * @return a byte array to be sent to the device
+ */
+ public byte[] getMaxTemps() {
+ ByteArrayOutputStream stream = new ByteArrayOutputStream();
+ for (Day day : days) {
+ stream.write((byte) (day.maxTemp >> 8));
+ stream.write((byte) (day.maxTemp));
+ }
+ return stream.toByteArray();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/bandwpseries/BandWPSeriesDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/bandwpseries/BandWPSeriesDeviceCoordinator.java
new file mode 100644
index 0000000..ea816ba
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/bandwpseries/BandWPSeriesDeviceCoordinator.java
@@ -0,0 +1,63 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.bandwpseries;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+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.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.bandwpseries.BandWPSeriesDeviceSupport;
+
+public class BandWPSeriesDeviceCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_bandw_pseries;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Bowers and Wilkins";
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return BandWPSeriesDeviceSupport.class;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("LE_BWHP");
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ BatteryConfig battery0 = new BatteryConfig(0, R.drawable.ic_earbuds_battery, R.string.left_earbud);
+ BatteryConfig battery1 = new BatteryConfig(1, R.drawable.ic_earbuds_battery, R.string.right_earbud);
+ BatteryConfig battery2 = new BatteryConfig(2, R.drawable.ic_tws_case, R.string.battery_case);
+ return new BatteryConfig[]{battery0, battery1, battery2};
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[] {
+ R.xml.devicesettings_active_noise_cancelling_toggle,
+ R.xml.devicesettings_bandw_pseries,
+ R.xml.devicesettings_wear_sensor_toggle
+ };
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java
new file mode 100644
index 0000000..9137fc6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java
@@ -0,0 +1,346 @@
+/* Copyright (C) 2022-2024 Andreas Shimokawa, Daniel Dakhno, Gordon Williams
+
+ 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.devices.banglejs;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DEVICE_INTERNET_ACCESS;
+
+import android.app.Activity;
+import android.app.DownloadManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.util.Base64;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.DownloadListener;
+import android.webkit.JavascriptInterface;
+import android.webkit.PermissionRequest;
+import android.webkit.ValueCallback;
+import android.webkit.WebChromeClient;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.PopupMenu;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs.BangleJSDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_BANGLEJS_WEBVIEW_URL;
+
+public class AppsManagementActivity extends AbstractGBActivity {
+ private static final Logger LOG = LoggerFactory.getLogger(AppsManagementActivity.class);
+
+ private WebView webView;
+ private GBDevice mGBDevice;
+ private DeviceCoordinator mCoordinator;
+ /// It seems we can get duplicate broadcasts sometimes - so this helps to avoid that
+ private int deviceRxSeq = -1;
+ /// When a file chooser has been opened in the WebView, this is what should get called
+ private ValueCallback<Uri[]> fileChooserCallback;
+ /// used by showSaveFileDialog for file data
+ private byte[] fileSaveData;
+
+ // Request code for creating a PDF document.
+ private static final int CHOOSE_FILE = 0;
+ private static final int CREATE_FILE = 1;
+
+ public AppsManagementActivity() {
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_banglejs_apps_management);
+
+ Intent intent = getIntent();
+ Bundle bundle = intent.getExtras();
+ if (bundle != null) {
+ mGBDevice = bundle.getParcelable(GBDevice.EXTRA_DEVICE);
+ } else {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+ mCoordinator = mGBDevice.getDeviceCoordinator();
+ }
+
+ private void toast(String data) {
+ GB.toast(data, Toast.LENGTH_LONG, GB.INFO);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (webView!=null) return; // already set up
+ IntentFilter commandFilter = new IntentFilter();
+ commandFilter.addAction(GBDevice.ACTION_DEVICE_CHANGED);
+ commandFilter.addAction(BangleJSDeviceSupport.BANGLEJS_COMMAND_RX);
+ LocalBroadcastManager.getInstance(this).registerReceiver(deviceUpdateReceiver, commandFilter);
+ initViews();
+ }
+
+ @Override
+ protected void onDestroy() {
+ webView.destroy();
+ webView = null;
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(deviceUpdateReceiver);
+ super.onDestroy();
+ finish();
+ }
+
+ BroadcastReceiver deviceUpdateReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ switch (intent.getAction()) {
+ case BangleJSDeviceSupport.BANGLEJS_COMMAND_RX: {
+ String data = String.valueOf(intent.getExtras().get("DATA"));
+ int seq = intent.getIntExtra("SEQ",0);
+ LOG.info("WebView TX: " + data + "("+seq+")");
+ if (seq==deviceRxSeq) {
+ LOG.info("WebView TX DUPLICATE AND IGNORED");
+ } else {
+ deviceRxSeq = seq;
+ bangleRxData(data);
+ }
+ break;
+ }
+ }
+ }
+ };
+
+ public class WebViewInterface {
+ Context mContext;
+
+ WebViewInterface(Context c) {
+ mContext = c;
+ }
+
+ /// Called from the WebView when data needs to be sent to the Bangle
+ @JavascriptInterface
+ public void bangleTx(String data) {
+ LOG.info("WebView RX: " + data);
+ bangleTxData(data);
+ }
+
+ /// Called from the WebView to download a file
+ @JavascriptInterface
+ public void saveFile(String filename, String mimetype, String b64) {
+ LOG.info("WebView save file: {}", filename);
+ showSaveFileDialog(filename, mimetype, Base64.decode(b64, Base64.DEFAULT));
+ }
+ }
+
+ // Called when data received from Bangle.js - push data to the WebView
+ public void bangleRxData(String data) {
+ JSONArray s = new JSONArray();
+ s.put(data);
+ String ss = s.toString();
+ final String js = "bangleRx("+ss.substring(1, ss.length()-1)+");";
+ LOG.info("WebView TX cmd: " + js);
+ if (webView!=null) webView.post(new Runnable() {
+ @Override
+ public void run() {
+ if (webView==null) return; // webView may have gone by the time we get called!
+ webView.evaluateJavascript(js, null);
+ }
+ });
+ }
+
+ // Called to send data to Bangle.js
+ public void bangleTxData(String data) {
+ Intent intent = new Intent(BangleJSDeviceSupport.BANGLEJS_COMMAND_TX);
+ intent.putExtra("DATA", data);
+ LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
+ }
+
+ public void showSaveFileDialog(String fileName, String mimeType, byte[] data) {
+ Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ if (mimeType!=null) intent.setType(mimeType);
+ if (fileName!=null) intent.putExtra(Intent.EXTRA_TITLE, fileName);
+ fileSaveData = data;
+ startActivityForResult(intent, CREATE_FILE);
+ // see onActivityResult
+ }
+
+ private void initViews() {
+ //https://stackoverflow.com/questions/4325639/android-calling-javascript-functions-in-webview
+ webView = findViewById(R.id.webview);
+ webView.setWebViewClient(new WebViewClient());
+ WebSettings settings = webView.getSettings();
+ settings.setJavaScriptEnabled(true);
+ settings.setDatabaseEnabled(true);
+ settings.setDomStorageEnabled(true);
+ settings.setUseWideViewPort(true);
+ settings.setLoadWithOverviewMode(true);
+ String databasePath = this.getApplicationContext().getDir("database", Context.MODE_PRIVATE).getPath();
+ settings.setDatabasePath(databasePath);
+ webView.addJavascriptInterface(new WebViewInterface(this), "Android");
+ webView.setWebContentsDebuggingEnabled(true); // FIXME
+
+ Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(mGBDevice.getAddress()));
+ String url = devicePrefs.getString(PREF_BANGLEJS_WEBVIEW_URL, "").trim();
+ if (url.isEmpty()) url = "https://banglejs.com/apps/android.html";
+ webView.loadUrl(url);
+
+ webView.setWebViewClient(new WebViewClient(){
+ public void onPageFinished(WebView view, String weburl){
+ //webView.loadUrl("javascript:showToast('WebView in Espruino')");
+ }
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView vw, WebResourceRequest request) {
+ Intent intent = new Intent(Intent.ACTION_VIEW, request.getUrl());
+ vw.getContext().startActivity(intent);
+ return true;
+ }
+
+ @Override
+ public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
+ Toast.makeText(AppsManagementActivity.this, "Error:" + description, Toast.LENGTH_SHORT).show();
+ view.loadUrl("about:blank");
+ }
+ });
+ webView.setWebChromeClient(new WebChromeClient() {
+ @Override
+ public void onPermissionRequest(final PermissionRequest request) {
+ request.grant(request.getResources());
+ }
+
+ @Override
+ public boolean onShowFileChooser(WebView vw, ValueCallback<Uri[]> filePathCallback,
+ FileChooserParams fileChooserParams) {
+ if (fileChooserCallback != null) {
+ fileChooserCallback.onReceiveValue(null);
+ }
+ fileChooserCallback = filePathCallback;
+
+ Intent selectionIntent = new Intent(Intent.ACTION_GET_CONTENT);
+ selectionIntent.addCategory(Intent.CATEGORY_OPENABLE);
+ String[] acceptedTypes = fileChooserParams.getAcceptTypes();
+ if ((acceptedTypes!=null) && acceptedTypes.length>0 && acceptedTypes[0].contains("/"))
+ selectionIntent.setType(acceptedTypes[0]);
+ else
+ selectionIntent.setType("*/*");
+
+ Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
+ chooserIntent.putExtra(Intent.EXTRA_INTENT, selectionIntent);
+ startActivityForResult(chooserIntent, CHOOSE_FILE);
+ // see onActivityResult
+ return true;
+ }
+
+
+ });
+ webView.setDownloadListener(new DownloadListener() {
+ public void onDownloadStart(String url, String userAgent,
+ String contentDisposition, String mimetype,
+ long contentLength) {
+ if (url == null) return;
+ // FIXME: When we get to this point we have no idea what the filename is
+ // maybe we should just add 'Android.downloadFile' and call that from the App Loader
+ if(url.startsWith("blob:")) {
+ LOG.error("WebView blob: download requested, but not supported");
+ // This should be possible with https://stackoverflow.com/a/48954970/1215872
+ // but having tried the XMLHttpRequest can't find the blob - I think because the blob ends
+ // up being created inside an iframe, and the data isn't accessible from the
+ // external one
+ } else if(url.startsWith("data:") && url.contains(";base64,")) {
+ int commaIdx = url.indexOf(";base64,");
+ if (commaIdx>=0) {
+ String b64 = url.substring(commaIdx + 8);
+ byte data[] = Base64.decode(b64, Base64.DEFAULT);
+ showSaveFileDialog(null, mimetype, data);
+ } else LOG.error("WebView data: download requested, but not base64");
+ } else { // just view in the web browser
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse(url));
+ startActivity(i);
+ }
+ }
+ });
+ }
+
+ @Override // for file chooser results
+ protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
+ super.onActivityResult(requestCode, resultCode, intent);
+ if (requestCode == CHOOSE_FILE) { // onShowFileChooser
+ if (fileChooserCallback == null) return;
+ Uri[] results = null;
+ if (resultCode == Activity.RESULT_OK)
+ results = new Uri[]{Uri.parse(intent.getDataString())};
+ fileChooserCallback.onReceiveValue(results);
+ fileChooserCallback = null;
+ }
+ if (requestCode == CREATE_FILE) { // showSaveFileDialog
+ if (resultCode == Activity.RESULT_CANCELED || intent == null || intent.getData() == null) {
+ return;
+ }
+
+ OutputStream os = null;
+ try {
+ os = getContentResolver().openOutputStream(intent.getData());
+ os.write(fileSaveData);
+ os.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ } finally {
+ try {
+ os.close();
+ } catch (IOException e) {
+ }
+ }
+ fileSaveData = null;
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSActivityPoint.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSActivityPoint.java
new file mode 100644
index 0000000..e073821
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSActivityPoint.java
@@ -0,0 +1,241 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.banglejs;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.LinkedList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
+import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
+import nodomain.freeyourgadget.gadgetbridge.util.GBToStringBuilder;
+
+public class BangleJSActivityPoint {
+ private static final Logger LOG = LoggerFactory.getLogger(BangleJSActivityPoint.class);
+
+ private final GPSCoordinate location;
+ private final long time;
+ private final int heartRate;
+ private final int hrConfidence;
+ private final String hrSource;
+ private final int steps;
+ private final int batteryPercentage;
+ private final double batteryVoltage;
+ private final boolean charging;
+ private final double barometerTemperature;
+ private final double barometerPressure;
+ private final double barometerAltitude;
+
+ public BangleJSActivityPoint(final long time,
+ final GPSCoordinate location,
+ final int heartRate,
+ final int hrConfidence,
+ final String hrSource,
+ final int steps,
+ final int batteryPercentage,
+ final double batteryVoltage,
+ final boolean charging,
+ final double barometerTemperature,
+ final double barometerPressure,
+ final double barometerAltitude) {
+ this.time = time;
+ this.location = location;
+ this.heartRate = heartRate;
+ this.hrConfidence = hrConfidence;
+ this.hrSource = hrSource;
+ this.steps = steps;
+ this.batteryPercentage = batteryPercentage;
+ this.batteryVoltage = batteryVoltage;
+ this.charging = charging;
+ this.barometerTemperature = barometerTemperature;
+ this.barometerPressure = barometerPressure;
+ this.barometerAltitude = barometerAltitude;
+ }
+
+ public long getTime() {
+ return time;
+ }
+
+ @Nullable
+ public GPSCoordinate getLocation() {
+ return location;
+ }
+
+ public int getHeartRate() {
+ return heartRate;
+ }
+
+ public int getHrConfidence() {
+ return hrConfidence;
+ }
+
+ public String getHrSource() {
+ return hrSource;
+ }
+
+ public int getSteps() {
+ return steps;
+ }
+
+ public int getBatteryPercentage() {
+ return batteryPercentage;
+ }
+
+ public double getBatteryVoltage() {
+ return batteryVoltage;
+ }
+
+ public boolean isCharging() {
+ return charging;
+ }
+
+ public double getBarometerTemperature() {
+ return barometerTemperature;
+ }
+
+ public double getBarometerPressure() {
+ return barometerPressure;
+ }
+
+ public double getBarometerAltitude() {
+ return barometerAltitude;
+ }
+
+ public ActivityPoint toActivityPoint() {
+ final ActivityPoint activityPoint = new ActivityPoint();
+ activityPoint.setTime(new Date(time));
+ if (heartRate > 0) {
+ activityPoint.setHeartRate(heartRate);
+ }
+ if (location != null) {
+ activityPoint.setLocation(location);
+ }
+ return activityPoint;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ final GBToStringBuilder tsb = new GBToStringBuilder(this);
+ tsb.append("location", location);
+ tsb.append("time", time);
+ tsb.append("heartRate", heartRate);
+ tsb.append("hrConfidence", hrConfidence);
+ tsb.append("hrSource", hrSource);
+ tsb.append("steps", steps);
+ tsb.append("batteryPercentage", batteryPercentage);
+ tsb.append("batteryVoltage", batteryVoltage);
+ tsb.append("charging", charging);
+ tsb.append("barometerTemperature", barometerTemperature);
+ tsb.append("barometerPressure", barometerPressure);
+ tsb.append("barometerAltitude", barometerAltitude);
+ return tsb.toString();
+ }
+
+ public static List<BangleJSActivityPoint> fromCsv(final File inputFile) {
+ final List<BangleJSActivityPoint> points = new LinkedList<>();
+ try (BufferedReader reader = new BufferedReader(new FileReader(inputFile))) {
+ final List<String> header = Arrays.asList(reader.readLine().split(","));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ points.add(BangleJSActivityPoint.fromCsvLine(header, line));
+ }
+ } catch (final IOException e) {
+ LOG.error("Failed to read {}", inputFile);
+ return null;
+ }
+
+ return points;
+ }
+
+ /**
+ * This parses all the standard fields from the <a href="https://github.com/espruino/BangleApps/blob/master/apps/recorder/widget.js">recorder</a>.
+ * Some apps such as bthrm add extra fields or modify others. We attempt to gracefully handle other formats (eg. source being "int" or "bthrm").
+ */
+ @Nullable
+ @VisibleForTesting
+ public static BangleJSActivityPoint fromCsvLine(final List<String> header, final String csvLine) {
+ final String[] split = csvLine.trim().replace(",", ", ").split(",");
+ if (split.length != header.size()) {
+ LOG.error("csv line {} length {} differs from header {} length {}", csvLine, split.length, header, header.size());
+ return null;
+ }
+ for (int i = 0; i < split.length; i++) {
+ split[i] = split[i].strip();
+ }
+
+ final int idxTime = header.indexOf("Time");
+ final int idxLatitude = header.indexOf("Latitude");
+ final int idxLongitude = header.indexOf("Longitude");
+ final int idxAltitude = header.indexOf("Altitude");
+ final int idxHeartrate = header.indexOf("Heartrate");
+ final int idxConfidence = header.indexOf("Confidence");
+ final int idxSource = header.indexOf("Source");
+ final int idxSteps = header.indexOf("Steps");
+ final int idxBatteryPercentage = header.indexOf("Battery Percentage");
+ final int idxBatteryVoltage = header.indexOf("Battery Voltage");
+ final int idxCharging = header.indexOf("Charging");
+ final int idxBarometerTemperature = header.indexOf("Barometer Temperature");
+ final int idxBarometerPressure = header.indexOf("Barometer Pressure");
+ final int idxBarometerAltitude = header.indexOf("Barometer Altitude");
+
+ final long time = idxTime >= 0 && StringUtils.isNotBlank(split[idxTime]) ? ((long) (Double.parseDouble(split[idxTime]) * 1000L)) : 0L;
+
+ try {
+ final GPSCoordinate location;
+ if (idxLatitude >= 0 && StringUtils.isNotBlank(split[idxLatitude]) && idxLongitude >= 0 && StringUtils.isNotBlank(split[idxLongitude])) {
+ final double latitude = Double.parseDouble(split[idxLatitude]);
+ final double longitude = Double.parseDouble(split[idxLongitude]);
+ final double altitude;
+ if (idxAltitude >= 0 && StringUtils.isNotBlank(split[idxAltitude])) {
+ altitude = Double.parseDouble(split[idxAltitude]);
+ } else {
+ altitude = GPSCoordinate.UNKNOWN_ALTITUDE;
+ }
+ location = new GPSCoordinate(longitude, latitude, altitude);
+ } else {
+ location = null;
+ }
+ final int heartRate = idxHeartrate >= 0 && StringUtils.isNotBlank(split[idxHeartrate]) ? (int) Math.round(Double.parseDouble(split[idxHeartrate])) : 0;
+ final int confidence = idxConfidence >= 0 && StringUtils.isNotBlank(split[idxConfidence]) ? Integer.parseInt(split[idxConfidence]) : 0;
+ final String source = idxSource >= 0 && StringUtils.isNotBlank(split[idxSource]) ? split[idxSource] : "";
+ final int steps = idxSteps >= 0 && StringUtils.isNotBlank(split[idxSteps]) ? Integer.parseInt(split[idxSteps]) : 0;
+ final int batteryPercentage = idxBatteryPercentage >= 0 && StringUtils.isNotBlank(split[idxBatteryPercentage]) ? Integer.parseInt(split[idxBatteryPercentage]) : -1;
+ final double batteryVoltage = idxBatteryVoltage >= 0 && StringUtils.isNotBlank(split[idxBatteryVoltage]) ? Double.parseDouble(split[idxBatteryVoltage]) : -1;
+ final boolean charging = idxCharging >= 0 && StringUtils.isNotBlank(split[idxCharging]) && Boolean.parseBoolean(split[idxCharging]);
+ final double barometerTemperature = idxBarometerTemperature >= 0 && StringUtils.isNotBlank(split[idxBarometerTemperature]) ? Double.parseDouble(split[idxBarometerTemperature]) : 0;
+ final double barometerPressure = idxBarometerPressure >= 0 && StringUtils.isNotBlank(split[idxBarometerPressure]) ? Double.parseDouble(split[idxBarometerPressure]) : 0;
+ final double barometerAltitude = idxBarometerAltitude >= 0 && StringUtils.isNotBlank(split[idxBarometerAltitude]) ? Double.parseDouble(split[idxBarometerAltitude]) : GPSCoordinate.UNKNOWN_ALTITUDE;
+
+ return new BangleJSActivityPoint(
+ time,
+ location,
+ heartRate,
+ confidence,
+ source,
+ steps,
+ batteryPercentage,
+ batteryVoltage,
+ charging,
+ barometerTemperature,
+ barometerPressure,
+ barometerAltitude
+ );
+ } catch (final Exception e) {
+ LOG.error("failed to parse '{}'", csvLine, e);
+ // Salvage the time at least
+ return new BangleJSActivityPoint(time, null, 0, 0, "", 0, -1, -1, false, 0, 0, 0);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSConstants.java
new file mode 100644
index 0000000..6f1858f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSConstants.java
@@ -0,0 +1,32 @@
+/* Copyright (C) 2019-2024 Gordon Williams, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.banglejs;
+
+import java.util.UUID;
+
+public final class BangleJSConstants {
+
+
+ public static final UUID UUID_SERVICE_NORDIC_UART = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID UUID_CHARACTERISTIC_NORDIC_UART_TX = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID UUID_CHARACTERISTIC_NORDIC_UART_RX = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
+
+ public static final String PREF_BANGLEJS_ACTIVITY_FULL_SYNC_TRIGGER = "pref_banglejs_activity_full_sync_trigger";
+ public static final String PREF_BANGLEJS_ACTIVITY_FULL_SYNC_STATUS = "pref_banglejs_activity_full_sync_status";
+ public static final String PREF_BANGLEJS_ACTIVITY_FULL_SYNC_START = "pref_banglejs_activity_full_sync_start";
+ public static final String PREF_BANGLEJS_ACTIVITY_FULL_SYNC_STOP = "pref_banglejs_activity_full_sync_stop";
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java
new file mode 100644
index 0000000..6e8b26e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java
@@ -0,0 +1,287 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Gabriele Monaco, Ganblejs, glemco, Gordon Williams, Jos茅 Rebelo, LukasEdl,
+ Petr Van臎k
+
+ 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.devices.banglejs;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.ArrayUtils;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.SleepAsAndroidFeature;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs.BangleJSDeviceSupport;
+
+public class BangleJSCoordinator extends AbstractBLEDeviceCoordinator {
+
+ @Override
+ public String getManufacturer() {
+ return "Espruino";
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ // TODO: filter on name beginning Bangle.js? Doesn't appear to be built-in :(
+ // https://developer.android.com/reference/android/bluetooth/le/ScanFilter.Builder.html#setDeviceName(java.lang.String)
+ ParcelUuid hpService = new ParcelUuid(BangleJSConstants.UUID_SERVICE_NORDIC_UART);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(hpService).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Bangle\\.js.*|Pixl\\.js.*|Puck\\.js.*|MDBT42Q.*|Espruino.*");
+ }
+
+ @Override
+ public int getBondingStyle(){
+ // Let the user decide whether to bond or not after discovery.
+ return BONDING_STYLE_ASK;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return true;
+ }
+
+ @Override
+ public int getCannedRepliesSlotCount(final GBDevice device) {
+ return 16;
+ }
+
+ @Override
+ public boolean supportsSleepAsAndroid() {
+ return true;
+ }
+
+ @Override
+ public Set<SleepAsAndroidFeature> getSleepAsAndroidFeatures() {
+ return EnumSet.of(SleepAsAndroidFeature.ACCELEROMETER, SleepAsAndroidFeature.HEART_RATE, SleepAsAndroidFeature.NOTIFICATIONS, SleepAsAndroidFeature.ALARMS);
+ }
+
+ @Nullable
+ @Override
+ public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
+ return new BangleJSWorkoutParser(context);
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return device.getModel() != null && device.getModel().equals("2");
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsManualHeartRateMeasurement(GBDevice device) {
+ /* we could do this, but the current code for onHeartRateTest
+ looks completely broken. There's no way to stop heart rate measurements
+ and it doesn't even appear to care what device it's getting the current
+ heart rate measurements from. Fixing it is too much work so disabling
+ for now.
+ */
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 10;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return BuildConfig.INTERNET_ACCESS;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return BuildConfig.INTERNET_ACCESS ? AppsManagementActivity.class : null;
+ }
+
+ @Override
+ public boolean suggestUnbindBeforePair() {
+ return false;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new BangleJSSampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ /* we say yes here (because we can't get a handle to our device's prefs to check)
+ and then in 'renderUnicodeAsImage' we call EmojiConverter.convertUnicodeEmojiToAscii
+ just like DeviceCommunicationService.sanitizeNotifText would have done if we'd
+ reported false *if* conversion is disabled */
+ return true;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ return new BatteryConfig[]{
+ new BatteryConfig(
+ 0,
+ GBDevice.BATTERY_ICON_DEFAULT,
+ GBDevice.BATTERY_LABEL_DEFAULT,
+ 15,
+ 100
+ )
+ };
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(final GBDevice device) {
+ final List<Integer> settings = new ArrayList<>();
+
+ settings.add(R.xml.devicesettings_banglejs_location);
+
+ settings.add(R.xml.devicesettings_header_notifications);
+ settings.add(R.xml.devicesettings_send_app_notifications);
+ settings.add(R.xml.devicesettings_notification_wake_on_open);
+ settings.add(R.xml.devicesettings_text_bitmaps);
+ settings.add(R.xml.devicesettings_transliteration);
+ settings.add(R.xml.devicesettings_canned_reply_16);
+
+ settings.add(R.xml.devicesettings_header_calendar);
+ settings.add(R.xml.devicesettings_sync_calendar);
+
+ settings.add(R.xml.devicesettings_header_connection);
+ settings.add(R.xml.devicesettings_high_mtu);
+ if (BuildConfig.INTERNET_ACCESS)
+ settings.add(R.xml.devicesettings_device_internet_access);
+
+ settings.add(R.xml.devicesettings_banglejs_activity);
+
+ settings.add(R.xml.devicesettings_header_apps);
+ settings.add(R.xml.devicesettings_loyalty_cards);
+
+ settings.add(R.xml.devicesettings_header_developer);
+ settings.add(R.xml.devicesettings_banglejs_apploader);
+ settings.add(R.xml.devicesettings_device_intents);
+
+ return ArrayUtils.toPrimitive(settings.toArray(new Integer[0]));
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new BangleJSSettingsCustomizer(device);
+ }
+
+ @Override
+ public boolean supportsNavigation() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return BangleJSDeviceSupport.class;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_banglejs;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_banglejs;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_banglejs_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSSampleProvider.java
new file mode 100644
index 0000000..304e590
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSSampleProvider.java
@@ -0,0 +1,127 @@
+/* Copyright (C) 2020-2024 Gordon Williams, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.banglejs;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.BangleJSActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.BangleJSActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class BangleJSSampleProvider extends AbstractSampleProvider<BangleJSActivitySample> {
+ private static final Logger LOG = LoggerFactory.getLogger(BangleJSSampleProvider.class);
+
+ public BangleJSSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<BangleJSActivitySample, ?> getSampleDao() {
+ return getSession().getBangleJSActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return BangleJSActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return BangleJSActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return BangleJSActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / 2048.0f;
+ }
+
+ @Override
+ public BangleJSActivitySample createActivitySample() {
+ return new BangleJSActivitySample();
+ }
+
+ /**
+ * Upserts a sample in the database, avoiding duplicated samples if a sample already exists in a
+ * close timestamp (within 2 minutes);
+ */
+ public void upsertSample(final BangleJSActivitySample sample) {
+ final List<BangleJSActivitySample> nearSamples = getGBActivitySamples(
+ sample.getTimestamp() - 60 * 2,
+ sample.getTimestamp() + 60 * 2
+ );
+
+ if (nearSamples.isEmpty()) {
+ // No nearest sample, just insert
+ LOG.debug("No duplicate found at {}, inserting", sample.getTimestamp());
+ addGBActivitySample(sample);
+ return;
+ }
+
+ BangleJSActivitySample nearestSample = nearSamples.get(0);
+
+ for (final BangleJSActivitySample s : nearSamples) {
+ final int curDist = Math.abs(nearestSample.getTimestamp() - s.getTimestamp());
+ final int newDist = Math.abs(sample.getTimestamp() - s.getTimestamp());
+ if (newDist < curDist) {
+ nearestSample = s;
+ }
+ }
+
+ LOG.debug("Found {} duplicates for {}, updating nearest sample at {}", nearSamples.size(), sample.getTimestamp(), nearestSample.getTimestamp());
+
+ if (sample.getHeartRate() != 0) {
+ nearestSample.setHeartRate(sample.getHeartRate());
+ }
+ if (sample.getSteps() != 0) {
+ nearestSample.setSteps(sample.getSteps());
+ }
+ if (sample.getRawIntensity() != 0) {
+ nearestSample.setRawIntensity(sample.getRawIntensity());
+ }
+
+ addGBActivitySample(nearestSample);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSSettingsCustomizer.java
new file mode 100644
index 0000000..8937e70
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSSettingsCustomizer.java
@@ -0,0 +1,141 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.banglejs;
+
+import static nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSConstants.PREF_BANGLEJS_ACTIVITY_FULL_SYNC_START;
+import static nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSConstants.PREF_BANGLEJS_ACTIVITY_FULL_SYNC_STATUS;
+import static nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSConstants.PREF_BANGLEJS_ACTIVITY_FULL_SYNC_STOP;
+import static nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSConstants.PREF_BANGLEJS_ACTIVITY_FULL_SYNC_TRIGGER;
+
+import android.app.AlertDialog;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Parcel;
+
+import androidx.preference.EditTextPreference;
+import androidx.preference.Preference;
+
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class BangleJSSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ private ProgressDialog activityFullSyncDialog;
+
+ final GBDevice device;
+
+ public BangleJSSettingsCustomizer(final GBDevice device) {
+ this.device = device;
+ }
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ // Handle full sync status
+ if (preference.getKey().equals(PREF_BANGLEJS_ACTIVITY_FULL_SYNC_STATUS)) {
+ final EditTextPreference fullSyncStatusPreference = (EditTextPreference) preference;
+ final String statusValue = fullSyncStatusPreference.getText();
+
+ if (activityFullSyncDialog != null) {
+ switch (statusValue) {
+ case "start":
+ activityFullSyncDialog.setMessage(handler.getContext().getString(R.string.busy_task_fetch_activity_data));
+ break;
+ case "end":
+ activityFullSyncDialog.dismiss();
+ activityFullSyncDialog = null;
+ break;
+ default:
+ activityFullSyncDialog.setMessage(statusValue);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ final Preference fullSyncPref = handler.findPreference(PREF_BANGLEJS_ACTIVITY_FULL_SYNC_TRIGGER);
+ if (fullSyncPref != null) {
+ fullSyncPref.setOnPreferenceClickListener(preference -> {
+ if (activityFullSyncDialog != null) {
+ // Already syncing
+ return true;
+ }
+
+ final Context context = preference.getContext();
+
+ new AlertDialog.Builder(context)
+ .setTitle(R.string.pref_activity_full_sync_trigger_title)
+ .setMessage(R.string.pref_activity_full_sync_trigger_warning)
+ .setIcon(R.drawable.ic_refresh)
+ .setPositiveButton(R.string.start, (dialog, whichButton) -> {
+ handler.notifyPreferenceChanged(PREF_BANGLEJS_ACTIVITY_FULL_SYNC_START);
+
+ activityFullSyncDialog = new ProgressDialog(context);
+ activityFullSyncDialog.setCancelable(false);
+ activityFullSyncDialog.setMessage(context.getString(R.string.sony_anc_optimizer_status_starting));
+ activityFullSyncDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
+ activityFullSyncDialog.setProgress(0);
+ activityFullSyncDialog.setButton(DialogInterface.BUTTON_NEGATIVE, context.getString(R.string.Cancel), (dialog1, which) -> {
+ dialog1.dismiss();
+ activityFullSyncDialog = null;
+ handler.notifyPreferenceChanged(PREF_BANGLEJS_ACTIVITY_FULL_SYNC_STOP);
+ });
+
+ activityFullSyncDialog.show();
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+
+ return true;
+ });
+ }
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeParcelable(device, 0);
+ }
+
+ public static final Creator<BangleJSSettingsCustomizer> CREATOR = new Creator<BangleJSSettingsCustomizer>() {
+ @Override
+ public BangleJSSettingsCustomizer createFromParcel(final Parcel in) {
+ final GBDevice device = in.readParcelable(BangleJSSettingsCustomizer.class.getClassLoader());
+ return new BangleJSSettingsCustomizer(device);
+ }
+
+ @Override
+ public BangleJSSettingsCustomizer[] newArray(final int size) {
+ return new BangleJSSettingsCustomizer[size];
+ }
+ };
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSWorkoutParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSWorkoutParser.java
new file mode 100644
index 0000000..b02157e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSWorkoutParser.java
@@ -0,0 +1,170 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.banglejs;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ACTIVE_SECONDS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ALTITUDE_AVG;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ALTITUDE_MAX;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ALTITUDE_MIN;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.CADENCE_AVG;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.DISTANCE_METERS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_AVG;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_MAX;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_MIN;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.INTERNAL_HAS_GPS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SPEED_AVG;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SPEED_MAX;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SPEED_MIN;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STEPS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STRIDE_AVG;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STRIDE_MAX;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STRIDE_MIN;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_BPM;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS_PER_SECOND;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SPM;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_STEPS;
+
+import android.content.Context;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.util.Accumulator;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+
+public class BangleJSWorkoutParser implements ActivitySummaryParser {
+ private static final Logger LOG = LoggerFactory.getLogger(BangleJSWorkoutParser.class);
+
+ private final Context mContext;
+
+ public BangleJSWorkoutParser(final Context context) {
+ this.mContext = context;
+ }
+
+ @Override
+ public BaseActivitySummary parseBinaryData(final BaseActivitySummary summary, final boolean forDetails) {
+ if (!forDetails) {
+ // Re-parsing the csv is too slow for summary
+ return summary;
+ }
+
+ if (summary.getRawDetailsPath() == null) {
+ return summary;
+ }
+
+ final File inputFile = FileUtils.tryFixPath(new File(summary.getRawDetailsPath()));
+ if (inputFile == null) {
+ return summary;
+ }
+
+ final List<BangleJSActivityPoint> points = BangleJSActivityPoint.fromCsv(inputFile);
+ if (points == null) {
+ return summary;
+ }
+
+ summary.setSummaryData(dataFromPoints(points).toString());
+ return summary;
+ }
+
+ public static ActivitySummaryData dataFromPoints(final List<BangleJSActivityPoint> points) {
+ final Accumulator accHeartRate = new Accumulator();
+ final Accumulator accSpeed = new Accumulator();
+ final Accumulator accAltitude = new Accumulator();
+ final Accumulator accStride = new Accumulator();
+ double totalDistance = 0;
+ int totalSteps = 0;
+ long totalTime = 0;
+ long totalActiveTime = 0;
+ boolean hasGps = false;
+
+ final ActivityUser activityUser = new ActivityUser();
+
+ BangleJSActivityPoint previousPoint = null;
+ for (final BangleJSActivityPoint p : points) {
+ if (p.getHeartRate() > 0) {
+ accHeartRate.add(p.getHeartRate());
+ }
+ final long timeDiff = previousPoint != null ? p.getTime() - previousPoint.getTime() : 0;
+ double distanceDiff;
+ // FIXME: GPS data can be missing for some entries which is handled here.
+ // Should use more complex logic to be more accurate. Use interpolation.
+ // Should distances be done via the GPX file we generate instead?
+ if (previousPoint != null && previousPoint.getLocation() != null && p.getLocation() != null) {
+ distanceDiff = p.getLocation().getDistance(previousPoint.getLocation());
+ hasGps = true;
+ } else {
+ distanceDiff = p.getSteps() * activityUser.getStepLengthCm() * 0.01d;
+ }
+ if (p.getSteps() > 0) {
+ accStride.add(distanceDiff / p.getSteps());
+ }
+ totalTime += timeDiff;
+ totalDistance += distanceDiff;
+ if (distanceDiff != 0) {
+ totalActiveTime += timeDiff;
+ }
+ if (timeDiff > 0) {
+ accSpeed.add(distanceDiff / (timeDiff / 1000d));
+ }
+
+ totalSteps += p.getSteps();
+
+ previousPoint = p;
+ }
+
+ final ActivitySummaryData summaryData = new ActivitySummaryData();
+ if (totalDistance != 0) {
+ summaryData.add(DISTANCE_METERS, (float) totalDistance, UNIT_METERS);
+ }
+ if (totalActiveTime > 0) {
+ summaryData.add(ACTIVE_SECONDS, Math.round(totalActiveTime / 1000d), UNIT_SECONDS);
+ }
+
+ if (totalSteps != 0) {
+ summaryData.add(STEPS, totalSteps, UNIT_STEPS);
+ }
+ if (accHeartRate.getCount() > 0) {
+ summaryData.add(HR_AVG, accHeartRate.getAverage(), UNIT_BPM);
+ summaryData.add(HR_MAX, (int) accHeartRate.getMax(), UNIT_BPM);
+ summaryData.add(HR_MIN, (int) accHeartRate.getMin(), UNIT_BPM);
+ }
+ if (accStride.getCount() > 0) {
+ summaryData.add(STRIDE_AVG, accStride.getAverage(), UNIT_METERS);
+ summaryData.add(STRIDE_MAX, accStride.getMax(), UNIT_METERS);
+ summaryData.add(STRIDE_MIN, accStride.getMin(), UNIT_METERS);
+ }
+ if (accSpeed.getCount() > 0) {
+ summaryData.add(SPEED_AVG, accSpeed.getAverage(), UNIT_METERS_PER_SECOND);
+ summaryData.add(SPEED_MAX, accSpeed.getMax(), UNIT_METERS_PER_SECOND);
+ summaryData.add(SPEED_MIN, accSpeed.getMin(), UNIT_METERS_PER_SECOND);
+ }
+ if (accAltitude.getCount() != 0) {
+ summaryData.add(ALTITUDE_MAX, accAltitude.getMax(), UNIT_METERS);
+ summaryData.add(ALTITUDE_MIN, accAltitude.getMin(), UNIT_METERS);
+ summaryData.add(ALTITUDE_AVG, accAltitude.getAverage(), UNIT_METERS);
+ }
+
+ if (totalTime > 0) {
+ // FIXME: Should cadence be steps/min or half that? https://www.polar.com/blog/what-is-running-cadence/
+ // The Bangle.js App Loader has Cadence = (steps/min)/2, https://github.com/espruino/BangleApps/blob/master/apps/recorder/interface.html#L103,
+ // as discussed here: https://github.com/espruino/BangleApps/pull/3068#issuecomment-1790293879 .
+ summaryData.add(CADENCE_AVG, 0.5 * 60 * totalSteps / (totalTime / 1000d), UNIT_SPM);
+ }
+
+ // TODO: Implement hrZones by doing calculations on Gadgetbridge side or make Bangle.js report
+ // this (Karvonen method implemented to a degree in watch app "Run+")?
+
+ // TODO: Does Bangle.js report laps in recorder logs?
+
+ summaryData.add(INTERNAL_HAS_GPS, String.valueOf(hasGps));
+
+ return summaryData;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/binary_sensor/activity/DataActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/binary_sensor/activity/DataActivity.java
new file mode 100644
index 0000000..6b8f76b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/binary_sensor/activity/DataActivity.java
@@ -0,0 +1,86 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno
+
+ 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.devices.binary_sensor.activity;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.widget.TextView;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.binary_sensor.BinarySensorSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.binary_sensor.protocol.constants.SensorState;
+
+public class DataActivity extends AbstractGBActivity {
+ TextView stateView, countView;
+
+ BroadcastReceiver stateReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (stateView == null) {
+ return;
+ }
+ if (countView == null) {
+ return;
+ }
+ boolean is_closed = intent.getBooleanExtra("EXTRA_SENSOR_CLOSED", false);
+ int count = intent.getIntExtra("EXTRA_SENSOR_COUNT", -1);
+
+ stateView.setText(is_closed ? "CLOSED" : "OPEN");
+ stateView.setBackgroundResource(is_closed ? android.R.color.holo_green_light : android.R.color.holo_red_light);
+ countView.setText("Count: " + count);
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_binary_sensor_data);
+
+ stateView = findViewById(R.id.text_sensor_state);
+ countView = findViewById(R.id.text_sensor_count);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(BinarySensorSupport.ACTION_SENSOR_STATE_CHANGED);
+ LocalBroadcastManager.getInstance(getApplicationContext())
+ .registerReceiver(
+ stateReceiver,
+ filter
+ );
+
+ LocalBroadcastManager.getInstance(getApplicationContext())
+ .sendBroadcast(new Intent(BinarySensorSupport.ACTION_SENSOR_STATE_REQUEST));
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ LocalBroadcastManager.getInstance(getApplicationContext())
+ .unregisterReceiver(
+ stateReceiver
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/binary_sensor/coordinator/BinarySensorCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/binary_sensor/coordinator/BinarySensorCoordinator.java
new file mode 100644
index 0000000..ab3767e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/binary_sensor/coordinator/BinarySensorCoordinator.java
@@ -0,0 +1,188 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver, Damien Gaignon, Daniel Dakhno,
+ Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.binary_sensor.coordinator;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.binary_sensor.activity.DataActivity;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.binary_sensor.BinarySensorSupport;
+
+public class BinarySensorCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ return Collections.singletonList(
+ new ScanFilter.Builder()
+ .setServiceUuid(ParcelUuid.fromString(BinarySensorSupport.BINARY_SENSOR_SERVICE_UUID))
+ .build()
+ );
+ }
+
+ @NonNull
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ for(ParcelUuid service : candidate.getServiceUuids()){
+ if(service.getUuid().toString().equals(BinarySensorSupport.BINARY_SENSOR_SERVICE_UUID)){
+ return true;
+ };
+ }
+ return false;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[0];
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Generic";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return DataActivity.class;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 0;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return BinarySensorSupport.class;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_binary_sensor;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_unknown;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_unknown_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/Casio2C2DDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/Casio2C2DDeviceCoordinator.java
new file mode 100644
index 0000000..b4407b5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/Casio2C2DDeviceCoordinator.java
@@ -0,0 +1,28 @@
+/* Copyright (C) 2024 Johannes Krude
+
+ 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/>. */
+/* Based on code from BlueWatcher, https://github.com/masterjc/bluewatcher */
+package nodomain.freeyourgadget.gadgetbridge.devices.casio;
+
+
+public abstract class Casio2C2DDeviceCoordinator extends CasioDeviceCoordinator {
+
+ @Override
+ public boolean getRemindersHaveTime() {
+ return false;
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/CasioConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/CasioConstants.java
new file mode 100644
index 0000000..8c6ad01
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/CasioConstants.java
@@ -0,0 +1,154 @@
+/* Copyright (C) 2020-2024 Andreas B枚hler, Johannes Krude
+
+ 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.devices.casio;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+public final class CasioConstants {
+ public static final UUID CASIO_VIRTUAL_SERVER_SERVICE = UUID.fromString("26eb0007-b012-49a8-b1f8-394fb2032b0f");
+
+ public static final UUID CASIO_VIRTUAL_SERVER_FEATURES = UUID.fromString("26eb0008-b012-49a8-b1f8-394fb2032b0f");
+
+ public static final UUID CASIO_A_NOT_W_REQ_NOT = UUID.fromString( "26eb0009-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID CASIO_A_NOT_COM_SET_NOT = UUID.fromString( "26eb000a-b012-49a8-b1f8-394fb2032b0f");
+
+ public static final UUID CCC_DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
+
+ // Immediate Alert
+
+ public static final UUID IMMEDIATE_ALERT_SERVICE_UUID = UUID.fromString("00001802-0000-1000-8000-00805f9b34fb");
+
+ // Alert
+
+ public static final UUID ALERT_SERVICE_UUID = UUID.fromString("26eb0000-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID ALERT_CHARACTERISTIC_UUID = UUID.fromString("00002a46-0000-1000-8000-00805f9b34fb");
+ public static final UUID ALERT_NOTIFICATION_CONTROL_POINT = UUID.fromString("00002a44-0000-1000-8000-00805f9b34fb");
+
+ // More Alert
+
+ public static final UUID MORE_ALERT_SERVICE_UUID = UUID.fromString("26eb001a-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID MORE_ALERT_UUID = UUID.fromString("26eb001b-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID MORE_ALERT_FOR_LONG_UUID = UUID.fromString("26eb001c-b012-49a8-b1f8-394fb2032b0f");
+
+ // Phone Alert
+ public static final UUID CASIO_PHONE_ALERT_STATUS_SERVICE = UUID.fromString("26eb0001-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID RINGER_CONTROL_POINT = UUID.fromString("00002a40-0000-1000-8000-00805f9b34fb");
+
+ // Phone Finder
+
+ public static final UUID CASIO_IMMEDIATE_ALERT_SERVICE_UUID = UUID.fromString("26eb0005-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID ALERT_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002a06-0000-1000-8000-00805f9b34fb");
+
+ // Current Time
+
+ public static final UUID CURRENT_TIME_SERVICE_UUID = UUID.fromString("26eb0002-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID CURRENT_TIME_CHARACTERISTIC_UUID = UUID.fromString("00002a2b-0000-1000-8000-00805f9b34fb");
+ public static final UUID LOCAL_TIME_CHARACTERISTIC_UUID = UUID.fromString("00002a0f-0000-1000-8000-00805f9b34fb");
+
+ // Control Mode
+ public static final UUID WATCH_FEATURES_SERVICE_UUID = UUID.fromString("26eb000d-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID WATCH_CTRL_SERVICE_UUID = UUID.fromString("26eb0018-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID KEY_CONTAINER_CHARACTERISTIC_UUID = UUID.fromString("26eb0019-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID NAME_OF_APP_CHARACTERISTIC_UUID = UUID.fromString("26eb001d-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID FUNCTION_SWITCH_CHARACTERISTIC = UUID.fromString("26eb001e-b012-49a8-b1f8-394fb2032b0f");
+ public static final String MUSIC_MESSAGE = "Music";
+
+ // Modern Watches - All Features
+ public static final UUID CASIO_READ_REQUEST_FOR_ALL_FEATURES_CHARACTERISTIC_UUID = UUID.fromString("26eb002c-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID CASIO_ALL_FEATURES_CHARACTERISTIC_UUID = UUID.fromString("26eb002d-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID = UUID.fromString("26eb0023-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID CASIO_CONVOY_CHARACTERISTIC_UUID = UUID.fromString("26eb0024-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID CASIO_NOTIFICATION_CHARACTERISTIC_UUID = UUID.fromString("26eb0030-b012-49a8-b1f8-394fb2032b0f");
+
+ // Link Loss
+
+ public static final UUID LINK_LOSS_SERVICE = UUID.fromString("00001803-0000-1000-8000-00805f9b34fb");
+
+ // TxPower
+
+ public static final UUID TX_POWER_SERVICE_UUID = UUID.fromString("00001804-0000-1000-8000-00805f9b34fb");
+ public static final UUID TX_POWER_LEVEL_CHARACTERISTIC_UUID = UUID.fromString("00002a07-0000-1000-8000-00805f9b34fb");
+ // Settings
+ public static final UUID CASIO_SETTING_FOR_BLE_CHARACTERISTIC_UUID = UUID.fromString("26eb000f-b012-49a8-b1f8-394fb2032b0f");
+ public static final UUID CASIO_SETTING_FOR_ALM_CHARACTERISTIC_UUID = UUID.fromString("26eb0013-b012-49a8-b1f8-394fb2032b0f");
+
+ // Notification Types - GB6900
+
+ public static final byte CALL_NOTIFICATION_ID = 3;
+ public static final byte MAIL_NOTIFICATION_ID = 1;
+ public static final byte CALENDAR_NOTIFICATION_ID = 7;
+ public static final byte SNS_NOTIFICATION_ID = 13;
+ public static final byte SMS_NOTIFICATION_ID = 5;
+
+ // Notification Types - GBX100
+ public static final byte CATEGORY_ADVERTISEMENT = 13;
+ public static final byte CATEGORY_BUSINESS = 9;
+ public static final byte CATEGORY_CONDITION = 12;
+ public static final byte CATEGORY_EMAIL = 6;
+ public static final byte CATEGORY_ENTERTAINMENT = 11;
+ public static final byte CATEGORY_HEALTH_AND_FITNESS = 8;
+ public static final byte CATEGORY_INCOMING_CALL = 1;
+ public static final byte CATEGORY_LOCATION = 10;
+ public static final byte CATEGORY_MISSED_CALL = 2;
+ public static final byte CATEGORY_NEWS = 7;
+ public static final byte CATEGORY_OTHER = 0;
+ public static final byte CATEGORY_SCHEDULE_AND_ALARM = 5;
+ public static final byte CATEGORY_SNS = 4;
+ public static final byte CATEGORY_VOICEMAIL = 3;
+
+ // Connection Reason - GW-B5600
+ public static final byte CONNECT_CNCT = 1;
+ public static final byte CONNECT_FIND = 2;
+ public static final byte CONNECT_AUTO = 3;
+ public static final byte CONNECT_TIME = 4;
+
+ public enum Model {
+ MODEL_CASIO_GENERIC,
+ MODEL_CASIO_6900B,
+ MODEL_CASIO_5600B,
+ MODEL_CASIO_GBX100,
+ MODEL_CASIO_STB1000,
+ MODEL_CASIO_GWB5600,
+ }
+
+ public enum ConfigurationOption {
+ OPTION_GENDER,
+ OPTION_WEIGHT,
+ OPTION_HEIGHT,
+ OPTION_WRIST,
+ OPTION_BIRTHDAY,
+ OPTION_STEP_GOAL,
+ OPTION_DISTANCE_GOAL,
+ OPTION_ACTIVITY_GOAL,
+ OPTION_AUTOLIGHT,
+ OPTION_TIMEFORMAT,
+ OPTION_KEY_VIBRATION,
+ OPTION_OPERATING_SOUNDS,
+ OPTION_ALL
+ }
+
+ public static final int CASIO_CONVOY_DATATYPE_STEPS = 0x04;
+ public static final int CASIO_CONVOY_DATATYPE_CALORIES = 0x05;
+
+ public static final int CASIO_FAKE_RING_SLEEP_DURATION = 3000;
+ public static final int CASIO_FAKE_RING_RETRIES = 10;
+
+ public static final int CASIO_AUTOREMOVE_MESSAGE_DELAY = 10000;
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/CasioDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/CasioDeviceCoordinator.java
new file mode 100644
index 0000000..133c50f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/CasioDeviceCoordinator.java
@@ -0,0 +1,45 @@
+/* Copyright (C) 2023-2024 Johannes Krude
+
+ 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/>. */
+/* Based on code from BlueWatcher, https://github.com/masterjc/bluewatcher */
+package nodomain.freeyourgadget.gadgetbridge.devices.casio;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import android.bluetooth.le.ScanFilter;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioConstants;
+
+public abstract class CasioDeviceCoordinator extends AbstractBLEDeviceCoordinator {
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid casioService = new ParcelUuid(CasioConstants.WATCH_FEATURES_SERVICE_UUID);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(casioService).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Casio";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/ecbs100/CasioECBS100DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/ecbs100/CasioECBS100DeviceCoordinator.java
new file mode 100644
index 0000000..fc4fcf1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/ecbs100/CasioECBS100DeviceCoordinator.java
@@ -0,0 +1,67 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.casio.ecbs100;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.casio.Casio2C2DDeviceCoordinator;
+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.casio.ecbs100.CasioECBS100DeviceSupport;
+
+public class CasioECBS100DeviceCoordinator extends Casio2C2DDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_BOND;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_timeformat,
+ R.xml.devicesettings_dateformat_day_month_order,
+ R.xml.devicesettings_operating_sounds,
+ R.xml.devicesettings_hourly_chime_enable,
+ R.xml.devicesettings_autolight,
+ R.xml.devicesettings_light_duration_longer,
+ R.xml.devicesettings_power_saving,
+ R.xml.devicesettings_casio_connection_duration,
+ R.xml.devicesettings_time_sync,
+
+ // timer
+ // reminder
+ // world time
+ };
+ }
+
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 5;
+ }
+
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return CasioECBS100DeviceSupport.class;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("CASIO ECB-S100");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_casioecbs100;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gb6900/CasioGB6900DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gb6900/CasioGB6900DeviceCoordinator.java
new file mode 100644
index 0000000..47034df
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gb6900/CasioGB6900DeviceCoordinator.java
@@ -0,0 +1,152 @@
+/* Copyright (C) 2020-2024 Andreas B枚hler, Damien Gaignon, Daniel Dakhno,
+ Johannes Krude, Jos茅 Rebelo, Petr Van臎k
+
+ 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/>. */
+/* Based on code from BlueWatcher, https://github.com/masterjc/bluewatcher */
+package nodomain.freeyourgadget.gadgetbridge.devices.casio.gb6900;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gb6900.CasioGB6900DeviceSupport;
+
+public class CasioGB6900DeviceCoordinator extends CasioDeviceCoordinator {
+ protected static final Logger LOG = LoggerFactory.getLogger(CasioGB6900DeviceCoordinator.class);
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("CASIO.*(6900B|5600B|STB-1000).*");
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_LAZY;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 5; // 4 regular and one snooze
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[] {
+ R.xml.devicesettings_disconnectnotification_noshed,
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return CasioGB6900DeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_casiogb6900;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gbx100/CasioGBX100DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gbx100/CasioGBX100DeviceCoordinator.java
new file mode 100644
index 0000000..fb4cc14
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gbx100/CasioGBX100DeviceCoordinator.java
@@ -0,0 +1,159 @@
+/* Copyright (C) 2020-2024 Andreas B枚hler, Damien Gaignon, Daniel Dakhno,
+ foxstidious, Johannes Krude, Jos茅 Rebelo, Petr Van臎k
+
+ 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/>. */
+/* Based on code from BlueWatcher, https://github.com/masterjc/bluewatcher */
+package nodomain.freeyourgadget.gadgetbridge.devices.casio.gbx100;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.casio.Casio2C2DDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.CasioGBX100ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100.CasioGBX100DeviceSupport;
+
+public class CasioGBX100DeviceCoordinator extends Casio2C2DDeviceCoordinator {
+ /** CASIO brand identifier in GB Device name */
+ public static final String CASIO_IDENTIFIER = "CASIO";
+
+ /** Sub-model string for GBX-100 in GB Device name */
+ public static final String GBX_100_SUB_MODEL = "GBX-100";
+ /** Sub-model string for GBD-200 in GB Device name */
+ public static final String GBD_200_SUB_MODEL = "GBD-200";
+ /** Sub-model string for GBD-100 in GB Device name */
+ public static final String GBD_100_SUB_MODEL = "GBD-100";
+ /** Sub-model string for GBD-H1000 in GB Device name */
+ public static final String GBD_H1000_SUB_MODEL = "GBD-H1000";
+
+ public static final String[] VARIANTS = {
+ GBX_100_SUB_MODEL,
+ GBD_200_SUB_MODEL,
+ GBD_100_SUB_MODEL,
+ GBD_H1000_SUB_MODEL};
+
+ protected static final Logger LOG = LoggerFactory.getLogger(CasioGBX100DeviceCoordinator.class);
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ String pattern = CASIO_IDENTIFIER + ".*(";
+ for (int i = 0; i < VARIANTS.length; i++) {
+ pattern += VARIANTS[i];
+ if (i < VARIANTS.length - 1) {
+ pattern += "|";
+ }
+ }
+ pattern += ")";
+ return Pattern.compile(pattern);
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_LAZY;
+ }
+
+ @Override
+ public boolean supportsAlarmSnoozing() {
+ return true;
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new CasioGBX100SampleProvider(device, session);
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 4;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getCasioGBX100ActivitySampleDao().queryBuilder();
+ qb.where(CasioGBX100ActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_find_phone,
+ R.xml.devicesettings_wearlocation,
+ R.xml.devicesettings_timeformat,
+ R.xml.devicesettings_autolight,
+ R.xml.devicesettings_key_vibration,
+ R.xml.devicesettings_operating_sounds,
+ R.xml.devicesettings_fake_ring_duration,
+ R.xml.devicesettings_autoremove_message,
+ R.xml.devicesettings_transliteration,
+ R.xml.devicesettings_preview_message_in_title,
+ R.xml.devicesettings_casio_alert
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return CasioGBX100DeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_casiogbx100;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gbx100/CasioGBX100SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gbx100/CasioGBX100SampleProvider.java
new file mode 100644
index 0000000..50d0f0a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gbx100/CasioGBX100SampleProvider.java
@@ -0,0 +1,80 @@
+/* Copyright (C) 2023-2024 Johannes Krude
+
+ 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.devices.casio.gbx100;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.CasioGBX100ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CasioGBX100ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class CasioGBX100SampleProvider extends AbstractSampleProvider<CasioGBX100ActivitySample> {
+ public CasioGBX100SampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ // The magic number 1500 is based on
+ // https://www.livestrong.com/article/474836-what-sport-burns-the-most-calories-per-hour/
+ return (rawIntensity / 1500f);
+ }
+
+ @Override
+ public CasioGBX100ActivitySample createActivitySample() {
+ return new CasioGBX100ActivitySample();
+ }
+
+ @Override
+ public AbstractDao<CasioGBX100ActivitySample, ?> getSampleDao() {
+ return getSession().getCasioGBX100ActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return CasioGBX100ActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CasioGBX100ActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CasioGBX100ActivitySampleDao.Properties.DeviceId;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gwb5600/CasioGMWB5000DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gwb5600/CasioGMWB5000DeviceCoordinator.java
new file mode 100644
index 0000000..f6a1263
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gwb5600/CasioGMWB5000DeviceCoordinator.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Johannes Krude, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+/* Based on code from BlueWatcher, https://github.com/masterjc/bluewatcher */
+package nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+
+public class CasioGMWB5000DeviceCoordinator extends CasioGWB5600DeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("CASIO GMW-B5000");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_casiogmwb5000;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gwb5600/CasioGWB5600DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gwb5600/CasioGWB5600DeviceCoordinator.java
new file mode 100644
index 0000000..39cc91d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/casio/gwb5600/CasioGWB5600DeviceCoordinator.java
@@ -0,0 +1,116 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Johannes Krude, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+/* Based on code from BlueWatcher, https://github.com/masterjc/bluewatcher */
+package nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.casio.Casio2C2DDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gwb5600.CasioGWB5600DeviceSupport;
+
+public class CasioGWB5600DeviceCoordinator extends Casio2C2DDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("CASIO GW-B5600");
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_BOND;
+ }
+
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_timeformat,
+ R.xml.devicesettings_dateformat_day_month_order,
+ R.xml.devicesettings_operating_sounds,
+ R.xml.devicesettings_hourly_chime_enable,
+ R.xml.devicesettings_autolight,
+ R.xml.devicesettings_light_duration_longer,
+ R.xml.devicesettings_power_saving,
+ R.xml.devicesettings_casio_connection_duration,
+ R.xml.devicesettings_time_sync,
+
+ // timer
+ // reminder
+ // world time
+ };
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(GBDevice device) {
+ return new String[]{
+ "auto",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "de_DE",
+ "it_IT",
+ "ru_RU",
+ };
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 5;
+ }
+
+ @Override
+ public int getMaximumReminderMessageLength() {
+ return 18;
+ }
+
+ @Override
+ public int getReminderSlotCount(final GBDevice device) {
+ return 5;
+ }
+
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return CasioGWB5600DeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_casiogwb5600;
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchPro2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchPro2Coordinator.java
new file mode 100644
index 0000000..a8123c5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchPro2Coordinator.java
@@ -0,0 +1,54 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class CmfWatchPro2Coordinator extends CmfWatchProCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^CMF Watch Pro 2(-[A-Z0-9]{4})$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_nothing_cmf_watch_pro_2;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_watchxplus;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_watchxplus_disabled;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ // We can negotiate auth key - #3982
+ return BONDING_STYLE_BOND;
+ }
+
+ @Override
+ public boolean supportsSunriseSunset() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProCoordinator.java
new file mode 100644
index 0000000..f6d3bdd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProCoordinator.java
@@ -0,0 +1,377 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.ArrayUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfActivitySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSpo2SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfStressSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.workout.CmfWorkoutSummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepSessionSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro.CmfInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro.CmfWatchProSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class CmfWatchProCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Watch Pro$");
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ final ParcelUuid casioService = new ParcelUuid(CmfWatchProSupport.UUID_SERVICE_CMF_CMD);
+ final ScanFilter filter = new ScanFilter.Builder().setServiceUuid(casioService).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Nullable
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final CmfInstallHandler handler = new CmfInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean suggestUnbindBeforePair() {
+ return false;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull final GBDevice gbDevice,
+ @NonNull final Device device,
+ @NonNull final DaoSession session) throws GBException {
+ final Long deviceId = device.getId();
+
+ session.getCmfActivitySampleDao().queryBuilder()
+ .where(CmfActivitySampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getCmfStressSampleDao().queryBuilder()
+ .where(CmfStressSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getCmfHeartRateSampleDao().queryBuilder()
+ .where(CmfHeartRateSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getCmfSleepSessionSampleDao().queryBuilder()
+ .where(CmfSleepSessionSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getCmfSleepStageSampleDao().queryBuilder()
+ .where(CmfSleepStageSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getCmfSpo2SampleDao().queryBuilder()
+ .where(CmfSpo2SampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Nothing";
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_nothing_cmf_watch_pro;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return CmfWatchProSupport.class;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean validateAuthKey(final String authKey) {
+ final byte[] authKeyBytes = authKey.trim().getBytes();
+ return authKeyBytes.length == 32 || (authKey.startsWith("0x") && authKeyBytes.length == 34);
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificAuthenticationSettings() {
+ return new int[]{R.xml.devicesettings_pairingkey};
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(final GBDevice device, DaoSession session) {
+ return new CmfActivitySampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends StressSample> getStressSampleProvider(final GBDevice device, final DaoSession session) {
+ return new CmfStressSampleProvider(device, session);
+ }
+
+ @Override
+ public int[] getStressRanges() {
+ // 1-29 = relaxed
+ // 30-59 = normal
+ // 60-79 = medium
+ // 80-99 = high
+ return new int[]{1, 30, 60, 80};
+ }
+
+ @Override
+ public TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ return new CmfSpo2SampleProvider(device, session);
+ }
+
+ @Nullable
+ @Override
+ public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
+ return new CmfWorkoutSummaryParser(device);
+ }
+
+ @Override
+ public boolean supportsFlashing() {
+ return true;
+ }
+
+ @Override
+ public int getAlarmSlotCount(final GBDevice device) {
+ return 5;
+ }
+
+ @Override
+ public boolean supportsAlarmTitle(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public int getAlarmTitleLimit(final GBDevice device) {
+ return 8;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false; // TODO for watchface management
+ }
+
+ @Override
+ public boolean supportsCachedAppManagement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsInstalledAppManagement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWatchfaceManagement(GBDevice device) {
+ return supportsAppsManagement(device);
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return AppManagerActivity.class;
+ }
+
+ @Override
+ public boolean supportsAppListFetching() {
+ return false; // TODO it does not, but we can fake it for watchfaces
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public int getContactsSlotCount(final GBDevice device) {
+ return 20;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsManualHeartRateMeasurement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRemSleep() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(final GBDevice device) {
+ final List<Integer> settings = new ArrayList<>();
+
+ settings.add(R.xml.devicesettings_header_time);
+ settings.add(R.xml.devicesettings_timeformat);
+
+ settings.add(R.xml.devicesettings_header_display);
+ settings.add(R.xml.devicesettings_workout_activity_types);
+ settings.add(R.xml.devicesettings_liftwrist_display_noshed);
+
+ settings.add(R.xml.devicesettings_header_health);
+ settings.add(R.xml.devicesettings_heartrate_sleep_alert_activity_stress_spo2);
+ settings.add(R.xml.devicesettings_inactivity_dnd);
+ settings.add(R.xml.devicesettings_hydration_reminder_dnd);
+
+ settings.add(R.xml.devicesettings_header_notifications);
+ settings.add(R.xml.devicesettings_send_app_notifications);
+ settings.add(R.xml.devicesettings_transliteration);
+
+ settings.add(R.xml.devicesettings_header_other);
+ if (getContactsSlotCount(device) > 0) {
+ settings.add(R.xml.devicesettings_contacts);
+ }
+
+ return ArrayUtils.toPrimitive(settings.toArray(new Integer[0]));
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new CmfWatchProSettingsCustomizer();
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return null;
+ // FIXME language setting does not seem to work from phone
+ //return new String[]{
+ // "auto",
+ // "ar_SA",
+ // "de_DE",
+ // "en_US",
+ // "es_ES",
+ // "fr_FR",
+ // "hi_IN",
+ // "id_ID",
+ // "it_IT",
+ // "ja_JP",
+ // "ko_KO",
+ // "zh_CN",
+ // "zh_HK",
+ //};
+ }
+
+ @Override
+ public List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
+ return Arrays.asList(
+ HeartRateCapability.MeasurementInterval.OFF,
+ HeartRateCapability.MeasurementInterval.SMART
+ );
+ }
+
+ protected static Prefs getPrefs(final GBDevice device) {
+ return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
+ }
+
+ public boolean supportsSunriseSunset() {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProSettingsCustomizer.java
new file mode 100644
index 0000000..a6b9b0e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProSettingsCustomizer.java
@@ -0,0 +1,82 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro;
+
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+import androidx.preference.Preference;
+
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class CmfWatchProSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ final String[] prefsToHide = new String[]{
+ "pref_key_header_heartrate_sleep",
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION,
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING,
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_ACTIVITY_MONITORING,
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_RELAXATION_REMINDER,
+ DeviceSettingsPreferenceConst.PREF_INACTIVITY_START,
+ DeviceSettingsPreferenceConst.PREF_INACTIVITY_END,
+ };
+
+ for (final String prefKey : prefsToHide) {
+ final Preference pref = handler.findPreference(prefKey);
+ if (pref != null) {
+ pref.setVisible(false);
+ }
+ }
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ public static final Creator<CmfWatchProSettingsCustomizer> CREATOR = new Creator<CmfWatchProSettingsCustomizer>() {
+ @Override
+ public CmfWatchProSettingsCustomizer createFromParcel(final Parcel in) {
+ return new CmfWatchProSettingsCustomizer();
+ }
+
+ @Override
+ public CmfWatchProSettingsCustomizer[] newArray(final int size) {
+ return new CmfWatchProSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull final Parcel dest, final int flags) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfActivitySampleProvider.java
new file mode 100644
index 0000000..d1c42e5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfActivitySampleProvider.java
@@ -0,0 +1,187 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+
+public class CmfActivitySampleProvider extends AbstractSampleProvider<CmfActivitySample> {
+ private static final Logger LOG = LoggerFactory.getLogger(CmfActivitySampleProvider.class);
+
+ public CmfActivitySampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<CmfActivitySample, ?> getSampleDao() {
+ return getSession().getCmfActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return CmfActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(final int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(final ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(final int rawIntensity) {
+ return rawIntensity / 100f;
+ }
+
+ @Override
+ public CmfActivitySample createActivitySample() {
+ return new CmfActivitySample();
+ }
+
+ @Override
+ protected List<CmfActivitySample> getGBActivitySamples(final int timestamp_from, final int timestamp_to) {
+ LOG.trace(
+ "Getting cmf activity samples between {} and {}",
+ timestamp_from,
+ timestamp_to
+ );
+
+ final long nanoStart = System.nanoTime();
+
+ final List<CmfActivitySample> samples = super.getGBActivitySamples(timestamp_from, timestamp_to);
+
+ if (!samples.isEmpty()) {
+ convertCumulativeSteps(samples, CmfActivitySampleDao.Properties.Steps);
+ }
+
+ final Map<Integer, CmfActivitySample> sampleByTs = new HashMap<>();
+ for (final CmfActivitySample sample : samples) {
+ sampleByTs.put(sample.getTimestamp(), sample);
+ }
+
+ overlayHeartRate(sampleByTs, timestamp_from, timestamp_to);
+ overlaySleep(sampleByTs, timestamp_from, timestamp_to);
+
+ final List<CmfActivitySample> finalSamples = new ArrayList<>(sampleByTs.values());
+ Collections.sort(finalSamples, (a, b) -> Integer.compare(a.getTimestamp(), b.getTimestamp()));
+
+ final long nanoEnd = System.nanoTime();
+
+ final long executionTime = (nanoEnd - nanoStart) / 1000000;
+
+ LOG.trace("Getting cmf samples took {}ms", executionTime);
+
+ return finalSamples;
+ }
+
+ private void overlayHeartRate(final Map<Integer, CmfActivitySample> sampleByTs, final int timestamp_from, final int timestamp_to) {
+ final CmfHeartRateSampleProvider heartRateSampleProvider = new CmfHeartRateSampleProvider(getDevice(), getSession());
+ final List<CmfHeartRateSample> hrSamples = heartRateSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L);
+
+ for (final CmfHeartRateSample hrSample : hrSamples) {
+ // round to the nearest minute, we don't need per-second granularity
+ final int tsSeconds = (int) ((hrSample.getTimestamp() / 1000) / 60) * 60;
+ CmfActivitySample sample = sampleByTs.get(tsSeconds);
+ if (sample == null) {
+ //LOG.debug("Adding dummy sample at {} for hr", tsSeconds);
+ sample = new CmfActivitySample();
+ sample.setTimestamp(tsSeconds);
+ sample.setProvider(this);
+ sampleByTs.put(tsSeconds, sample);
+ }
+
+ sample.setHeartRate(hrSample.getHeartRate());
+ }
+ }
+
+ private void overlaySleep(final Map<Integer, CmfActivitySample> sampleByTs, final int timestamp_from, final int timestamp_to) {
+ final CmfSleepStageSampleProvider sleepStageSampleProvider = new CmfSleepStageSampleProvider(getDevice(), getSession());
+ final List<CmfSleepStageSample> sleepStageSamples = sleepStageSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L);
+
+ for (final CmfSleepStageSample sleepStageSample : sleepStageSamples) {
+ // round to the nearest minute, we don't need per-second granularity
+ final int tsSeconds = (int) ((sleepStageSample.getTimestamp() / 1000) / 60) * 60;
+ for (int i = tsSeconds; i < tsSeconds + sleepStageSample.getDuration(); i += 60) {
+ CmfActivitySample sample = sampleByTs.get(i);
+ if (sample == null) {
+ //LOG.debug("Adding dummy sample at {} for sleep", i);
+ sample = new CmfActivitySample();
+ sample.setTimestamp(i);
+ sample.setProvider(this);
+ sampleByTs.put(i, sample);
+ }
+
+ final ActivityKind sleepRawKind = sleepStageToActivityKind(sleepStageSample.getStage());
+ sample.setRawKind(sleepRawKind.getCode());
+ sample.setRawIntensity(ActivitySample.NOT_MEASURED);
+ }
+ }
+ }
+
+ final ActivityKind sleepStageToActivityKind(final int sleepStage) {
+ switch (sleepStage) {
+ case 1:
+ return ActivityKind.DEEP_SLEEP;
+ case 2:
+ return ActivityKind.LIGHT_SLEEP;
+ case 3:
+ return ActivityKind.REM_SLEEP;
+ default:
+ return ActivityKind.UNKNOWN;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfHeartRateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfHeartRateSampleProvider.java
new file mode 100644
index 0000000..268e44d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfHeartRateSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfHeartRateSampleProvider extends AbstractTimeSampleProvider<CmfHeartRateSample> {
+ public CmfHeartRateSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<CmfHeartRateSample, ?> getSampleDao() {
+ return getSession().getCmfHeartRateSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfHeartRateSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfHeartRateSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfHeartRateSample createSample() {
+ return new CmfHeartRateSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepSessionSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepSessionSampleProvider.java
new file mode 100644
index 0000000..b907137
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepSessionSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepSessionSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepSessionSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfSleepSessionSampleProvider extends AbstractTimeSampleProvider<CmfSleepSessionSample> {
+ public CmfSleepSessionSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<CmfSleepSessionSample, ?> getSampleDao() {
+ return getSession().getCmfSleepSessionSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfSleepSessionSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfSleepSessionSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfSleepSessionSample createSample() {
+ return new CmfSleepSessionSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepStageSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepStageSampleProvider.java
new file mode 100644
index 0000000..c78a1b5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepStageSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfSleepStageSampleProvider extends AbstractTimeSampleProvider<CmfSleepStageSample> {
+ public CmfSleepStageSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<CmfSleepStageSample, ?> getSampleDao() {
+ return getSession().getCmfSleepStageSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfSleepStageSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfSleepStageSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfSleepStageSample createSample() {
+ return new CmfSleepStageSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSpo2SampleProvider.java
new file mode 100644
index 0000000..860c50e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSpo2SampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfSpo2SampleProvider extends AbstractTimeSampleProvider<CmfSpo2Sample> {
+ public CmfSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<CmfSpo2Sample, ?> getSampleDao() {
+ return getSession().getCmfSpo2SampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfSpo2SampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfSpo2SampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfSpo2Sample createSample() {
+ return new CmfSpo2Sample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfStressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfStressSampleProvider.java
new file mode 100644
index 0000000..3e1a94a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfStressSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfStressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfStressSampleProvider extends AbstractTimeSampleProvider<CmfStressSample> {
+ public CmfStressSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<CmfStressSample, ?> getSampleDao() {
+ return getSession().getCmfStressSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfStressSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfStressSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfStressSample createSample() {
+ return new CmfStressSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfWorkoutGpsSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfWorkoutGpsSampleProvider.java
new file mode 100644
index 0000000..680906e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfWorkoutGpsSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfWorkoutGpsSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfWorkoutGpsSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfWorkoutGpsSampleProvider extends AbstractTimeSampleProvider<CmfWorkoutGpsSample> {
+ public CmfWorkoutGpsSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<CmfWorkoutGpsSample, ?> getSampleDao() {
+ return getSession().getCmfWorkoutGpsSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfWorkoutGpsSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfWorkoutGpsSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfWorkoutGpsSample createSample() {
+ return new CmfWorkoutGpsSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/workout/CmfWorkoutSummaryParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/workout/CmfWorkoutSummaryParser.java
new file mode 100644
index 0000000..f66faf6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/workout/CmfWorkoutSummaryParser.java
@@ -0,0 +1,74 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.workout;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ACTIVE_SECONDS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Date;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro.CmfActivityType;
+
+public class CmfWorkoutSummaryParser implements ActivitySummaryParser {
+ private final GBDevice gbDevice;
+
+ public CmfWorkoutSummaryParser(final GBDevice device) {
+ this.gbDevice = device;
+ }
+
+ @Override
+ public BaseActivitySummary parseBinaryData(final BaseActivitySummary summary, final boolean forDetails) {
+ final byte[] rawSummaryData = summary.getRawSummaryData();
+ if (rawSummaryData == null) {
+ return summary;
+ }
+
+ final ByteBuffer buf = ByteBuffer.wrap(rawSummaryData).order(ByteOrder.LITTLE_ENDIAN);
+
+ final ActivitySummaryData summaryData = new ActivitySummaryData();
+
+ final int startTime = buf.getInt();
+ final int duration = buf.getShort();
+ final byte workoutType = buf.get();
+
+ buf.get(new byte[19]); // ?
+ final int endTime = buf.getInt();
+ final boolean gps = buf.get() == 1;
+ buf.get(); // ?
+
+ summary.setStartTime(new Date(startTime * 1000L));
+ summary.setEndTime(new Date(endTime * 1000L));
+
+ final CmfActivityType cmfActivityType = CmfActivityType.fromCode(workoutType);
+ if (cmfActivityType != null) {
+ summary.setActivityKind(cmfActivityType.getActivityKind().getCode());
+ } else {
+ summary.setActivityKind(ActivityKind.UNKNOWN.getCode());
+ }
+
+ summaryData.add(ACTIVE_SECONDS, duration, UNIT_SECONDS);
+
+ return summary;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/AbstractColmiR0xCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/AbstractColmiR0xCoordinator.java
new file mode 100644
index 0000000..1a6136c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/AbstractColmiR0xCoordinator.java
@@ -0,0 +1,233 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi;
+
+import androidx.annotation.NonNull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+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.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiActivitySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiHrvSummarySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiHrvValueSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiSpo2SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiStressSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHrvSummarySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHrvValueSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepSessionSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepStageSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.colmi.ColmiR0xDeviceSupport;
+
+public abstract class AbstractColmiR0xCoordinator extends AbstractBLEDeviceCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractColmiR0xCoordinator.class);
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ final Long deviceId = device.getId();
+
+ final Map<AbstractDao<?, ?>, Property> daoMap = new HashMap<AbstractDao<?, ?>, Property>() {{
+ put(session.getColmiActivitySampleDao(), ColmiActivitySampleDao.Properties.DeviceId);
+ put(session.getColmiHeartRateSampleDao(), ColmiHeartRateSampleDao.Properties.DeviceId);
+ put(session.getColmiSpo2SampleDao(), ColmiSpo2SampleDao.Properties.DeviceId);
+ put(session.getColmiStressSampleDao(), ColmiStressSampleDao.Properties.DeviceId);
+ put(session.getColmiSleepSessionSampleDao(), ColmiSleepSessionSampleDao.Properties.DeviceId);
+ put(session.getColmiSleepStageSampleDao(), ColmiSleepStageSampleDao.Properties.DeviceId);
+ put(session.getColmiHrvSummarySampleDao(), ColmiHrvSummarySampleDao.Properties.DeviceId);
+ put(session.getColmiHrvValueSampleDao(), ColmiHrvValueSampleDao.Properties.DeviceId);
+ }};
+
+ for (final Map.Entry<AbstractDao<?, ?>, Property> e : daoMap.entrySet()) {
+ e.getKey().queryBuilder()
+ .where(e.getValue().eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Colmi";
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return ColmiR0xDeviceSupport.class;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_smartring;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_smartring_disabled;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public boolean supportsPowerOff() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateStats() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsManualHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsRemSleep() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsAwakeSleep() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHrvMeasurement() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new ColmiActivitySampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(GBDevice device, DaoSession session) {
+ return new ColmiSpo2SampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends StressSample> getStressSampleProvider(GBDevice device, DaoSession session) {
+ return new ColmiStressSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HrvSummarySample> getHrvSummarySampleProvider(GBDevice device, DaoSession session) {
+ return new ColmiHrvSummarySampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HrvValueSample> getHrvValueSampleProvider(final GBDevice device, final DaoSession session) {
+ return new ColmiHrvValueSampleProvider(device, session);
+ }
+
+ @Override
+ public List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
+ return Arrays.asList(
+ HeartRateCapability.MeasurementInterval.OFF,
+ HeartRateCapability.MeasurementInterval.MINUTES_5,
+ HeartRateCapability.MeasurementInterval.MINUTES_10,
+ HeartRateCapability.MeasurementInterval.MINUTES_15,
+ HeartRateCapability.MeasurementInterval.MINUTES_30,
+ HeartRateCapability.MeasurementInterval.MINUTES_45,
+ HeartRateCapability.MeasurementInterval.HOUR_1
+ );
+ }
+
+ @Override
+ public int[] getStressRanges() {
+ // 1-29 = relaxed
+ // 30-59 = normal
+ // 60-79 = medium
+ // 80-99 = high
+ return new int[]{1, 30, 60, 80};
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_colmi_r0x);
+ return deviceSpecificSettings;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR02Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR02Coordinator.java
new file mode 100644
index 0000000..e56f2ff
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR02Coordinator.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class ColmiR02Coordinator extends AbstractColmiR0xCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiR02Coordinator.class);
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("R02_.*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_colmi_r02;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR03Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR03Coordinator.java
new file mode 100644
index 0000000..0ab8a74
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR03Coordinator.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class ColmiR03Coordinator extends AbstractColmiR0xCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiR03Coordinator.class);
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("R03_.*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_colmi_r03;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR06Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR06Coordinator.java
new file mode 100644
index 0000000..46ea27b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR06Coordinator.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class ColmiR06Coordinator extends AbstractColmiR0xCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiR06Coordinator.class);
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("R06_.*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_colmi_r06;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR09Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR09Coordinator.java
new file mode 100644
index 0000000..8a27e0d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR09Coordinator.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class ColmiR09Coordinator extends AbstractColmiR0xCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiR09Coordinator.class);
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("R09_.*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_colmi_r09;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xConstants.java
new file mode 100644
index 0000000..fd80e8c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xConstants.java
@@ -0,0 +1,67 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi;
+
+import java.util.UUID;
+
+public class ColmiR0xConstants {
+ public static final UUID CHARACTERISTIC_SERVICE_V1 = UUID.fromString("6e40fff0-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID CHARACTERISTIC_SERVICE_V2 = UUID.fromString("de5bf728-d711-4e47-af26-65e3012a5dc7");
+ public static final UUID CHARACTERISTIC_WRITE = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID CHARACTERISTIC_COMMAND = UUID.fromString("de5bf72a-d711-4e47-af26-65e3012a5dc7");
+ public static final UUID CHARACTERISTIC_NOTIFY_V1 = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID CHARACTERISTIC_NOTIFY_V2 = UUID.fromString("de5bf729-d711-4e47-af26-65e3012a5dc7");
+
+ public static final byte CMD_SET_DATE_TIME = 0x01;
+ public static final byte CMD_BATTERY = 0x03;
+ public static final byte CMD_PHONE_NAME = 0x04;
+ public static final byte CMD_POWER_OFF = 0x08;
+ public static final byte CMD_PREFERENCES = 0x0a;
+ public static final byte CMD_SYNC_HEART_RATE = 0x15;
+ public static final byte CMD_AUTO_HR_PREF = 0x16;
+ public static final byte CMD_GOALS = 0x21;
+ public static final byte CMD_AUTO_SPO2_PREF = 0x2c;
+ public static final byte CMD_PACKET_SIZE = 0x2f;
+ public static final byte CMD_AUTO_STRESS_PREF = 0x36;
+ public static final byte CMD_SYNC_STRESS = 0x37;
+ public static final byte CMD_AUTO_HRV_PREF = 0x38;
+ public static final byte CMD_SYNC_HRV = 0x39;
+ public static final byte CMD_SYNC_ACTIVITY = 0x43;
+ public static final byte CMD_FIND_DEVICE = 0x50;
+ public static final byte CMD_MANUAL_HEART_RATE = 0x69;
+ public static final byte CMD_NOTIFICATION = 0x73;
+ public static final byte CMD_BIG_DATA_V2 = (byte) 0xbc;
+ public static final byte CMD_FACTORY_RESET = (byte) 0xff;
+
+ public static final byte PREF_READ = 0x01;
+ public static final byte PREF_WRITE = 0x02;
+ public static final byte PREF_DELETE = 0x03;
+
+ public static final byte NOTIFICATION_NEW_HR_DATA = 0x01;
+ public static final byte NOTIFICATION_NEW_SPO2_DATA = 0x03;
+ public static final byte NOTIFICATION_NEW_STEPS_DATA = 0x04;
+ public static final byte NOTIFICATION_BATTERY_LEVEL = 0x0c;
+ public static final byte NOTIFICATION_LIVE_ACTIVITY = 0x12;
+
+ public static final byte BIG_DATA_TYPE_SLEEP = 0x27;
+ public static final byte BIG_DATA_TYPE_SPO2 = 0x2a;
+
+ public static final byte SLEEP_TYPE_LIGHT = 0x02;
+ public static final byte SLEEP_TYPE_DEEP = 0x03;
+ public static final byte SLEEP_TYPE_REM = 0x04;
+ public static final byte SLEEP_TYPE_AWAKE = 0x05;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xPacketHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xPacketHandler.java
new file mode 100644
index 0000000..d7d06ef
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xPacketHandler.java
@@ -0,0 +1,491 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi;
+
+import android.content.Context;
+import android.content.Intent;
+import android.widget.Toast;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiActivitySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiHeartRateSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiHrvValueSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiSleepSessionSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiSleepStageSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiSpo2SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiStressSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepSessionSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiStressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.colmi.ColmiR0xDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+public class ColmiR0xPacketHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiR0xPacketHandler.class);
+
+ public static void hrIntervalSettings(ColmiR0xDeviceSupport support, byte[] value) {
+ if (value[1] == ColmiR0xConstants.PREF_WRITE) return; // ignore empty response when writing setting
+ boolean enabled = value[2] == 0x01;
+ int minutes = value[3];
+ LOG.info("Received HR interval preference: {} minutes, enabled={}", minutes, enabled);
+ GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences();
+ eventUpdatePreferences.withPreference(
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_MEASUREMENT_INTERVAL,
+ String.valueOf(minutes * 60)
+ );
+ support.evaluateGBDeviceEvent(eventUpdatePreferences);
+ }
+
+ public static void spo2Settings(ColmiR0xDeviceSupport support, byte[] value) {
+ boolean enabled = value[2] == 0x01;
+ LOG.info("Received SpO2 preference: {}", enabled ? "enabled" : "disabled");
+ GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences();
+ eventUpdatePreferences.withPreference(
+ DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING,
+ enabled
+ );
+ support.evaluateGBDeviceEvent(eventUpdatePreferences);
+ }
+
+ public static void stressSettings(ColmiR0xDeviceSupport support, byte[] value) {
+ boolean enabled = value[2] == 0x01;
+ LOG.info("Received stress preference: {}", enabled ? "enabled" : "disabled");
+ GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences();
+ eventUpdatePreferences.withPreference(
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING,
+ enabled
+ );
+ support.evaluateGBDeviceEvent(eventUpdatePreferences);
+ }
+
+ public static void hrvSettings(ColmiR0xDeviceSupport support, byte[] value) {
+ boolean enabled = value[2] == 0x01;
+ LOG.info("Received HRV preference: {}", enabled ? "enabled" : "disabled");
+ GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences();
+ eventUpdatePreferences.withPreference(
+ DeviceSettingsPreferenceConst.PREF_HRV_ALL_DAY_MONITORING,
+ enabled
+ );
+ support.evaluateGBDeviceEvent(eventUpdatePreferences);
+ }
+
+ public static void goalsSettings(byte[] value) {
+ int steps = BLETypeConversions.toUint32(value[2], value[3], value[4], (byte) 0);
+ int calories = BLETypeConversions.toUint32(value[5], value[6], value[7], (byte) 0);
+ int distance = BLETypeConversions.toUint32(value[8], value[9], value[10], (byte) 0);
+ int sport = BLETypeConversions.toUint16(value[11], value[12]);
+ int sleep = BLETypeConversions.toUint16(value[13], value[14]);
+ LOG.info("Received goals preferences: {} steps, {} calories, {}m distance, {}min sport, {}min sleep", steps, calories, distance, sport, sleep);
+ }
+
+ public static void liveHeartRate(GBDevice device, Context context, byte[] value) {
+ int errorCode = value[2];
+ int hrResponse = value[3] & 0xff;
+ switch (errorCode) {
+ case 0:
+ LOG.info("Received live heart rate response: {} bpm", hrResponse);
+ break;
+ case 1:
+ GB.toast(context.getString(R.string.smart_ring_measurement_error_worn_incorrectly), Toast.LENGTH_LONG, GB.ERROR);
+ LOG.warn("Live HR error code {} received from ring", errorCode);
+ return;
+ case 2:
+ LOG.warn("Live HR error 2 (temporary error / missing data) received");
+ return;
+ default:
+ GB.toast(String.format(context.getString(R.string.smart_ring_measurement_error_unknown), errorCode), Toast.LENGTH_LONG, GB.ERROR);
+ LOG.warn("Live HR error code {} received from ring", errorCode);
+ return;
+ }
+ if (hrResponse > 0) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ // Build sample object and save in database
+ ColmiHeartRateSampleProvider sampleProvider = new ColmiHeartRateSampleProvider(device, db.getDaoSession());
+ Long userId = DBHelper.getUser(db.getDaoSession()).getId();
+ Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId();
+ ColmiHeartRateSample gbSample = new ColmiHeartRateSample();
+ gbSample.setDeviceId(deviceId);
+ gbSample.setUserId(userId);
+ gbSample.setTimestamp(Calendar.getInstance().getTimeInMillis());
+ gbSample.setHeartRate(hrResponse);
+ sampleProvider.addSample(gbSample);
+ // Send local intent with sample for listeners like the heart rate dialog
+ Intent liveIntent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES);
+ liveIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ liveIntent.putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, gbSample);
+ LocalBroadcastManager.getInstance(context)
+ .sendBroadcast(liveIntent);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database for recording heart rate samples", e);
+ }
+ }
+ }
+
+ public static void liveActivity(byte[] value) {
+ int steps = BLETypeConversions.toUint32(value[4], value[3], value[2], (byte) 0);
+ int calories = BLETypeConversions.toUint32(value[7], value[6], value[5], (byte) 0) / 10;
+ int distance = BLETypeConversions.toUint32(value[10], value[9], value[8], (byte) 0);
+ LOG.info("Received live activity notification: {} steps, {} calories, {}m distance", steps, calories, distance);
+ }
+
+ public static void historicalActivity(GBDevice device, Context context, byte[] value) {
+ if ((value[1] & 0xff) == 0xff) {
+ device.unsetBusyTask();
+ device.sendDeviceUpdateIntent(context);
+ LOG.info("Empty activity history, sync aborted");
+ } else if ((value[1] & 0xff) == 0xf0) {
+ // initial packet, doesn't contain anything interesting
+ } else {
+ // Unpack timestamp and data
+ Calendar sampleCal = Calendar.getInstance();
+ // The code below converts the raw hex value to a date. That seems wrong, but is correct,
+ // because this date is for some reason transmitted as ints used as literal bytes:
+ // A date like 2024-08-18 would be transmitted as 0x24 0x08 0x18.
+ sampleCal.set(Calendar.YEAR, 2000 + Integer.valueOf(String.format("%02x", value[1])));
+ sampleCal.set(Calendar.MONTH, Integer.valueOf(String.format("%02x", value[2])) - 1);
+ sampleCal.set(Calendar.DAY_OF_MONTH, Integer.valueOf(String.format("%02x", value[3])));
+ sampleCal.set(Calendar.HOUR_OF_DAY, value[4] / 4); // And the hour is transmitted as nth quarter of the day...
+ sampleCal.set(Calendar.MINUTE, 0);
+ sampleCal.set(Calendar.SECOND, 0);
+ sampleCal.set(Calendar.MILLISECOND, 0);
+ int calories = BLETypeConversions.toUint16(value[7], value[8]);
+ int steps = BLETypeConversions.toUint16(value[9], value[10]);
+ int distance = BLETypeConversions.toUint16(value[11], value[12]);
+ LOG.info("Received activity sample: {} - {} calories, {} steps, {} distance", sampleCal.getTime(), calories, steps, distance);
+ // Build sample object and save in database
+ try (DBHandler db = GBApplication.acquireDB()) {
+ ColmiActivitySampleProvider sampleProvider = new ColmiActivitySampleProvider(device, db.getDaoSession());
+ Long userId = DBHelper.getUser(db.getDaoSession()).getId();
+ Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId();
+ ColmiActivitySample gbSample = sampleProvider.createActivitySample();
+ gbSample.setProvider(sampleProvider);
+ gbSample.setDeviceId(deviceId);
+ gbSample.setUserId(userId);
+ gbSample.setRawKind(ActivityKind.ACTIVITY.getCode());
+ gbSample.setTimestamp((int) (sampleCal.getTimeInMillis() / 1000));
+ gbSample.setCalories(calories);
+ gbSample.setSteps(steps);
+ gbSample.setDistance(distance);
+ sampleProvider.addGBActivitySample(gbSample);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database for recording activity samples", e);
+ }
+ // Determine if this sync is done
+ int currentActivityPacket = value[5];
+ int totalActivityPackets = value[6];
+ if (currentActivityPacket == totalActivityPackets - 1) {
+ device.unsetBusyTask();
+ device.sendDeviceUpdateIntent(context);
+ }
+ }
+ }
+
+ public static void historicalStress(GBDevice device, Context context, byte[] value) {
+ ArrayList<ColmiStressSample> stressSamples = new ArrayList<>();
+ int stressPacketNr = value[1] & 0xff;
+ if (stressPacketNr == 0xff) {
+ device.unsetBusyTask();
+ device.sendDeviceUpdateIntent(context);
+ LOG.info("Empty stress history, sync aborted");
+ } else if (stressPacketNr == 0) {
+ LOG.info("Received initial stress history response");
+ } else {
+ Calendar sampleCal = Calendar.getInstance();
+ sampleCal.set(Calendar.SECOND, 0);
+ sampleCal.set(Calendar.MILLISECOND, 0);
+ int startValue = stressPacketNr == 1 ? 3 : 2; // packet 1 data starts at byte 3, others at byte 2
+ int minutesInPreviousPackets = 0;
+ if (stressPacketNr > 1) {
+ // 30 is the interval in minutes between values/measurements
+ minutesInPreviousPackets = 12 * 30; // 12 values in packet 1
+ minutesInPreviousPackets += (stressPacketNr - 2) * 13 * 30; // 13 values per packet
+ }
+ for (int i = startValue; i < value.length - 1; i++) {
+ if (value[i] != 0x00) {
+ // Determine time of day
+ int minuteOfDay = minutesInPreviousPackets + (i - startValue) * 30;
+ sampleCal.set(Calendar.HOUR_OF_DAY, minuteOfDay / 60);
+ sampleCal.set(Calendar.MINUTE, minuteOfDay % 60);
+ LOG.info("Stress level is {} at {}", value[i] & 0xff, sampleCal.getTime());
+ // Build sample object and save in database
+ ColmiStressSample gbSample = new ColmiStressSample();
+ gbSample.setTimestamp(sampleCal.getTimeInMillis());
+ gbSample.setStress(value[i] & 0xff);
+ stressSamples.add(gbSample);
+ }
+ }
+ if (!stressSamples.isEmpty()) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ ColmiStressSampleProvider sampleProvider = new ColmiStressSampleProvider(device, db.getDaoSession());
+ Long userId = DBHelper.getUser(db.getDaoSession()).getId();
+ Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId();
+ for (final ColmiStressSample sample : stressSamples) {
+ sample.setDeviceId(deviceId);
+ sample.setUserId(userId);
+ }
+ LOG.info("Will persist {} stress samples", stressSamples.size());
+ sampleProvider.addSamples(stressSamples);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database for recording stress samples", e);
+ }
+ }
+ if (stressPacketNr == 4) {
+ device.unsetBusyTask();
+ device.sendDeviceUpdateIntent(context);
+ }
+ }
+ }
+
+ public static void historicalSpo2(GBDevice device, byte[] value) {
+ ArrayList<ColmiSpo2Sample> spo2Samples = new ArrayList<>();
+ int length = BLETypeConversions.toUint16(value[2], value[3]);
+ int index = 6; // start of data (day nr, followed by values)
+ int spo2_days_ago = -1;
+ while (spo2_days_ago != 0 && index - 6 < length) {
+ spo2_days_ago = value[index];
+ Calendar syncingDay = Calendar.getInstance();
+ syncingDay.add(Calendar.DAY_OF_MONTH, 0 - spo2_days_ago);
+ syncingDay.set(Calendar.MINUTE, 0);
+ syncingDay.set(Calendar.SECOND, 0);
+ syncingDay.set(Calendar.MILLISECOND, 0);
+ index++;
+ for (int hour=0; hour<=23; hour++) {
+ syncingDay.set(Calendar.HOUR_OF_DAY, hour);
+ float spo2_min = value[index];
+ index++;
+ float spo2_max = value[index];
+ index++;
+ if (spo2_min > 0 && spo2_max > 0) {
+ LOG.info("Received SpO2 data from {} days ago at {}:00: min={}, max={}", spo2_days_ago, hour, spo2_min, spo2_max);
+ ColmiSpo2Sample spo2Sample = new ColmiSpo2Sample();
+ spo2Sample.setTimestamp(syncingDay.getTimeInMillis());
+ spo2Sample.setSpo2(Math.round((spo2_min + spo2_max) / 2.0f));
+ spo2Samples.add(spo2Sample);
+ }
+ if (index - 6 >= length) {
+ break;
+ }
+ }
+ }
+ if (!spo2Samples.isEmpty()) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ ColmiSpo2SampleProvider sampleProvider = new ColmiSpo2SampleProvider(device, db.getDaoSession());
+ Long userId = DBHelper.getUser(db.getDaoSession()).getId();
+ Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId();
+ for (final ColmiSpo2Sample sample : spo2Samples) {
+ sample.setDeviceId(deviceId);
+ sample.setUserId(userId);
+ }
+ LOG.info("Will persist {} SpO2 samples", spo2Samples.size());
+ sampleProvider.addSamples(spo2Samples);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database for recording SpO2 samples", e);
+ }
+ }
+ }
+
+ public static void historicalSleep(GBDevice gbDevice, Context context, byte[] value) {
+ int packetLength = BLETypeConversions.toUint16(value[2], value[3]);
+ if (packetLength < 2) {
+ LOG.info("Received empty sleep data packet: {}", StringUtils.bytesToHex(value));
+ } else {
+ int daysInPacket = value[6];
+ LOG.debug("Received sleep data packet for {} days: {}", daysInPacket, StringUtils.bytesToHex(value));
+ int index = 7;
+ for (int i = 1; i <= daysInPacket; i++) {
+ // Parse sleep session
+ int daysAgo = value[index];
+ index++;
+ int dayBytes = value[index];
+ index++;
+ // sleepStart is received as "minutes after midnight"
+ int sleepStart = BLETypeConversions.toUint16(value[index], value[index + 1]);
+ index += 2;
+ // sleepEnd is received as "minutes after midnight"
+ int sleepEnd = BLETypeConversions.toUint16(value[index], value[index + 1]);
+ index += 2;
+ // Calculate sleep start timestamp
+ LOG.info("Sleep session daysAgo={}, dayBytes={}, sleepStart={}, sleepEnd={}", daysAgo, dayBytes, sleepStart, sleepEnd);
+ Calendar sessionStart = Calendar.getInstance();
+ sessionStart.add(Calendar.DAY_OF_MONTH, 0 - daysAgo);
+ sessionStart.set(Calendar.HOUR_OF_DAY, 0);
+ sessionStart.set(Calendar.MINUTE, 0);
+ sessionStart.set(Calendar.SECOND, 0);
+ sessionStart.set(Calendar.MILLISECOND, 0);
+ if (sleepStart > sleepEnd) {
+ // Sleep started a day earlier, so before midnight
+ sessionStart.add(Calendar.MINUTE, sleepStart - 1440);
+ } else {
+ // Sleep started this day, so after midnight
+ sessionStart.add(Calendar.MINUTE, sleepStart);
+ }
+ // Calculate sleep end timestamp
+ Calendar sessionEnd = Calendar.getInstance();
+ sessionEnd.add(Calendar.DAY_OF_MONTH, 0 - daysAgo);
+ sessionEnd.set(Calendar.HOUR_OF_DAY, 0);
+ sessionEnd.set(Calendar.MINUTE, sleepEnd);
+ sessionEnd.set(Calendar.SECOND, 0);
+ sessionEnd.set(Calendar.MILLISECOND, 0);
+ LOG.info("Sleep session starts at {} and ends at {}", sessionStart.getTime(), sessionEnd.getTime());
+ // Build sample object to persist
+ final ColmiSleepSessionSample sessionSample = new ColmiSleepSessionSample();
+ sessionSample.setTimestamp(sessionStart.getTimeInMillis());
+ sessionSample.setWakeupTime(sessionEnd.getTimeInMillis());
+ // Handle sleep stages
+ final List<ColmiSleepStageSample> stageSamples = new ArrayList<>();
+ Calendar sleepStage = (Calendar) sessionStart.clone();
+ for (int j = 4; j < dayBytes; j += 2) {
+ int sleepMinutes = value[index + 1];
+ final ColmiSleepStageSample sample = new ColmiSleepStageSample();
+ sample.setTimestamp(sleepStage.getTimeInMillis());
+ sample.setDuration(value[index + 1]);
+ sample.setStage(value[index]);
+ if (sleepMinutes > 0) {
+ LOG.info("Sleep stage type={} starts at {} and lasts for {} minutes", value[index], sleepStage.getTime(), sleepMinutes);
+ if (sleepStage.getTimeInMillis() + sleepMinutes * 60 * 1000 > sessionEnd.getTimeInMillis()) {
+ LOG.warn("Warning: sleep stage exceeds end of sleep session, received data may be corrupt");
+ }
+ stageSamples.add(sample);
+ sleepStage.add(Calendar.MINUTE, sleepMinutes);
+ } else {
+ LOG.info("Ignoring sleep stage type={} starts at {} and lasts for {} minutes", value[index], sleepStage.getTime(), sleepMinutes);
+ }
+ // Prepare for next sample
+ index += 2;
+ }
+ // Persist sleep session
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(gbDevice, session);
+ final User user = DBHelper.getUser(session);
+
+ final ColmiSleepSessionSampleProvider sampleProvider = new ColmiSleepSessionSampleProvider(gbDevice, session);
+
+ sessionSample.setDevice(device);
+ sessionSample.setUser(user);
+
+ LOG.debug("Will persist 1 sleep session sample from {} to {}", sessionSample.getTimestamp(), sessionSample.getWakeupTime());
+ sampleProvider.addSample(sessionSample);
+ } catch (final Exception e) {
+ GB.toast(context, "Error saving sleep session sample", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ // Persist sleep stages
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(gbDevice, session);
+ final User user = DBHelper.getUser(session);
+
+ final ColmiSleepStageSampleProvider sampleProvider = new ColmiSleepStageSampleProvider(gbDevice, session);
+
+ for (final ColmiSleepStageSample sample : stageSamples) {
+ sample.setDevice(device);
+ sample.setUser(user);
+ }
+
+ LOG.debug("Will persist {} sleep stage samples", stageSamples.size());
+ sampleProvider.addSamples(stageSamples);
+ } catch (final Exception e) {
+ GB.toast(context, "Error saving sleep stage samples", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+ }
+ }
+
+ public static void historicalHRV(GBDevice device, Context context, byte[] value, int daysAgo) {
+ LOG.info("Received HRV history sync packet: {}", StringUtils.bytesToHex(value));
+ int hrvPacketNr = value[1] & 0xff;
+ if (hrvPacketNr == 0xff) {
+ LOG.info("Empty HRV history, sync aborted");
+ device.unsetBusyTask();
+ device.sendDeviceUpdateIntent(context);
+ } else if (hrvPacketNr == 0) {
+ int packetsTotalNr = value[2];
+ LOG.info("HRV history packet {} out of total {}", hrvPacketNr, packetsTotalNr);
+ } else {
+ LOG.info("HRV history packet {}", hrvPacketNr);
+ Calendar sampleCal = Calendar.getInstance();
+ if (daysAgo != 0) {
+ sampleCal.add(Calendar.DAY_OF_MONTH, 0 - daysAgo);
+ sampleCal.set(Calendar.HOUR_OF_DAY, 0);
+ sampleCal.set(Calendar.MINUTE, 0);
+ }
+ sampleCal.set(Calendar.SECOND, 0);
+ sampleCal.set(Calendar.MILLISECOND, 0);
+ int startValue = hrvPacketNr == 1 ? 3 : 2; // packet 1 contains something in byte 2
+ int minutesInPreviousPackets = 0;
+ if (hrvPacketNr > 1) {
+ minutesInPreviousPackets = 12 * 30; // packet 1
+ minutesInPreviousPackets += (hrvPacketNr - 2) * 13 * 30;
+ }
+ for (int i = startValue; i < value.length - 1; i++) {
+ if (value[i] != 0x00) {
+ // Determine time of day
+ int minuteOfDay = minutesInPreviousPackets + (i - startValue) * 30;
+ sampleCal.set(Calendar.HOUR_OF_DAY, minuteOfDay / 60);
+ sampleCal.set(Calendar.MINUTE, minuteOfDay % 60);
+ LOG.info("Value {} is {} ms, time of day is {}", i, value[i] & 0xff, sampleCal.getTime());
+ // Build sample object and save in database
+ try (DBHandler db = GBApplication.acquireDB()) {
+ ColmiHrvValueSampleProvider sampleProvider = new ColmiHrvValueSampleProvider(device, db.getDaoSession());
+ Long userId = DBHelper.getUser(db.getDaoSession()).getId();
+ Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId();
+ ColmiHrvValueSample gbSample = new ColmiHrvValueSample();
+ gbSample.setDeviceId(deviceId);
+ gbSample.setUserId(userId);
+ gbSample.setTimestamp(sampleCal.getTimeInMillis());
+ gbSample.setValue(value[i] & 0xff);
+ sampleProvider.addSample(gbSample);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database for recording HRV samples", e);
+ }
+ }
+ }
+ if (hrvPacketNr == 4) {
+ device.unsetBusyTask();
+ device.sendDeviceUpdateIntent(context);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR10Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR10Coordinator.java
new file mode 100644
index 0000000..4f6972d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR10Coordinator.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class ColmiR10Coordinator extends AbstractColmiR0xCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiR10Coordinator.class);
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^COLMI R10_.*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_colmi_r10;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiActivitySampleProvider.java
new file mode 100644
index 0000000..a027fa1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiActivitySampleProvider.java
@@ -0,0 +1,191 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR0xConstants;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+
+public class ColmiActivitySampleProvider extends AbstractSampleProvider<ColmiActivitySample> {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiActivitySampleProvider.class);
+
+ public ColmiActivitySampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<ColmiActivitySample, ?> getSampleDao() {
+ return getSession().getColmiActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return null;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return Math.min(rawIntensity / 7000f, 1f);
+ }
+
+ @Override
+ public ColmiActivitySample createActivitySample() {
+ return new ColmiActivitySample();
+ }
+
+ @Override
+ protected List<ColmiActivitySample> getGBActivitySamples(final int timestamp_from, final int timestamp_to) {
+ LOG.trace(
+ "Getting Colmi activity samples between {} and {}",
+ timestamp_from,
+ timestamp_to
+ );
+ final long nanoStart = System.nanoTime();
+
+ final List<ColmiActivitySample> samples = fillGaps(
+ super.getGBActivitySamples(timestamp_from, timestamp_to),
+ timestamp_from,
+ timestamp_to
+ );
+
+ final Map<Integer, ColmiActivitySample> sampleByTs = new HashMap<>();
+ for (final ColmiActivitySample sample : samples) {
+ sampleByTs.put(sample.getTimestamp(), sample);
+ }
+
+ overlayHeartRate(sampleByTs, timestamp_from, timestamp_to);
+ overlaySleep(sampleByTs, timestamp_from, timestamp_to);
+
+ final List<ColmiActivitySample> finalSamples = new ArrayList<>(sampleByTs.values());
+ Collections.sort(finalSamples, (a, b) -> Integer.compare(a.getTimestamp(), b.getTimestamp()));
+
+ final long nanoEnd = System.nanoTime();
+ final long executionTime = (nanoEnd - nanoStart) / 1000000;
+ LOG.trace("Getting Colmi samples took {}ms", executionTime);
+
+ return finalSamples;
+ }
+
+ private void overlayHeartRate(final Map<Integer, ColmiActivitySample> sampleByTs, final int timestamp_from, final int timestamp_to) {
+ final ColmiHeartRateSampleProvider heartRateSampleProvider = new ColmiHeartRateSampleProvider(getDevice(), getSession());
+ final List<ColmiHeartRateSample> hrSamples = heartRateSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L);
+
+ for (final ColmiHeartRateSample hrSample : hrSamples) {
+ // round to the nearest minute, we don't need per-second granularity
+ final int tsSeconds = (int) ((hrSample.getTimestamp() / 1000) / 60) * 60;
+ ColmiActivitySample sample = sampleByTs.get(tsSeconds);
+ if (sample == null) {
+ sample = new ColmiActivitySample();
+ sample.setTimestamp(tsSeconds);
+ sample.setProvider(this);
+ sampleByTs.put(tsSeconds, sample);
+ }
+
+ sample.setHeartRate(hrSample.getHeartRate());
+ }
+ }
+
+ private void overlaySleep(final Map<Integer, ColmiActivitySample> sampleByTs, final int timestamp_from, final int timestamp_to) {
+ final ColmiSleepStageSampleProvider sleepStageSampleProvider = new ColmiSleepStageSampleProvider(getDevice(), getSession());
+ final List<ColmiSleepStageSample> sleepStageSamples = sleepStageSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L);
+
+ // Retrieve the last stage before this time range, as the user could have been asleep during
+ // the range transition
+ final ColmiSleepStageSample lastSleepStageBeforeRange = sleepStageSampleProvider.getLastSampleBefore(timestamp_from * 1000L);
+ if (lastSleepStageBeforeRange != null && (lastSleepStageBeforeRange.getTimestamp() + lastSleepStageBeforeRange.getDuration() * 1000L > timestamp_from)) {
+ LOG.debug("Last sleep stage before range: ts={}, stage={}", lastSleepStageBeforeRange.getTimestamp(), lastSleepStageBeforeRange.getStage());
+ sleepStageSamples.add(0, lastSleepStageBeforeRange);
+ }
+
+ for (final ColmiSleepStageSample sleepStageSample : sleepStageSamples) {
+ final ActivityKind sleepRawKind = sleepStageToActivityKind(sleepStageSample.getStage());
+ // round to the nearest minute, we don't need per-second granularity
+ final int tsSeconds = (int) ((sleepStageSample.getTimestamp() / 1000) / 60) * 60;
+ for (int i = tsSeconds; i < tsSeconds + sleepStageSample.getDuration() * 60; i += 60) {
+ if (i < timestamp_from) continue;
+ ColmiActivitySample sample = sampleByTs.get(i);
+ if (sample == null) {
+ sample = new ColmiActivitySample();
+ sample.setTimestamp(i);
+ sample.setProvider(this);
+ sampleByTs.put(i, sample);
+ }
+ sample.setRawKind(sleepRawKind.getCode());
+ sample.setRawIntensity(ActivitySample.NOT_MEASURED);
+ }
+ }
+ }
+
+ final ActivityKind sleepStageToActivityKind(final int sleepStage) {
+ switch (sleepStage) {
+ case ColmiR0xConstants.SLEEP_TYPE_LIGHT:
+ return ActivityKind.LIGHT_SLEEP;
+ case ColmiR0xConstants.SLEEP_TYPE_DEEP:
+ return ActivityKind.DEEP_SLEEP;
+ case ColmiR0xConstants.SLEEP_TYPE_REM:
+ return ActivityKind.REM_SLEEP;
+ case ColmiR0xConstants.SLEEP_TYPE_AWAKE:
+ return ActivityKind.AWAKE_SLEEP;
+ default:
+ return ActivityKind.UNKNOWN;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHeartRateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHeartRateSampleProvider.java
new file mode 100644
index 0000000..b9b2660
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHeartRateSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiHeartRateSampleProvider extends AbstractTimeSampleProvider<ColmiHeartRateSample> {
+ public ColmiHeartRateSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<ColmiHeartRateSample, ?> getSampleDao() {
+ return getSession().getColmiHeartRateSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiHeartRateSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiHeartRateSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiHeartRateSample createSample() {
+ return new ColmiHeartRateSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHrvSummarySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHrvSummarySampleProvider.java
new file mode 100644
index 0000000..99214f8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHrvSummarySampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHrvSummarySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHrvSummarySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiHrvSummarySampleProvider extends AbstractTimeSampleProvider<ColmiHrvSummarySample> {
+ public ColmiHrvSummarySampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<ColmiHrvSummarySample, ?> getSampleDao() {
+ return getSession().getColmiHrvSummarySampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiHrvSummarySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiHrvSummarySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiHrvSummarySample createSample() {
+ return new ColmiHrvSummarySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHrvValueSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHrvValueSampleProvider.java
new file mode 100644
index 0000000..6bb4e1c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHrvValueSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHrvValueSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiHrvValueSampleProvider extends AbstractTimeSampleProvider<ColmiHrvValueSample> {
+ public ColmiHrvValueSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<ColmiHrvValueSample, ?> getSampleDao() {
+ return getSession().getColmiHrvValueSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiHrvValueSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiHrvValueSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiHrvValueSample createSample() {
+ return new ColmiHrvValueSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepSessionSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepSessionSampleProvider.java
new file mode 100644
index 0000000..2d237cd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepSessionSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepSessionSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepSessionSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiSleepSessionSampleProvider extends AbstractTimeSampleProvider<ColmiSleepSessionSample> {
+ public ColmiSleepSessionSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<ColmiSleepSessionSample, ?> getSampleDao() {
+ return getSession().getColmiSleepSessionSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiSleepSessionSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiSleepSessionSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiSleepSessionSample createSample() {
+ return new ColmiSleepSessionSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepStageSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepStageSampleProvider.java
new file mode 100644
index 0000000..06db0e9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepStageSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepStageSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiSleepStageSampleProvider extends AbstractTimeSampleProvider<ColmiSleepStageSample> {
+ public ColmiSleepStageSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<ColmiSleepStageSample, ?> getSampleDao() {
+ return getSession().getColmiSleepStageSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiSleepStageSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiSleepStageSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiSleepStageSample createSample() {
+ return new ColmiSleepStageSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSpo2SampleProvider.java
new file mode 100644
index 0000000..10e9eb1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSpo2SampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiSpo2SampleProvider extends AbstractTimeSampleProvider<ColmiSpo2Sample> {
+ public ColmiSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<ColmiSpo2Sample, ?> getSampleDao() {
+ return getSession().getColmiSpo2SampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiSpo2SampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiSpo2SampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiSpo2Sample createSample() {
+ return new ColmiSpo2Sample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiStressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiStressSampleProvider.java
new file mode 100644
index 0000000..4b6966f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiStressSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.devices.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiStressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiStressSampleProvider extends AbstractTimeSampleProvider<ColmiStressSample> {
+ public ColmiStressSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<ColmiStressSample, ?> getSampleDao() {
+ return getSession().getColmiStressSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiStressSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiStressSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiStressSample createSample() {
+ return new ColmiStressSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/activity/CyclingLiveDataActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/activity/CyclingLiveDataActivity.java
new file mode 100644
index 0000000..98a4153
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/activity/CyclingLiveDataActivity.java
@@ -0,0 +1,170 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.activity;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class CyclingLiveDataActivity extends AbstractGBActivity {
+ private TextView speedView, tripDistanceView, totalDistanceView;
+ private GBDevice selectedDevice;
+ private float tripStartDistance = 0, tripCurrentDistance = 0;
+ private float toUnitFactor = 1;
+ private int
+ speedStringResource = R.string.km_h,
+ tripStringResource = R.string.label_distance_trip,
+ totalStringResource = R.string.label_distance_total;
+
+ private static final String PREFS_KEY_TRIP_START = "CYCLING_TRIP_START";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if(getIntent() == null) {
+ selectedDevice = getStoredCyclingSensor();
+ }else if(getIntent().getExtras() == null) {
+ selectedDevice = getStoredCyclingSensor();
+ }else if((selectedDevice =
+ getIntent().getExtras().getParcelable("device")) == null) {
+ selectedDevice = getStoredCyclingSensor();
+ }
+
+ if (selectedDevice == null) {
+ GB.toast(getString(R.string.error_no_cycling_sensor_found), Toast.LENGTH_SHORT, GB.ERROR);
+ finish();
+ return;
+ }
+
+ setContentView(R.layout.activity_cycling_live_data);
+
+ speedView = findViewById(R.id.cycling_data_speed);
+ tripDistanceView = findViewById(R.id.cycling_data_trip_distance);
+ totalDistanceView = findViewById(R.id.cycling_data_total_distance);
+
+ tripStartDistance = GBApplication
+ .getDevicePrefs(selectedDevice)
+ .getFloat(PREFS_KEY_TRIP_START, 0);
+
+ tripDistanceView.setOnLongClickListener(new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View view) {
+ if(tripCurrentDistance == 0) {
+ return true;
+ }
+
+ tripStartDistance = tripCurrentDistance;
+
+ GBApplication
+ .getDeviceSpecificSharedPrefs(selectedDevice.getAddress())
+ .edit()
+ .putFloat(PREFS_KEY_TRIP_START, tripStartDistance)
+ .apply();
+
+ tripDistanceView.setText(getString(tripStringResource, 0f));
+
+ return true;
+ }
+ });
+
+ String measurementSystem = GBApplication.getPrefs().getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, "metric");
+
+ if(!measurementSystem.equals("metric")) {
+ toUnitFactor = 0.621371f;
+
+ speedStringResource = R.string.mi_h;
+ tripStringResource = R.string.label_distance_trip_mph;
+ totalStringResource = R.string.label_distance_total_mph;
+ }
+ }
+
+ private GBDevice getStoredCyclingSensor(){
+ List<GBDevice> devices = GBApplication
+ .app()
+ .getDeviceManager()
+ .getDevices();
+
+ for(GBDevice device: devices) {
+ if (device.getState() != GBDevice.State.INITIALIZED) continue;
+ if (device.getType() != DeviceType.CYCLING_SENSOR) continue;
+
+ return device;
+ }
+
+ return null;
+ }
+
+ private BroadcastReceiver cyclingDataReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String deviceAddress = intent.getStringExtra("EXTRA_DEVICE_ADDRESS");
+
+ if(deviceAddress == null) {
+ return;
+ }
+
+ if(!deviceAddress.equals(selectedDevice.getAddress())) {
+ return;
+ }
+
+ CyclingSample sample = (CyclingSample) intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE);
+
+ if(sample == null) {
+ return;
+ }
+
+ Float metersPerSecond = sample.getSpeed();
+ if(metersPerSecond != null) {
+ float kmh = metersPerSecond * 3.6f * toUnitFactor;
+ speedView.setText(String.format("%.1f %s", kmh, getString(speedStringResource)));
+ }else{
+ speedView.setText(String.format("%.1f %s", 0f, getString(speedStringResource)));
+ }
+
+ tripCurrentDistance = sample.getDistance();
+
+ tripDistanceView.setText(getString(tripStringResource, ((tripCurrentDistance - tripStartDistance) * toUnitFactor) / 1000));
+ totalDistanceView.setText(getString(totalStringResource, (tripCurrentDistance * toUnitFactor) / 1000));
+ }
+ };
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ LocalBroadcastManager
+ .getInstance(this)
+ .registerReceiver(
+ cyclingDataReceiver,
+ new IntentFilter(DeviceService.ACTION_REALTIME_SAMPLES)
+ );
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+
+ LocalBroadcastManager
+ .getInstance(this)
+ .unregisterReceiver(cyclingDataReceiver);
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/coordinator/CyclingSensorCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/coordinator/CyclingSensorCoordinator.java
new file mode 100644
index 0000000..ed74e0b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/coordinator/CyclingSensorCoordinator.java
@@ -0,0 +1,121 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.coordinator;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.activity.CyclingLiveDataActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.db.CyclingSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.cycling_sensor.support.CyclingSensorSupport;
+
+public class CyclingSensorCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ final Long deviceId = device.getId();
+
+ session.getCyclingSampleDao().queryBuilder()
+ .where(CyclingSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ return candidate.supportsService(CyclingSensorSupport.UUID_CYCLING_SENSOR_SERVICE);
+ }
+
+ @Override
+ public boolean supportsCyclingData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public TimeSampleProvider<CyclingSample> getCyclingSampleProvider(GBDevice device, DaoSession session) {
+ return new CyclingSampleProvider(device, session);
+ }
+
+ @Override
+ public boolean supportsSleepMeasurement() {
+ return false;
+ }
+ @Override
+ public boolean supportsStepCounter() {
+ return false;
+ }
+ @Override
+ public boolean supportsSpeedzones() {
+ return false;
+ }
+ @Override
+ public boolean supportsActivityTabs() {
+ return false;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Generic";
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return CyclingLiveDataActivity.class;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_cycling_sensor
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return CyclingSensorSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_cycling_sensor;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(GBDevice device) {
+ return true;
+ }
+
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/db/CyclingSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/db/CyclingSampleProvider.java
new file mode 100644
index 0000000..6e521b6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cycling_sensor/db/CyclingSampleProvider.java
@@ -0,0 +1,41 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.db;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CyclingSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CyclingSampleProvider extends AbstractTimeSampleProvider<CyclingSample> {
+ public CyclingSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<CyclingSample, ?> getSampleDao() {
+ return getSession().getCyclingSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CyclingSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CyclingSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CyclingSample createSample() {
+ return new CyclingSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/divoom/PixooCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/divoom/PixooCoordinator.java
new file mode 100644
index 0000000..9407254
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/divoom/PixooCoordinator.java
@@ -0,0 +1,109 @@
+/* Copyright (C) 2023-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.divoom;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+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.divoom.PixooSupport;
+
+public class PixooCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Pixoo(-.+)?");
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Divoom";
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return PixooSupport.class;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ PixooInstallHandler installHandler = new PixooInstallHandler(uri, context);
+ return installHandler.isValid() ? installHandler : null;
+ }
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_pixoo;
+ }
+
+ @Override
+ public boolean supportsFlashing() {
+ // To install bitmaps
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 10;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_lovetoy;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_lovetoy_disabled;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_header_display,
+ R.xml.devicesettings_screen_brightness,
+ R.xml.devicesettings_header_time,
+ R.xml.devicesettings_timeformat,
+ R.xml.devicesettings_header_notifications,
+ R.xml.devicesettings_send_app_notifications,
+ R.xml.devicesettings_header_other,
+ R.xml.devicesettings_pixoo,
+ R.xml.devicesettings_header_connection,
+ R.xml.devicesettings_device_name,
+ };
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/divoom/PixooInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/divoom/PixooInstallHandler.java
new file mode 100644
index 0000000..beee43e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/divoom/PixooInstallHandler.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2023-2024 Andreas Shimokawa
+
+ 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.devices.divoom;
+
+import android.content.Context;
+import android.net.Uri;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class PixooInstallHandler implements InstallHandler {
+ private final Context mContext;
+ private final Uri mUri;
+
+ public PixooInstallHandler(Uri uri, Context context) {
+ mContext = context;
+ mUri = uri;
+ }
+
+ @Override
+ public boolean isValid() {
+ return true;
+ }
+
+ @Override
+ public void validateInstallation(InstallActivity installActivity, GBDevice device) {
+ installActivity.setInstallEnabled(true);
+ }
+
+ @Override
+ public void onStartInstall(GBDevice device) {
+
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/domyos/DomyosT540Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/domyos/DomyosT540Coordinator.java
new file mode 100644
index 0000000..8fd1c53
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/domyos/DomyosT540Coordinator.java
@@ -0,0 +1,155 @@
+/* Copyright (C) 2023-2024 Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.domyos;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.EnumSet;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.domyos.DomyosT540Support;
+
+public class DomyosT540Coordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Domyos-TC-9610.*");
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Domyos";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return DomyosT540Support.class;
+ }
+
+ @Override
+ public EnumSet<ServiceDeviceSupport.Flags> getInitialFlags() {
+ return EnumSet.of(ServiceDeviceSupport.Flags.THROTTLING, ServiceDeviceSupport.Flags.BUSY_CHECKING);
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ // nothing to delete, yet
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_domyos_t540;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_lovetoy;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_lovetoy_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/femometer/FemometerVinca2DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/femometer/FemometerVinca2DeviceCoordinator.java
new file mode 100644
index 0000000..3a51cc9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/femometer/FemometerVinca2DeviceCoordinator.java
@@ -0,0 +1,140 @@
+/* Copyright (C) 2023-2024 Alicia Hormann
+
+ 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.devices.femometer;
+
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.regex.Pattern;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.FemometerVinca2TemperatureSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.FemometerVinca2TemperatureSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.femometer.FemometerVinca2DeviceSupport;
+
+public class FemometerVinca2DeviceCoordinator extends AbstractDeviceCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Joytech Healthcare
+ return "Femometer";
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return FemometerVinca2DeviceSupport.class;
+ }
+
+ @Override
+ public TimeSampleProvider<FemometerVinca2TemperatureSample> getTemperatureSampleProvider(GBDevice device, DaoSession session) {
+ return new FemometerVinca2SampleProvider(device, session);
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("BM-Vinca2");
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_femometer_vinca2;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_thermometer;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_thermometer_disabled;
+ }
+
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getFemometerVinca2TemperatureSampleDao().queryBuilder();
+ qb.where(FemometerVinca2TemperatureSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_NONE;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public int getAlarmSlotCount(final GBDevice device) {
+ return 1;
+ }
+
+ @Override
+ public boolean supportsTemperatureMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSleepMeasurement() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsStepCounter() {
+ return false;
+ }
+ @Override
+ public boolean supportsSpeedzones() {
+ return false;
+ }
+ @Override
+ public boolean supportsActivityTabs() {
+ return false;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_volume,
+ R.xml.devicesettings_femometer,
+ R.xml.devicesettings_temperature_scale_cf,
+ };
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/femometer/FemometerVinca2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/femometer/FemometerVinca2SampleProvider.java
new file mode 100644
index 0000000..40a2ba8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/femometer/FemometerVinca2SampleProvider.java
@@ -0,0 +1,55 @@
+/* Copyright (C) 2023-2024 Alicia Hormann
+
+ 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.devices.femometer;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.FemometerVinca2TemperatureSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.FemometerVinca2TemperatureSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class FemometerVinca2SampleProvider extends AbstractTimeSampleProvider<FemometerVinca2TemperatureSample> {
+
+ public FemometerVinca2SampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ @NonNull
+ public AbstractDao<FemometerVinca2TemperatureSample, ?> getSampleDao() {
+ return getSession().getFemometerVinca2TemperatureSampleDao();
+ }
+
+ @NonNull
+ protected Property getTimestampSampleProperty() {
+ return FemometerVinca2TemperatureSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ protected Property getDeviceIdentifierSampleProperty() {
+ return FemometerVinca2TemperatureSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public FemometerVinca2TemperatureSample createSample() {
+ return new FemometerVinca2TemperatureSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProConstants.java
new file mode 100644
index 0000000..5f07131
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProConstants.java
@@ -0,0 +1,250 @@
+/* Copyright (C) 2021-2024 Petr Van臎k
+
+ 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.devices.fitpro;
+
+import java.util.UUID;
+
+public class FitProConstants {
+
+ // cd 00 2a 05 01 0c 00 0c 0000012d000000bb000018
+ // | | | | | | | | |-- payload ->
+ // | | | | | | | |----- payload length low
+ // | | | | | | |-------- payload length high
+ // | | | | | |----------- command
+ // | | | | |-------------- delimiter/version
+ // | | | |----------------- command group
+ // | | |-------------------- full length low
+ // | |----------------------- full length high
+ // |-------------------------- header
+
+ public static final byte DATA_HEADER = (byte) 0xCD;
+ public static final byte DATA_HEADER_ACK = (byte) 0xDC;
+
+ public static final byte[] DATA_TEMPLATE = {
+ (byte) DATA_HEADER, // header
+ (byte) 0x00, // data len hi
+ (byte) 0, // data len low
+ (byte) 0, // command 1
+ (byte) 0x1, // delimiter?
+ (byte) 0, // command 2
+ (byte) 0x0, // data len hi
+ (byte) 0, // data len low
+ // data payload
+ };
+
+ public static final UUID UUID_CHARACTERISTIC_UART = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9d");
+ public static final UUID UUID_CHARACTERISTIC_TX = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9d");
+ public static final UUID UUID_CHARACTERISTIC_RX = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9d");
+ //public static final UUID LT716_OTA = UUID.fromString("00010203-0405-0607-0809-0a0b0c0d1912"); //when in OTA mode
+
+ public static final byte CMD_GROUP_GENERAL = (byte) 0x12;
+ public static final byte CMD_GROUP_BAND_INFO = (byte) 0x20;
+ public static final byte CMD_GROUP_RECEIVE_BUTTON_DATA = 0x1c;
+ public static final byte CMD_GROUP_RECEIVE_SPORTS_DATA = 0x15;
+ public static final byte CMD_GROUP_HEARTRATE_SETTINGS = 0x16;
+ public static final byte CMD_GROUP_REQUEST_DATA = 0x1a;
+ public static final byte CMD_GROUP_BIND = 0x14;
+ public static final byte CMD_GROUP_RESET = 0x1d;
+
+ //group general 0x12
+ public static final byte CMD_FIND_BAND = (byte) 0x0b;
+ public static final byte CMD_SET_DATE_TIME = (byte) 0x1;
+ public static final byte CMD_SET_LANGUAGE = (byte) 0x15;
+ public static final byte CMD_NOTIFICATION_MESSAGE = (byte) 0x12;
+ public static final byte CMD_NOTIFICATION_CALL = (byte) 0x11;
+ public static final byte CMD_WEATHER = (byte) 0x20;
+ public static final byte CMD_CAMERA = (byte) 0xc;
+ public static final byte CMD_HEART_RATE_MEASUREMENT = 0x18; //on/off
+ public static final byte CMD_DND = (byte) 0x14;
+ public static final byte CMD_INIT1 = 0xa;
+ public static final byte CMD_INIT2 = 0xc;
+ public static final byte CMD_INIT3 = (byte) 0xff;
+
+ public static final byte CMD_SET_SLEEP_TIMES = (byte) 0xF;
+ public static final byte CMD_ALARM = (byte) 0x2;
+ public static final byte CMD_SET_ARM = (byte) 0x6;
+ public static final byte CMD_GET_HR = (byte) 0xd; //0/1
+ public static final byte CMD_GET_PRESS = (byte) 0xe; //0/1
+
+ public static final byte CMD_NOTIFICATIONS_ENABLE = 0x7;
+ public static final byte[] VALUE_SET_NOTIFICATIONS_ENABLE_ON = new byte[]{0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1};
+ public static final byte[] VALUE_SET_NOTIFICATIONS_ENABLE_OFF = new byte[]{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0};
+
+ public static final byte CMD_SET_LONG_SIT_REMINDER = (byte) 0x5;
+ public static final byte[] VALUE_SET_LONG_SIT_REMINDER_ON = new byte[]{0x0, 0x1, 0x0, (byte) 0x96};
+ public static final byte[] VALUE_SET_LONG_SIT_REMINDER_OFF = new byte[]{0x0, 0x0, 0x0, (byte) 0x96, 0x4, 0x8, 0x16, 0x7f};
+
+ public static final byte CMD_SET_DISPLAY_ON_LIFT = (byte) 0x9;
+ public static final byte CMD_SET_STEP_GOAL = (byte) 0x3;
+ public static final byte CMD_SET_USER_DATA = (byte) 0x4;
+ public static final byte CMD_SET_DEVICE_VIBRATIONS = (byte) 0x8;
+
+ //group receive sports data 0x15
+ public static final byte CMD_REQUEST_DAY_STEPS_SUMMARY = 0x6;
+ //request steps/hr, as per
+ // https://github.com/vanous/MFitX/blob/AntiGoogle/app/src/main/java/anonymouls/dev/mgcex/app/backend/LM517CommandInterpreter.kt#L242
+ public static final byte CMD_REQUEST_FETCH_DAY_STEPS_DATA = 0xd;
+ public static final byte CMD_REQUEST_STEPS_DATA1 = 0x6;
+ public static final byte CMD_REQUEST_STEPS_DATA0x7 = 0x7;
+ public static final byte CMD_REQUEST_STEPS_DATA0x8 = 0x8;
+ public static final byte CMD_REQUEST_STEPS_DATA0x10 = 0x10;
+
+ //also sleep data as per mfitx
+ //https://github.com/vanous/MFitX/blob/AntiGoogle/app/src/main/java/anonymouls/dev/mgcex/app/backend/LM517CommandInterpreter.kt#L235
+
+ public static final byte RX_HEART_RATE_DATA = 0x0e;
+ public static final byte RX_SPORTS_MEASUREMENT = 0x18; //0/1
+ public static final byte SPORTS_RECEIVE_KEY = 0x1; //0/1
+
+ public static final byte RX_SLEEP_DATA = 0x3;
+ public static final byte RX_STEP_DATA = 0x2;
+ // https://github.com/vanous/MFitX/blob/AntiGoogle/app/src/main/java/anonymouls/dev/mgcex/app/backend/LM517CommandInterpreter.kt#L162
+
+ public static final byte RX_SPORTS_DAY_DATA = 0xc; //0/1
+
+ //group get band info 0x20
+ public static final byte CMD_RX_BAND_INFO = (byte) 0x2;
+
+ //group request data 0x1a
+ public static final byte CMD_GET_STEPS_TARGET = 0x2;
+ public static final byte CMD_GET_HW_INFO = 0x10;
+ public static final byte CMD_GET_AUTO_HR = 0x8;
+ public static final byte CMD_GET_CONTACTS = 0xd;
+
+ //group 0x14
+ public static final byte CMD_UNBIND = (byte) 0x0;
+
+ //group 0x1d
+ public static final byte CMD_RESET = (byte) 0x1;
+
+ // group receive data
+ public static final byte RX_FIND_PHONE = (byte) 0x01;
+ public static final byte RX_CAMERA1 = (byte) 0x02;
+ public static final byte RX_CAMERA2 = (byte) 0x03;
+ public static final byte RX_CAMERA3 = (byte) 0x04;
+ public static final byte RX_MEDIA_PLAY_PAUSE = (byte) 0x0b;
+ public static final byte RX_MEDIA_FORW = (byte) 0x0c;
+ public static final byte RX_MEDIA_BACK = (byte) 0x0a;
+
+
+ //values
+ public static final byte VALUE_ON = (byte) 0x1;
+ public static final byte VALUE_OFF = (byte) 0x0;
+
+ public static final byte UNIT_METRIC = (byte) 0x1;
+ public static final byte UNIT_IMPERIAL = (byte) 0x2;
+
+ public static final byte GENDER_MALE = (byte) 0x1;
+ public static final byte GENDER_FEMALE = (byte) 0x0;
+
+ public static final byte VALUE_SET_ARM_LEFT = (byte) 0x0; //guessing
+ public static final byte VALUE_SET_ARM_RIGHT = (byte) 0x1;
+
+
+ public static final byte[] VALUE_SET_DEVICE_VIBRATIONS_ENABLE = new byte[]{0x1, 0x1, 0x1, 0x1};
+ public static final byte[] VALUE_SET_DEVICE_VIBRATIONS_DISABLE = new byte[]{0, 0, 0, 0};
+
+ public static final byte NOTIFICATION_ICON_FACEBOOK = (byte) 0x4;
+ public static final byte NOTIFICATION_ICON_TWITTER = (byte) 0x5;
+ public static final byte NOTIFICATION_ICON_WHATSAPP = (byte) 0x8;
+ public static final byte NOTIFICATION_ICON_LINE = (byte) 0x7;
+ public static final byte NOTIFICATION_ICON_SMS = (byte) 0x1;
+ public static final byte NOTIFICATION_ICON_WECHAT = (byte) 0x3;
+ public static final byte NOTIFICATION_ICON_QQ = (byte) 0x2;
+ public static final byte NOTIFICATION_ICON_INSTAGRAM = (byte) 0x10;
+
+ public static final int LANG_CHINESE = 0x0;
+ public static final int LANG_PORTUGUESE = 0xc;
+ public static final int LANG_GERMAN = 0x5;
+ public static final int LANG_SPANISH = 0x6;
+ public static final int LANG_FRENCH = 0x7;
+ public static final int LANG_NETHERLANDS = 0xa;
+ public static final int LANG_POLISH = 0xb;
+ public static final int LANG_RUSSIAN = 0xd;
+ public static final int LANG_TURKISH = 0x10;
+ public static final int LANG_ENGLISH = 0x1;
+ public static final int LANG_CZECH = 0x4;
+ public static final int LANG_ITALIAN = 0x12;
+
+ //00 01 00 96 03 08 16 7f
+ //^^ zeros ^^ on/off ^^ sep ^^unknown ^^minutes ^^from ^^ to ^^ unknown
+ //minutes are array of minutes by 15, in 45,60, 75...鈫� 3,4,5...
+ //the byte 4 鈫� 96 could be experimented with to set different values..., byte 3 is probably Hi, byte 4 Low
+ //public static final byte[] CMD_SET_LONG_SIT_REMINDER = new byte[]{(byte) 0x12, (byte) 0x5};
+ //maybe could be usign the ON/OFF?
+ //public static final byte[] VALUE_SET_LONG_SIT_REMINDER_ON = new byte[]{0x0, 0x1, 0x0, (byte) 0x96};
+ //public static final byte[] VALUE_SET_LONG_SIT_REMINDER_OFF = new byte[]{0x0, 0x0, 0x0, (byte) 0x96, 0x4, 0x8, 0x16, 0x7f};
+
+ //Value: cd 00 0a 12 01 09 00 05 01 01 e0 05 28 ON
+ //Value: cd 00 0a 12 01 09 00 05 00 01 e0 05 28 OFF
+ // 00 1 2 3 4 5 6 7 8 9 10 11 12
+ //byte 8 on/off
+ // 9,10,11,12 鈫� time from/to
+ //public static final byte[] CMD_SET_DISPLAY_ON_LIFT = new byte[]{(byte) 0x12, (byte) 0x9};
+ //maybe could be usign the ON/OFF?
+ //public static final byte VALUE_SET_DISPLAY_ON_LIFT_ON = 0x1;
+ //public static final byte[] VALUE_SET_DISPLAY_ON_LIFT_OFF = new byte[]{0x0, 0x0, 0x0, 0x0, 0x0};
+
+ //time
+ //0xCD 0x00 0x09 0x12 0x01 0x01 0x00 0x04 0xA5 0x83 0x73 0xDB
+ //find watch
+ //new byte[]{(byte) 0xcd, (byte) 0x00, (byte) 0x06,
+ // (byte) 0x12, (byte) 0x01, (byte) 0x0b, (byte) 0x00, (byte) 0x01, (byte) 0x01};
+
+ /*init procedure:
+ get pair cd 00 06 12 01 0a 00 01 02 鈫� 18, 10
+ cd 00 09 12 01 01 00 04 55 f8 36 90
+ cd 00 05 1a 01 0a 00 00
+ cd 00 05 1a 01 0c 00 00
+ cd 00 06 12 01 15 00 01 01
+ cd 00 06 12 01 ff 00 01 01
+ cd 00 05 1a 01 01 00 00
+ dc 00 05 1a 01 00 0c 01
+ cd 00 05 1a 01 0f 00 00
+ cd 00 05 1a 01 10 00 00
+ dc 00 05 1a 01 00 1c 01
+ something cd 00 05 20 01 02 00 00 鈫� 32, 2
+ real time step cd 00 06 15 01 06 00 01 01 鈫� 21, 6
+ */
+
+ //received heartrate
+ //0xcd 0x00 0x11 0x15 0x01 0x0e 0x00 0x0c 0x2a 0xf8 0x00 0x01 0x00 0x00 0x31 0x2c 0x62 0x75 0x54 0x47
+ //0xcd 0x00 0x11 0x15 0x01 0x0e 0x00 0x0c 0x2b 0x04 0x00 0x01 0x00 0x01 0x43 0x94 0x62 0x5c 0x48 0x53
+ //cd001115010e000c2b04000100014394625c4853
+
+ //find band
+ // cd 00 06 12 01 0b 00 01 01
+
+ //set celsius/fahrenheit?
+ //cd0006120121000100
+ //cd0006120121000101
+
+ //find phone
+ //0xcd 0x00 0x05 0x1c 0x01 0x01 0x00 0x00
+ //camera:
+ //0xcd 0x00 0x05 0x1c 0x01 0x03 0x00 0x00
+ //0xcd 0x00 0x05 0x1c 0x01 0x02 0x00 0x00
+ //0xcd 0x00 0x05 0x1c 0x01 0x04 0x00 0x00
+
+ // << 0xCD 0x00 0x05 0x1C 0x01 0x0A 0x00 0x00
+ //pause 0xCD 0x00 0x05 0x1C 0x01 0x0B 0x00 0x00
+ // >> 0xCD 0x00 0x05 0x1C 0x01 0x0C 0x00 0x00
+
+ //0xCD 0x00 0x05 0x1C 0x01 0x03 0x00 0x00
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProDeviceCoordinator.java
new file mode 100644
index 0000000..2598faf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProDeviceCoordinator.java
@@ -0,0 +1,180 @@
+/* Copyright (C) 2021-2024 a b, Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.fitpro;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.EnumSet;
+import java.util.regex.Pattern;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.FitProActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.fitpro.FitProDeviceSupport;
+
+public class FitProDeviceCoordinator extends AbstractBLEDeviceCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(FitProDeviceCoordinator.class);
+
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getFitProActivitySampleDao().queryBuilder();
+ qb.where(FitProActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("M6.*|M4.*|LH716|Sunset 6|Watch7|Fit1900");
+ }
+
+ @Override
+ public int getBondingStyle() {
+ // different devices seem to work differently.
+ // user will unfortunately need to decide
+ return BONDING_STYLE_ASK;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new FitProSampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 8;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "FitPro";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_liftwrist_display_no_on,
+ R.xml.devicesettings_inactivity_extended,
+ R.xml.devicesettings_donotdisturb_no_auto,
+ R.xml.devicesettings_sleep_time,
+ R.xml.devicesettings_wearlocation,
+ //R.xml.devicesettings_autoheartrate, //removed, this is most likely not supported by these bands
+ R.xml.devicesettings_vibrations_enable,
+ R.xml.devicesettings_notifications_enable,
+ R.xml.devicesettings_fitpro,
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return FitProDeviceSupport.class;
+ }
+
+ @Override
+ public EnumSet<ServiceDeviceSupport.Flags> getInitialFlags() {
+ return EnumSet.of(ServiceDeviceSupport.Flags.THROTTLING, ServiceDeviceSupport.Flags.BUSY_CHECKING);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_fitpro;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProSampleProvider.java
new file mode 100644
index 0000000..37303a9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProSampleProvider.java
@@ -0,0 +1,99 @@
+/* Copyright (C) 2021-2024 Petr Van臎k
+
+ 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.devices.fitpro;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.FitProActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.FitProActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class FitProSampleProvider extends AbstractSampleProvider<FitProActivitySample> {
+ public FitProSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<FitProActivitySample, ?> getSampleDao() {
+ return getSession().getFitProActivitySampleDao();
+ }
+
+
+ // as per FitProDeviceSupport.rawActivityKindToUniqueKind
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ switch (rawType) {
+ case 1:
+ return ActivityKind.ACTIVITY;
+ case 11:
+ return ActivityKind.DEEP_SLEEP;
+ case 12:
+ return ActivityKind.LIGHT_SLEEP;
+ default:
+ return ActivityKind.UNKNOWN;
+ }
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ switch (activityKind) {
+ case DEEP_SLEEP:
+ return 11;
+ case LIGHT_SLEEP:
+ return 12;
+ case ACTIVITY:
+ default:
+ return 1;
+ }
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / 2000f; //samples are per 5 minutes, so this should be sufficient
+ }
+
+ @Override
+ public FitProActivitySample createActivitySample() {
+ return new FitProActivitySample();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return FitProActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return FitProActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return FitProActivitySampleDao.Properties.DeviceId;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/colacao/ColaCao21Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/colacao/ColaCao21Coordinator.java
new file mode 100644
index 0000000..e630ca4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/colacao/ColaCao21Coordinator.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+
+package nodomain.freeyourgadget.gadgetbridge.devices.fitpro.colacao;
+
+import androidx.annotation.DrawableRes;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.fitpro.FitProDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColaCao21Coordinator extends FitProDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("ColaCao21");
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ // HR requests crash the device
+ return false;
+ }
+
+ @Override
+ @DrawableRes
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ @DrawableRes
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_colacao21;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/colacao/ColaCao23Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/colacao/ColaCao23Coordinator.java
new file mode 100644
index 0000000..ec502a6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/colacao/ColaCao23Coordinator.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+
+package nodomain.freeyourgadget.gadgetbridge.devices.fitpro.colacao;
+
+import androidx.annotation.DrawableRes;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.fitpro.FitProDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColaCao23Coordinator extends FitProDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("ColaCao23");
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ // HR requests crash the device
+ return false;
+ }
+
+ @Override
+ @DrawableRes
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ @DrawableRes
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_colacao23;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/flipper/zero/FlipperZeroCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/flipper/zero/FlipperZeroCoordinator.java
new file mode 100644
index 0000000..f8727ab
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/flipper/zero/FlipperZeroCoordinator.java
@@ -0,0 +1,150 @@
+/* Copyright (C) 2022-2024 Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.flipper.zero;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.flipper.zero.support.FlipperZeroSupport;
+
+public class FlipperZeroCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @NonNull
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ if(candidate.supportsService(UUID.fromString("00003082-0000-1000-8000-00805f9b34fb"))){
+ return true; // need to filter for flipper here
+ }
+ return false;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Flipper Devices";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return FlipperZeroSupport.class;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_flipper_zero;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_flipper;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_flipper_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBuds2DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBuds2DeviceCoordinator.java
new file mode 100644
index 0000000..c510b1c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBuds2DeviceCoordinator.java
@@ -0,0 +1,76 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, narektor
+
+ 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.devices.galaxy_buds;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+
+public class GalaxyBuds2DeviceCoordinator extends GalaxyBudsGenericCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ // Some devices are just called "Buds2", others "Galaxy Buds2 (..."
+ return Pattern.compile("(Galaxy )?Buds2( \\(.*)?");
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new GalaxyBudsSettingsCustomizer(device);
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_buds_pro_case, R.string.battery_case);
+ BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_buds_pro_left, R.string.left_earbud);
+ BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_buds_pro_right, R.string.right_earbud);
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_galaxy_buds_2,
+ };
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_galaxybuds_2;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds_pro;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_pro_disabled;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBuds2ProDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBuds2ProDeviceCoordinator.java
new file mode 100644
index 0000000..cd7410b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBuds2ProDeviceCoordinator.java
@@ -0,0 +1,77 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, narektor
+
+ 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.devices.galaxy_buds;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class GalaxyBuds2ProDeviceCoordinator extends GalaxyBudsGenericCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^(Galaxy )?Buds2 Pro.*");
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new GalaxyBudsSettingsCustomizer(device);
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_buds_pro_case, R.string.battery_case);
+ BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_buds_pro_left, R.string.left_earbud);
+ BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_buds_pro_right, R.string.right_earbud);
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_galaxy_buds_2_pro,
+ };
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_galaxybuds_2_pro;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds_pro;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_pro_disabled;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsDeviceCoordinator.java
new file mode 100644
index 0000000..a031bd2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsDeviceCoordinator.java
@@ -0,0 +1,58 @@
+/* Copyright (C) 2021-2024 Daniel Dakhno, Petr Van臎k
+
+ 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.devices.galaxy_buds;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class GalaxyBudsDeviceCoordinator extends GalaxyBudsGenericCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Galaxy Buds \\(.*");
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 2;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_galaxy_buds_l, R.string.left_earbud);
+ BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_galaxy_buds_r, R.string.right_earbud);
+ return new BatteryConfig[]{battery1, battery2};
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_galaxybuds;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_galaxy_buds,
+ };
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsGenericCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsGenericCoordinator.java
new file mode 100644
index 0000000..f57d481
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsGenericCoordinator.java
@@ -0,0 +1,144 @@
+/* Copyright (C) 2022-2024 Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.galaxy_buds;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.galaxy_buds.GalaxyBudsDeviceSupport;
+
+public abstract class GalaxyBudsGenericCoordinator extends AbstractBLClassicDeviceCoordinator {
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_BOND;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice
+ device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Samsung";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device
+ device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return GalaxyBudsDeviceSupport.class;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsLiveDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsLiveDeviceCoordinator.java
new file mode 100644
index 0000000..f5f1c11
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsLiveDeviceCoordinator.java
@@ -0,0 +1,72 @@
+/* Copyright (C) 2021-2024 Daniel Dakhno, Petr Van臎k
+
+ 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.devices.galaxy_buds;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+
+public class GalaxyBudsLiveDeviceCoordinator extends GalaxyBudsGenericCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Galaxy Buds Live \\(.*");
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_tws_case, R.string.battery_case);
+ BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_galaxy_buds_live_l, R.string.left_earbud);
+ BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_galaxy_buds_live_r, R.string.right_earbud);
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+ final List<Integer> audio = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.AUDIO);
+ audio.add(R.xml.devicesettings_galaxy_buds_live);
+ final List<Integer> callsAndNotif = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
+ callsAndNotif.add(R.xml.devicesettings_headphones);
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_galaxybuds_live;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds_live;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_live_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsProDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsProDeviceCoordinator.java
new file mode 100644
index 0000000..72bea04
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsProDeviceCoordinator.java
@@ -0,0 +1,77 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.galaxy_buds;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class GalaxyBudsProDeviceCoordinator extends GalaxyBudsGenericCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Galaxy Buds Pro \\(.*");
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new GalaxyBudsSettingsCustomizer(device);
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_buds_pro_case, R.string.battery_case);
+ BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_buds_pro_left, R.string.left_earbud);
+ BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_buds_pro_right, R.string.right_earbud);
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_galaxy_buds_pro,
+ };
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_galaxybuds_pro;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds_pro;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_pro_disabled;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsSettingsCustomizer.java
new file mode 100644
index 0000000..82d90c7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/galaxy_buds/GalaxyBudsSettingsCustomizer.java
@@ -0,0 +1,282 @@
+/* Copyright (C) 2022-2024 narektor, Petr Van臎k
+
+ 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.devices.galaxy_buds;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GALAXY_BUDS_2_NOISE_CONTROL;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GALAXY_BUDS_AMBIENT_VOLUME;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GALAXY_BUDS_PRO_ANC_LEVEL;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GALAXY_BUDS_PRO_BALANCE;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GALAXY_BUDS_PRO_NOISE_CONTROL;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GALAXY_BUDS_TOUCH_LEFT;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GALAXY_BUDS_TOUCH_LEFT_SWITCH;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GALAXY_BUDS_TOUCH_RIGHT;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GALAXY_BUDS_TOUCH_RIGHT_SWITCH;
+
+import android.os.Parcel;
+
+import androidx.preference.Preference;
+
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class GalaxyBudsSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+
+ final GBDevice device;
+
+ public GalaxyBudsSettingsCustomizer(final GBDevice device) {
+ this.device = device;
+ }
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, Prefs prefs, final String rootKey) {
+
+ final Preference pref_galaxy_buds_pro_balance = handler.findPreference(PREF_GALAXY_BUDS_PRO_BALANCE);
+ if (pref_galaxy_buds_pro_balance != null) {
+ pref_galaxy_buds_pro_balance.setSummary(String.valueOf((prefs.getInt(PREF_GALAXY_BUDS_PRO_BALANCE, 16) - 16)));
+
+ pref_galaxy_buds_pro_balance.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ String newValue = String.valueOf((int) newVal - 16);
+ pref_galaxy_buds_pro_balance.setSummary(newValue);
+ handler.notifyPreferenceChanged(PREF_GALAXY_BUDS_PRO_BALANCE);
+ return true;
+ }
+ });
+ }
+
+ final Preference pref_galaxy_buds_pro_noise_control = handler.findPreference(PREF_GALAXY_BUDS_PRO_NOISE_CONTROL);
+ String pref_galaxy_buds_pro_noise_control_value = prefs.getString(PREF_GALAXY_BUDS_PRO_NOISE_CONTROL, "0");
+ final Preference pref_galaxy_buds_2_noise_control = handler.findPreference(PREF_GALAXY_BUDS_2_NOISE_CONTROL);
+ String pref_galaxy_buds_2_noise_control_value = prefs.getString(PREF_GALAXY_BUDS_2_NOISE_CONTROL, "0");
+ final Preference pref_galaxy_buds_pro_anc_level = handler.findPreference(PREF_GALAXY_BUDS_PRO_ANC_LEVEL);
+ final Preference pref_galaxy_buds_ambient_volume = handler.findPreference(PREF_GALAXY_BUDS_AMBIENT_VOLUME);
+
+ if (pref_galaxy_buds_pro_noise_control != null) {
+
+ if (pref_galaxy_buds_pro_anc_level != null && pref_galaxy_buds_ambient_volume != null) {
+ switch (pref_galaxy_buds_pro_noise_control_value) {
+ case "0":
+ pref_galaxy_buds_pro_anc_level.setEnabled(false);
+ pref_galaxy_buds_ambient_volume.setEnabled(false);
+ break;
+ case "1":
+ pref_galaxy_buds_pro_anc_level.setEnabled(true);
+ pref_galaxy_buds_ambient_volume.setEnabled(false);
+ break;
+ case "2":
+ pref_galaxy_buds_pro_anc_level.setEnabled(false);
+ pref_galaxy_buds_ambient_volume.setEnabled(true);
+ break;
+ }
+ }
+
+ pref_galaxy_buds_pro_noise_control.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ handler.notifyPreferenceChanged(PREF_GALAXY_BUDS_PRO_NOISE_CONTROL);
+ switch (newVal.toString()) {
+ case "0":
+ if (pref_galaxy_buds_pro_anc_level != null)
+ pref_galaxy_buds_pro_anc_level.setEnabled(false);
+ if (pref_galaxy_buds_ambient_volume != null)
+ pref_galaxy_buds_ambient_volume.setEnabled(false);
+ break;
+ case "1":
+ if (pref_galaxy_buds_pro_anc_level != null)
+ pref_galaxy_buds_pro_anc_level.setEnabled(true);
+ if (pref_galaxy_buds_ambient_volume != null)
+ pref_galaxy_buds_ambient_volume.setEnabled(false);
+ break;
+ case "2":
+ if (pref_galaxy_buds_pro_anc_level != null)
+ pref_galaxy_buds_pro_anc_level.setEnabled(false);
+ if (pref_galaxy_buds_ambient_volume != null)
+ pref_galaxy_buds_ambient_volume.setEnabled(true);
+ break;
+ }
+
+ return true;
+ }
+ });
+ }
+
+ if (pref_galaxy_buds_2_noise_control != null) {
+
+ switch (pref_galaxy_buds_2_noise_control_value) {
+ case "0":
+ case "1":
+ pref_galaxy_buds_ambient_volume.setEnabled(false);
+ break;
+ case "2":
+ pref_galaxy_buds_ambient_volume.setEnabled(true);
+ break;
+ }
+
+ pref_galaxy_buds_2_noise_control.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ handler.notifyPreferenceChanged(PREF_GALAXY_BUDS_2_NOISE_CONTROL);
+ switch (newVal.toString()) {
+ case "0":
+ case "1":
+ pref_galaxy_buds_ambient_volume.setEnabled(false);
+ break;
+ case "2":
+ pref_galaxy_buds_ambient_volume.setEnabled(true);
+ break;
+ }
+
+ return true;
+ }
+ });
+ }
+
+ final Preference pref_galaxy_buds_touch_right = handler.findPreference(PREF_GALAXY_BUDS_TOUCH_RIGHT);
+ String pref_galaxy_buds_touch_right_value = prefs.getString(PREF_GALAXY_BUDS_TOUCH_RIGHT, "1");
+ final Preference pref_galaxy_buds_touch_right_switch = handler.findPreference(PREF_GALAXY_BUDS_TOUCH_RIGHT_SWITCH);
+
+ if (pref_galaxy_buds_touch_right != null) {
+
+ switch (pref_galaxy_buds_touch_right_value) {
+ case "2":
+ pref_galaxy_buds_touch_right_switch.setEnabled(true);
+ break;
+ default:
+ pref_galaxy_buds_touch_right_switch.setEnabled(false);
+ }
+
+ pref_galaxy_buds_touch_right.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ handler.notifyPreferenceChanged(PREF_GALAXY_BUDS_TOUCH_RIGHT);
+ switch (newVal.toString()) {
+ case "2":
+ pref_galaxy_buds_touch_right_switch.setEnabled(true);
+ break;
+ default:
+ pref_galaxy_buds_touch_right_switch.setEnabled(false);
+
+ }
+
+ return true;
+ }
+ });
+ }
+
+ final Preference pref_galaxy_buds_touch_left = handler.findPreference(PREF_GALAXY_BUDS_TOUCH_LEFT);
+ String pref_galaxy_buds_touch_left_value = prefs.getString(PREF_GALAXY_BUDS_TOUCH_LEFT, "1");
+ final Preference pref_galaxy_buds_touch_left_switch = handler.findPreference(PREF_GALAXY_BUDS_TOUCH_LEFT_SWITCH);
+
+ if (pref_galaxy_buds_touch_left != null) {
+
+ switch (pref_galaxy_buds_touch_left_value) {
+ case "2":
+ pref_galaxy_buds_touch_left_switch.setEnabled(true);
+ break;
+ default:
+ pref_galaxy_buds_touch_left_switch.setEnabled(false);
+ }
+
+ pref_galaxy_buds_touch_left.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ handler.notifyPreferenceChanged(PREF_GALAXY_BUDS_TOUCH_LEFT);
+ switch (newVal.toString()) {
+ case "2":
+ pref_galaxy_buds_touch_left_switch.setEnabled(true);
+ break;
+ default:
+ pref_galaxy_buds_touch_left_switch.setEnabled(false);
+
+ }
+
+ return true;
+ }
+ });
+ }
+
+
+/*
+ final Preference pref_galaxy_buds_ambient_mode = handler.findPreference(PREF_GALAXY_BUDS_AMBIENT_SOUND);
+ boolean is_pref_galaxy_buds_ambient_mode_enabled = prefs.getBoolean(PREF_GALAXY_BUDS_AMBIENT_SOUND, false);
+ final Preference pref_galaxy_buds_ambient_voice_focus_preference = handler.findPreference(PREF_GALAXY_BUDS_AMBIENT_VOICE_FOCUS_PREFERENCE);
+
+ if (pref_galaxy_buds_ambient_mode != null) {
+ if (is_pref_galaxy_buds_ambient_mode_enabled) {
+ pref_galaxy_buds_ambient_voice_focus_preference.setEnabled(true);
+ } else {
+ pref_galaxy_buds_ambient_voice_focus_preference.setEnabled(false);
+ }
+
+
+ pref_galaxy_buds_ambient_mode.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ handler.notifyPreferenceChanged(PREF_GALAXY_BUDS_AMBIENT_SOUND);
+ if ((boolean) newVal) {
+ pref_galaxy_buds_ambient_voice_focus_preference.setEnabled(true);
+ } else {
+ pref_galaxy_buds_ambient_voice_focus_preference.setEnabled(false);
+ }
+
+ return true;
+ }
+ });
+ }
+
+ */
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+
+ public static final Creator<GalaxyBudsSettingsCustomizer> CREATOR = new Creator<GalaxyBudsSettingsCustomizer>() {
+ @Override
+ public GalaxyBudsSettingsCustomizer createFromParcel(final Parcel in) {
+ final GBDevice device = in.readParcelable(GalaxyBudsSettingsCustomizer.class.getClassLoader());
+ return new GalaxyBudsSettingsCustomizer(device);
+ }
+
+ @Override
+ public GalaxyBudsSettingsCustomizer[] newArray(final int size) {
+ return new GalaxyBudsSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeParcelable(device, 0);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java
new file mode 100644
index 0000000..606680c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java
@@ -0,0 +1,217 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionSleepStage;
+import nodomain.freeyourgadget.gadgetbridge.util.RangeMap;
+
+public class GarminActivitySampleProvider extends AbstractSampleProvider<GarminActivitySample> {
+ private static final Logger LOG = LoggerFactory.getLogger(GarminActivitySampleProvider.class);
+
+ public GarminActivitySampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<GarminActivitySample, ?> getSampleDao() {
+ return getSession().getGarminActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return GarminActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(final int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(final ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(final int rawIntensity) {
+ return rawIntensity / 100f;
+ }
+
+ @Override
+ public GarminActivitySample createActivitySample() {
+ return new GarminActivitySample();
+ }
+
+ @Override
+ protected List<GarminActivitySample> getGBActivitySamples(final int timestamp_from, final int timestamp_to) {
+ LOG.trace(
+ "Getting garmin activity samples between {} and {}",
+ timestamp_from,
+ timestamp_to
+ );
+
+ final long nanoStart = System.nanoTime();
+
+ final List<GarminActivitySample> samples = fillGaps(
+ super.getGBActivitySamples(timestamp_from, timestamp_to),
+ timestamp_from,
+ timestamp_to
+ );
+
+ if (!samples.isEmpty()) {
+ convertCumulativeSteps(samples, GarminActivitySampleDao.Properties.Steps);
+ }
+
+ convertCalories(samples);
+ overlaySleep(samples, timestamp_from, timestamp_to);
+
+ final long nanoEnd = System.nanoTime();
+
+ final long executionTime = (nanoEnd - nanoStart) / 1000000;
+
+ LOG.trace("Getting Garmin samples took {}ms", executionTime);
+
+ return samples;
+ }
+
+ /**
+ * Converts the calories from kcal to cal
+ */
+ private void convertCalories(List<GarminActivitySample> samples) {
+ for (GarminActivitySample sample : samples) {
+ sample.setActiveCalories(sample.getActiveCalories() * 1000);
+ }
+ }
+
+ public void overlaySleep(final List<GarminActivitySample> samples, final int timestamp_from, final int timestamp_to) {
+ // The samples provided by Garmin are upper-bound timestamps of the sleep stage
+ final RangeMap<Long, ActivityKind> stagesMap = new RangeMap<>(RangeMap.Mode.UPPER_BOUND);
+
+ final GarminEventSampleProvider eventSampleProvider = new GarminEventSampleProvider(getDevice(), getSession());
+ final List<GarminEventSample> sleepEventSamples = eventSampleProvider.getSleepEvents(
+ timestamp_from * 1000L,
+ timestamp_to * 1000L
+ );
+ if (!sleepEventSamples.isEmpty()) {
+ LOG.debug("Found {} sleep event samples between {} and {}", sleepEventSamples.size(), timestamp_from, timestamp_to);
+ for (final GarminEventSample event : sleepEventSamples) {
+ switch (event.getEventType()) {
+ case 0: // start
+ // We only need the start event as an upper-bound timestamp (anything before it is unknown)
+ stagesMap.put(event.getTimestamp(), ActivityKind.UNKNOWN);
+ break;
+ case 1: // stop
+ // See FitImporter#processRawSleepSamples / #4048
+ if (event.getData() != null && event.getData() == -1) {
+ stagesMap.put(event.getTimestamp(), ActivityKind.LIGHT_SLEEP);
+ }
+ default:
+ }
+ }
+ }
+
+ final GarminSleepStageSampleProvider sleepStagesSampleProvider = new GarminSleepStageSampleProvider(getDevice(), getSession());
+
+ // Retrieve the next stage after this time range
+ final GarminEventSample nextSleepStageAfterRange = eventSampleProvider.getNextSleepEventAfter(timestamp_to * 1000L);
+ if (nextSleepStageAfterRange != null && nextSleepStageAfterRange.getEventType() == 1) {
+ // Sleep session actually ends outside of this range, we need to fetch the next sleep stage
+ final GarminSleepStageSample nextStage = sleepStagesSampleProvider.getNextSampleAfter(timestamp_to * 1000L);
+ if (nextStage != null) {
+ stagesMap.put(nextStage.getTimestamp(), toActivityKind(nextStage));
+ }
+ }
+
+ final List<GarminSleepStageSample> stageSamples = sleepStagesSampleProvider.getAllSamples(
+ timestamp_from * 1000L,
+ timestamp_to * 1000L
+ );
+
+ if (!stageSamples.isEmpty()) {
+ // We got actual sleep stages
+ LOG.debug("Found {} sleep stage samples between {} and {}", stageSamples.size(), timestamp_from, timestamp_to);
+
+ for (final GarminSleepStageSample stageSample : stageSamples) {
+ stagesMap.put(stageSample.getTimestamp(), toActivityKind(stageSample));
+ }
+ }
+
+ if (!stagesMap.isEmpty()) {
+ for (final GarminActivitySample sample : samples) {
+ final long ts = sample.getTimestamp() * 1000L;
+ final ActivityKind sleepType = stagesMap.get(ts);
+ if (sleepType != null && !sleepType.equals(ActivityKind.UNKNOWN)) {
+ sample.setRawKind(sleepType.getCode());
+ sample.setRawIntensity(ActivitySample.NOT_MEASURED);
+ }
+ }
+ }
+ }
+
+ private ActivityKind toActivityKind(final GarminSleepStageSample stageSample) {
+ final FieldDefinitionSleepStage.SleepStage sleepStage = FieldDefinitionSleepStage.SleepStage.fromId(stageSample.getStage());
+ if (sleepStage == null) {
+ LOG.error("Unknown sleep stage for {}", stageSample.getStage());
+ return ActivityKind.UNKNOWN;
+ }
+
+ switch (sleepStage) {
+ case AWAKE:
+ return ActivityKind.AWAKE_SLEEP;
+ case LIGHT:
+ return ActivityKind.LIGHT_SLEEP;
+ case DEEP:
+ return ActivityKind.DEEP_SLEEP;
+ case REM:
+ return ActivityKind.REM_SLEEP;
+ }
+
+ return ActivityKind.UNKNOWN;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminBodyEnergySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminBodyEnergySampleProvider.java
new file mode 100644
index 0000000..1fe89f0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminBodyEnergySampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminBodyEnergySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminBodyEnergySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminBodyEnergySampleProvider extends AbstractTimeSampleProvider<GarminBodyEnergySample> {
+ public GarminBodyEnergySampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminBodyEnergySample, ?> getSampleDao() {
+ return getSession().getGarminBodyEnergySampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminBodyEnergySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminBodyEnergySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminBodyEnergySample createSample() {
+ return new GarminBodyEnergySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java
new file mode 100644
index 0000000..572f1d5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java
@@ -0,0 +1,414 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+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.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.WorkoutVo2MaxSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.Vo2MaxSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminBodyEnergySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvSummarySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvValueSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.PendingFileDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.BodyEnergySample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
+import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.SleepScoreSample;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+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
+ public boolean suggestUnbindBeforePair() {
+ return false;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull final GBDevice gbDevice, @NonNull final Device device, @NonNull final DaoSession session) throws GBException {
+ deleteAllActivityData(device, session);
+ }
+
+ public void deleteAllActivityData(@NonNull final Device device, @NonNull final DaoSession session) throws GBException {
+ final Long deviceId = device.getId();
+
+ final Map<AbstractDao<?, ?>, Property> daoMap = new HashMap<AbstractDao<?, ?>, Property>() {{
+ put(session.getGarminActivitySampleDao(), GarminActivitySampleDao.Properties.DeviceId);
+ put(session.getGarminStressSampleDao(), GarminStressSampleDao.Properties.DeviceId);
+ put(session.getGarminBodyEnergySampleDao(), GarminBodyEnergySampleDao.Properties.DeviceId);
+ put(session.getGarminSpo2SampleDao(), GarminSpo2SampleDao.Properties.DeviceId);
+ put(session.getGarminSleepStageSampleDao(), GarminSleepStageSampleDao.Properties.DeviceId);
+ put(session.getGarminEventSampleDao(), GarminEventSampleDao.Properties.DeviceId);
+ put(session.getGarminHrvSummarySampleDao(), GarminHrvSummarySampleDao.Properties.DeviceId);
+ put(session.getGarminHrvValueSampleDao(), GarminHrvValueSampleDao.Properties.DeviceId);
+ put(session.getBaseActivitySummaryDao(), BaseActivitySummaryDao.Properties.DeviceId);
+ put(session.getPendingFileDao(), PendingFileDao.Properties.DeviceId);
+ }};
+
+ for (final Map.Entry<AbstractDao<?, ?>, Property> e : daoMap.entrySet()) {
+ e.getKey().queryBuilder()
+ .where(e.getValue().eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Garmin";
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_zetime;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_zetime_disabled;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return GarminSupport.class;
+ }
+
+ @Nullable
+ @Override
+ public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
+ return new GarminWorkoutParser(context);
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(final GBDevice device, DaoSession session) {
+ return new GarminActivitySampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends StressSample> getStressSampleProvider(final GBDevice device, final DaoSession session) {
+ return new GarminStressSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends BodyEnergySample> getBodyEnergySampleProvider(final GBDevice device, final DaoSession session) {
+ return new GarminBodyEnergySampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HrvSummarySample> getHrvSummarySampleProvider(final GBDevice device, final DaoSession session) {
+ return new GarminHrvSummarySampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HrvValueSample> getHrvValueSampleProvider(final GBDevice device, final DaoSession session) {
+ return new GarminHrvValueSampleProvider(device, session);
+ }
+
+ @Override
+ public Vo2MaxSampleProvider<? extends Vo2MaxSample> getVo2MaxSampleProvider(final GBDevice device, final DaoSession session) {
+ return new WorkoutVo2MaxSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ return new GarminSpo2SampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends PaiSample> getPaiSampleProvider(final GBDevice device, final DaoSession session) {
+ return new GarminPaiSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends RespiratoryRateSample> getRespiratoryRateSampleProvider(final GBDevice device, final DaoSession session) {
+ return new GarminRespiratoryRateSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends RestingMetabolicRateSample> getRestingMetabolicRateProvider(final GBDevice device, final DaoSession session) {
+ return new GarminRestingMetabolicRateSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends SleepScoreSample> getSleepScoreProvider(final GBDevice device, final DaoSession session) {
+ return new GarminSleepStatsSampleProvider(device, session);
+ }
+
+ @Override
+ public GarminHeartRateRestingSampleProvider getHeartRateRestingSampleProvider(final GBDevice device, final DaoSession session) {
+ return new GarminHeartRateRestingSampleProvider(device, session);
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ if (supports(device, GarminCapability.REALTIME_SETTINGS)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_garmin_realtime_settings);
+ }
+
+ 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_canned_reply_16);
+ notifications.add(R.xml.devicesettings_canned_dismisscall_16);
+ }
+ if (getContactsSlotCount(device) > 0) {
+ notifications.add(R.xml.devicesettings_contacts);
+ }
+
+ final List<Integer> location = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.LOCATION);
+ location.add(R.xml.devicesettings_workout_send_gps_to_band);
+ if (supportsAgpsUpdates(device)) {
+ location.add(R.xml.devicesettings_garmin_agps);
+ }
+
+ 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);
+ developer.add(R.xml.devicesettings_fetch_unknown_files);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(GBDevice device) {
+ return new GarminSettingsCustomizer();
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsBodyEnergy() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHrvMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsVO2Max() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsVO2MaxCycling() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsVO2MaxRunning() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActiveCalories() {
+ return true;
+ }
+
+ @Override
+ public int[] getStressRanges() {
+ // 1-25 = relaxed
+ // 26-50 = low
+ // 51-80 = moderate
+ // 76-100 = high
+ return new int[]{1, 26, 51, 76};
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateRestingMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsRemSleep() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsAwakeSleep() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSleepScore() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsRespiratoryRate() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsDayRespiratoryRate() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsPai() {
+ // Intensity Minutes
+ return true;
+ }
+
+ @Override
+ public int getPaiName() {
+ return R.string.garmin_intensity_minutes;
+ }
+
+ @Override
+ public boolean supportsPaiTime() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsPaiLow() {
+ return false;
+ }
+
+ @Override
+ public int getPaiTarget() {
+ return 150;
+ }
+
+ @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;
+ }
+
+ @Override
+ public int getContactsSlotCount(final GBDevice device) {
+ if (getPrefs(device).getBoolean(GarminPreferences.PREF_FEAT_CONTACTS, false)) {
+ return 50;
+ }
+
+ return 0;
+ }
+
+ protected static Prefs getPrefs(final GBDevice device) {
+ return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ public boolean supportsAgpsUpdates(final GBDevice device) {
+ return !getPrefs(device).getString(GarminPreferences.PREF_AGPS_KNOWN_URLS, "").isEmpty();
+ }
+
+ public boolean supports(final GBDevice device, final GarminCapability capability) {
+ return getPrefs(device).getStringSet(GarminPreferences.PREF_GARMIN_CAPABILITIES, Collections.emptySet())
+ .contains(capability.name());
+ }
+
+ @Override
+ public boolean supportsFlashing() {
+ return true;
+ }
+
+ @Nullable
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ final GarminFitFileInstallHandler fitFileInstallHandler = new GarminFitFileInstallHandler(uri, context);
+ if (fitFileInstallHandler.isValid())
+ return fitFileInstallHandler;
+
+ final GarminGpxRouteInstallHandler garminGpxRouteInstallHandler = new GarminGpxRouteInstallHandler(uri, context);
+ if (garminGpxRouteInstallHandler.isValid())
+ return garminGpxRouteInstallHandler;
+
+ return null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminEventSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminEventSampleProvider.java
new file mode 100644
index 0000000..1bdf211
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminEventSampleProvider.java
@@ -0,0 +1,101 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collections;
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminEventSampleProvider extends AbstractTimeSampleProvider<GarminEventSample> {
+ public GarminEventSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminEventSample, ?> getSampleDao() {
+ return getSession().getGarminEventSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminEventSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminEventSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminEventSample createSample() {
+ return new GarminEventSample();
+ }
+
+ public List<GarminEventSample> getSleepEvents(final long timestampFrom, final long timestampTo) {
+ final QueryBuilder<GarminEventSample> qb = getSampleDao().queryBuilder();
+ final Property timestampProperty = getTimestampSampleProperty();
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no samples
+ return Collections.emptyList();
+ }
+ final Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(deviceProperty.eq(dbDevice.getId()), timestampProperty.ge(timestampFrom))
+ .where(timestampProperty.le(timestampTo))
+ .where(GarminEventSampleDao.Properties.Event.eq(74));
+
+ final List<GarminEventSample> samples = qb.build().list();
+ detachFromSession();
+ return samples;
+ }
+
+ public GarminEventSample getNextSleepEventAfter(final long timestampFrom) {
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+
+ final Property deviceIdSampleProp = getDeviceIdentifierSampleProperty();
+ final Property timestampSampleProp = getTimestampSampleProperty();
+ final List<GarminEventSample> samples = getSampleDao().queryBuilder()
+ .where(
+ deviceIdSampleProp.eq(dbDevice.getId()),
+ timestampSampleProp.ge(timestampFrom),
+ GarminEventSampleDao.Properties.Event.eq(74)
+ ).orderAsc(getTimestampSampleProperty())
+ .limit(1)
+ .list();
+
+ return !samples.isEmpty() ? samples.get(0) : null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminFitFileInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminFitFileInstallHandler.java
new file mode 100644
index 0000000..c7e4ba5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminFitFileInstallHandler.java
@@ -0,0 +1,231 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Optional;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitCourse;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileId;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitWorkout;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+
+public class GarminFitFileInstallHandler implements InstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(GarminFitFileInstallHandler.class);
+
+ protected final Context mContext;
+ private byte[] rawBytes;
+ private FitFile fitFile;
+ private FileType.FILETYPE fileType;
+
+ public GarminFitFileInstallHandler(final Uri uri, final Context context) {
+ this.mContext = context;
+
+ final UriHelper uriHelper;
+ try {
+ uriHelper = UriHelper.get(uri, context);
+ } catch (final IOException e) {
+ LOG.error("Failed to get uri", e);
+ return;
+ }
+
+ // Quickly check whether it's a valid fit file without reading the entire thing
+ try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ final byte[] header = new byte[12];
+ final int read = in.read(header);
+ if (read != header.length) {
+ throw new IOException("Not enough bytes for fit header");
+ }
+ if (BLETypeConversions.toUint32(header, 8) != FitFile.Header.MAGIC) {
+ // not fit file
+ return;
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to validate fit file", e);
+ return;
+ }
+
+ try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ rawBytes = FileUtils.readAll(in, 10 * 1024 * 1024); // 10MB
+ fitFile = FitFile.parseIncoming(rawBytes);
+
+ final Optional<FitFileId> fitFileIdOpt = fitFile.getRecords().stream()
+ .filter(r -> r instanceof FitFileId)
+ .map(r -> (FitFileId) r)
+ .findFirst();
+
+ if (!fitFileIdOpt.isPresent()) {
+ LOG.error("Fit file has no ID");
+ return;
+ }
+
+ final FitFileId fitFileId = fitFileIdOpt.get();
+ if (fitFileId.getType() == null) {
+ LOG.error("Fit file ID has null type");
+ return;
+ }
+
+ fileType = fitFileId.getType();
+ } catch (final Exception e) {
+ LOG.error("Failed to read fit file", e);
+ }
+ }
+
+ @Override
+ public boolean isValid() {
+ return fitFile != null && fileType != null;
+ }
+
+ @Override
+ public void validateInstallation(final InstallActivity installActivity, final GBDevice device) {
+ if (fitFile == null || fileType == null) {
+ return;
+ }
+
+ if (device.isBusy()) {
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ if (!(coordinator instanceof GarminCoordinator)) {
+ LOG.warn("Coordinator is not a GarminCoordinator: {}", coordinator.getClass());
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ final GarminCoordinator garminCoordinator = (GarminCoordinator) coordinator;
+ final boolean fileSupported = parseFitFile(installActivity, garminCoordinator, device);
+
+ if (!fileSupported) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!device.isInitialized()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ installActivity.setInstallEnabled(true);
+ }
+
+ @Override
+ public void onStartInstall(final GBDevice device) {
+ }
+
+ public byte[] getRawBytes() {
+ return rawBytes;
+ }
+
+ public FitFile getFitFile() {
+ return fitFile;
+ }
+
+ public FileType.FILETYPE getFileType() {
+ return fileType;
+}
+
+ private boolean parseFitFile(final InstallActivity installActivity, final GarminCoordinator coordinator, final GBDevice device) {
+ final String name;
+ final int kindName;
+
+ switch (fileType) {
+ case COURSES:
+ if (!coordinator.supports(device, GarminCapability.COURSE_DOWNLOAD)) {
+ LOG.warn("Device does not support course download");
+ return false;
+ }
+ final Optional<FitCourse> fitCourseOpt = fitFile.getRecords().stream()
+ .filter(r -> r instanceof FitCourse)
+ .map(r -> (FitCourse) r)
+ .findFirst();
+
+ if (!fitCourseOpt.isPresent()) {
+ LOG.error("Fit file has no course record");
+ return false;
+ }
+ final FitCourse fitCourse = fitCourseOpt.get();
+
+ name = String.valueOf(fitCourse.getName());
+ kindName = R.string.kind_gpx_route;
+
+ break;
+ case WORKOUTS:
+ if (!coordinator.supports(device, GarminCapability.WORKOUT_DOWNLOAD)) {
+ LOG.warn("Device does not support workout download");
+ return false;
+ }
+ final Optional<FitWorkout> fitWorkoutOpt = fitFile.getRecords().stream()
+ .filter(r -> r instanceof FitWorkout)
+ .map(r -> (FitWorkout) r)
+ .findFirst();
+
+ if (!fitWorkoutOpt.isPresent()) {
+ LOG.error("Fit file has no workout record");
+ return false;
+ }
+ final FitWorkout fitWorkout = fitWorkoutOpt.get();
+
+ name = String.valueOf(fitWorkout.getName());
+ kindName = R.string.menuitem_workout;
+
+ break;
+ default:
+ LOG.warn("Unsupported fit file type: {}", fileType);
+ return false;
+ }
+
+ final GenericItem fwItem = new GenericItem(mContext.getString(
+ R.string.installhandler_firmware_name,
+ mContext.getString(coordinator.getDeviceNameResource()),
+ mContext.getString(kindName),
+ name
+ ));
+ fwItem.setIcon(coordinator.getDefaultIconResource());
+
+ final StringBuilder builder = new StringBuilder();
+ final String kindNameString = mContext.getString(kindName);
+ builder.append(mContext.getString(R.string.fw_upgrade_notice, kindNameString));
+ installActivity.setInfoText(builder.toString());
+ installActivity.setInstallItem(fwItem);
+
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminGpxRouteInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminGpxRouteInstallHandler.java
new file mode 100644
index 0000000..5afb29e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminGpxRouteInstallHandler.java
@@ -0,0 +1,139 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.GpxRouteFileConverter;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+
+import static nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability.COURSE_DOWNLOAD;
+
+public class GarminGpxRouteInstallHandler implements InstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(GarminGpxRouteInstallHandler.class);
+
+ protected final Context mContext;
+ public byte[] rawBytes;
+ private GpxRouteFileConverter gpxRouteFileConverter;
+
+ public GarminGpxRouteInstallHandler(final Uri uri, final Context context) {
+ this.mContext = context;
+
+ final UriHelper uriHelper;
+ try {
+ uriHelper = UriHelper.get(uri, context);
+ } catch (final IOException e) {
+ LOG.error("Failed to get uri", e);
+ return;
+ }
+ try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ rawBytes = FileUtils.readAll(in, 1024 * 1024); // 1MB
+
+ final GpxRouteFileConverter gpxRouteFileConverter1 = new GpxRouteFileConverter(rawBytes);
+ if (gpxRouteFileConverter1.isValid()) {
+ this.gpxRouteFileConverter = gpxRouteFileConverter1;
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to read file", e);
+ }
+ }
+
+ @Override
+ public boolean isValid() {
+ return gpxRouteFileConverter != null;
+ }
+
+ @Override
+ public void validateInstallation(final InstallActivity installActivity, final GBDevice device) {
+ if (device.isBusy()) {
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ if (!(coordinator instanceof GarminCoordinator)) {
+ LOG.warn("Coordinator is not a GarminCoordinator: {}", coordinator.getClass());
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ final GarminCoordinator garminCoordinator = (GarminCoordinator) coordinator;
+ if (!garminCoordinator.supports(device, COURSE_DOWNLOAD)) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!device.isInitialized()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final GenericItem fwItem = createInstallItem(device);
+ fwItem.setIcon(coordinator.getDefaultIconResource());
+
+ if (gpxRouteFileConverter == null) {
+ fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_incompatible_version));
+ installActivity.setInfoText(mContext.getString(R.string.fwinstaller_firmware_not_compatible_to_device));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final StringBuilder builder = new StringBuilder();
+ final String gpxRoute = mContext.getString(R.string.kind_gpx_route);
+ builder.append(mContext.getString(R.string.fw_upgrade_notice, gpxRoute));
+ installActivity.setInfoText(builder.toString());
+ installActivity.setInstallItem(fwItem);
+ installActivity.setInstallEnabled(true);
+ }
+
+ @Override
+ public void onStartInstall(final GBDevice device) {
+ }
+
+ public GpxRouteFileConverter getGpxRouteFileConverter() {
+ return gpxRouteFileConverter;
+ }
+
+ private GenericItem createInstallItem(final GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final String firmwareName = mContext.getString(
+ R.string.installhandler_firmware_name,
+ mContext.getString(coordinator.getDeviceNameResource()),
+ mContext.getString(R.string.kind_gpx_route),
+ gpxRouteFileConverter.getName()
+ );
+ return new GenericItem(firmwareName);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHeartRateRestingSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHeartRateRestingSampleProvider.java
new file mode 100644
index 0000000..15a3779
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHeartRateRestingSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminHeartRateRestingSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminHeartRateRestingSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminHeartRateRestingSampleProvider extends AbstractTimeSampleProvider<GarminHeartRateRestingSample> {
+ public GarminHeartRateRestingSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminHeartRateRestingSample, ?> getSampleDao() {
+ return getSession().getGarminHeartRateRestingSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminHeartRateRestingSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminHeartRateRestingSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminHeartRateRestingSample createSample() {
+ return new GarminHeartRateRestingSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHrvSummarySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHrvSummarySampleProvider.java
new file mode 100644
index 0000000..9053747
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHrvSummarySampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvSummarySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvSummarySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminHrvSummarySampleProvider extends AbstractTimeSampleProvider<GarminHrvSummarySample> {
+ public GarminHrvSummarySampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminHrvSummarySample, ?> getSampleDao() {
+ return getSession().getGarminHrvSummarySampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminHrvSummarySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminHrvSummarySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminHrvSummarySample createSample() {
+ return new GarminHrvSummarySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHrvValueSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHrvValueSampleProvider.java
new file mode 100644
index 0000000..de6d88b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminHrvValueSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvValueSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminHrvValueSampleProvider extends AbstractTimeSampleProvider<GarminHrvValueSample> {
+ public GarminHrvValueSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminHrvValueSample, ?> getSampleDao() {
+ return getSession().getGarminHrvValueSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminHrvValueSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminHrvValueSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminHrvValueSample createSample() {
+ return new GarminHrvValueSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminIntensityMinutesSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminIntensityMinutesSampleProvider.java
new file mode 100644
index 0000000..8205ce4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminIntensityMinutesSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminIntensityMinutesSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminIntensityMinutesSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminIntensityMinutesSampleProvider extends AbstractTimeSampleProvider<GarminIntensityMinutesSample> {
+ public GarminIntensityMinutesSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminIntensityMinutesSample, ?> getSampleDao() {
+ return getSession().getGarminIntensityMinutesSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminIntensityMinutesSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminIntensityMinutesSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminIntensityMinutesSample createSample() {
+ return new GarminIntensityMinutesSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPaiSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPaiSampleProvider.java
new file mode 100644
index 0000000..aa7d48b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPaiSampleProvider.java
@@ -0,0 +1,241 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.time.DayOfWeek;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.time.temporal.TemporalAdjusters;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminIntensityMinutesSample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
+
+public class GarminPaiSampleProvider implements TimeSampleProvider<PaiSample> {
+ private final GarminIntensityMinutesSampleProvider intensityMinutesSampleProvider;
+
+ public GarminPaiSampleProvider(final GBDevice device, final DaoSession session) {
+ this.intensityMinutesSampleProvider = new GarminIntensityMinutesSampleProvider(device, session);
+ }
+
+ @NonNull
+ @Override
+ public List<PaiSample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ // Intensity minutes reset every monday, so we need to go to the previous monday if we
+ // are not there yet
+ final ZoneId tz = ZoneId.systemDefault();
+ ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestampFrom), tz);
+ final DayOfWeek dayOfWeek = zonedDateTime.getDayOfWeek();
+ if (dayOfWeek != DayOfWeek.MONDAY) {
+ zonedDateTime = zonedDateTime.with(TemporalAdjusters.previous(DayOfWeek.MONDAY));
+ }
+ zonedDateTime = zonedDateTime.truncatedTo(ChronoUnit.DAYS);
+ final List<GarminIntensityMinutesSample> allSamples = intensityMinutesSampleProvider.getAllSamples(
+ zonedDateTime.toEpochSecond() * 1000L,
+ timestampTo
+ );
+ if (allSamples.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ int totalModerate = 0;
+ int totalVigorous = 0;
+ int currentDay = 0;
+
+ LocalDate lastSampleDate = LocalDate.ofInstant(Instant.ofEpochMilli(allSamples.get(0).getTimestamp()), tz);
+ final List<PaiSample> ret = new ArrayList<>(allSamples.size());
+ for (final GarminIntensityMinutesSample sample : allSamples) {
+ final LocalDate sampleDate = LocalDate.ofInstant(Instant.ofEpochMilli(sample.getTimestamp()), tz);
+
+ // Since we only persist minute samples for days where there was activity, we need to fill any gaps
+ for (LocalDate d = lastSampleDate.plusDays(1); d.isBefore(sampleDate); d = d.plusDays(1)) {
+ if (lastSampleDate.getDayOfWeek() != d.getDayOfWeek()) {
+ currentDay = 0;
+ if (d.getDayOfWeek() == DayOfWeek.MONDAY) {
+ totalModerate = 0;
+ totalVigorous = 0;
+ }
+ }
+
+ ret.add(new GarminPaiSample(
+ d.atStartOfDay(tz).toInstant().toEpochMilli(),
+ totalModerate,
+ totalVigorous,
+ currentDay
+ ));
+
+ lastSampleDate = d;
+ }
+
+ if (sampleDate.getDayOfWeek() != lastSampleDate.getDayOfWeek()) {
+ currentDay = 0;
+ if (sampleDate.getDayOfWeek() == DayOfWeek.MONDAY) {
+ totalModerate = 0;
+ totalVigorous = 0;
+ }
+ }
+
+ totalModerate += sample.getModerate();
+ totalVigorous += sample.getVigorous();
+ currentDay += sample.getModerate() + sample.getVigorous() * 2;
+
+ if (sample.getTimestamp() >= timestampFrom && sample.getTimestamp() <= timestampTo) {
+ ret.add(new GarminPaiSample(
+ sample.getTimestamp(),
+ totalModerate,
+ totalVigorous,
+ currentDay
+ ));
+ }
+
+ lastSampleDate = sampleDate;
+ }
+
+ // Finally, fill out from the last sample to the end of the timestampTo
+ final LocalDate endDate = LocalDate.ofInstant(Instant.ofEpochMilli(timestampTo), tz);
+ for (LocalDate d = lastSampleDate.plusDays(1); !d.isAfter(endDate); d = d.plusDays(1)) {
+ if (lastSampleDate.getDayOfWeek() != d.getDayOfWeek()) {
+ currentDay = 0;
+ if (d.getDayOfWeek() == DayOfWeek.MONDAY) {
+ totalModerate = 0;
+ totalVigorous = 0;
+ }
+ }
+
+ ret.add(new GarminPaiSample(
+ d.atStartOfDay(tz).toInstant().toEpochMilli(),
+ totalModerate,
+ totalVigorous,
+ currentDay
+ ));
+
+ lastSampleDate = d;
+ }
+
+ return ret;
+ }
+
+ @Override
+ public void addSample(final PaiSample timeSample) {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Override
+ public void addSamples(final List<PaiSample> timeSamples) {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Override
+ public PaiSample createSample() {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Nullable
+ @Override
+ public PaiSample getLatestSample() {
+ // TODO
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public PaiSample getLatestSample(long until) {
+ // TODO
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public PaiSample getFirstSample() {
+ // TODO
+ return null;
+ }
+
+
+ public static class GarminPaiSample implements PaiSample {
+ private final long timestamp;
+ private final int minutesModerate;
+ private final int minutesVigorous;
+ private final int today;
+
+ public GarminPaiSample(final long timestamp,
+ final int moderate,
+ final int vigorous,
+ final int today) {
+ this.timestamp = timestamp;
+ this.minutesModerate = moderate;
+ this.minutesVigorous = vigorous;
+ this.today = today;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public float getPaiLow() {
+ return 0;
+ }
+
+ @Override
+ public float getPaiModerate() {
+ return minutesModerate;
+ }
+
+ @Override
+ public float getPaiHigh() {
+ return minutesVigorous * 2;
+ }
+
+ @Override
+ public int getTimeLow() {
+ return 0; // not supported
+ }
+
+ @Override
+ public int getTimeModerate() {
+ return minutesModerate;
+ }
+
+ @Override
+ public int getTimeHigh() {
+ return minutesVigorous;
+ }
+
+ @Override
+ public float getPaiToday() {
+ return today;
+ }
+
+ @Override
+ public float getPaiTotal() {
+ return minutesModerate + 2 * minutesVigorous;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java
new file mode 100644
index 0000000..1e5a885
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java
@@ -0,0 +1,27 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
+
+public class GarminPreferences {
+ public static final String PREF_GARMIN_CAPABILITIES = "garmin_capabilities";
+ public static final String PREF_FEAT_CANNED_MESSAGES = "feat_canned_messages";
+ public static final String PREF_FEAT_CONTACTS = "feat_contacts";
+ public static final String PREF_AGPS_KNOWN_URLS = "garmin_agps_known_urls";
+ public static final String PREF_GARMIN_AGPS_STATUS = "garmin_agps_status_%s";
+ public static final String PREF_GARMIN_AGPS_UPDATE_TIME = "garmin_agps_update_time_%s";
+ public static final String PREF_GARMIN_AGPS_FOLDER = "garmin_agps_folder";
+ public static final String PREF_GARMIN_AGPS_FILENAME = "garmin_agps_filename_%s";
+ public static final String PREF_GARMIN_REALTIME_SETTINGS = "garmin_realtime_settings";
+
+ public static String agpsStatus(final String url) {
+ return String.format(GarminPreferences.PREF_GARMIN_AGPS_STATUS, CheckSums.md5(url));
+ }
+
+ public static String agpsUpdateTime(final String url) {
+ return String.format(GarminPreferences.PREF_GARMIN_AGPS_UPDATE_TIME, CheckSums.md5(url));
+ }
+
+ public static String agpsFilename(final String url) {
+ return String.format(GarminPreferences.PREF_GARMIN_AGPS_FILENAME, CheckSums.md5(url));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsActivity.java
new file mode 100644
index 0000000..eb8c041
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsActivity.java
@@ -0,0 +1,94 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.preference.PreferenceFragmentCompat;
+
+import java.util.Optional;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class GarminRealtimeSettingsActivity extends AbstractSettingsActivityV2 {
+ private GBDevice device;
+ private int screenId;
+
+ public static final String EXTRA_SCREEN_ID = "screenId";
+
+ @Override
+ protected String fragmentTag() {
+ return GarminRealtimeSettingsFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return GarminRealtimeSettingsFragment.newInstance(device, screenId);
+ }
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ screenId = getIntent().getIntExtra(EXTRA_SCREEN_ID, GarminRealtimeSettingsFragment.ROOT_SCREEN_ID);
+
+ super.onCreate(savedInstanceState);
+
+ if (device == null || !device.isInitialized()) {
+ GB.toast(getString(R.string.watch_not_connected), Toast.LENGTH_SHORT, GB.INFO);
+ finish();
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ final MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.menu_garmin_realtime_settings, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
+ final int itemId = item.getItemId();
+
+ if (itemId == R.id.garmin_rt_debug_toggle) {
+ getFragment().ifPresent(GarminRealtimeSettingsFragment::toggleDebug);
+ return true;
+ } else if (itemId == R.id.garmin_rt_debug_share) {
+ getFragment().ifPresent(GarminRealtimeSettingsFragment::shareDebug);
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onActivityResult(final int requestCode, final int resultCode, final @Nullable Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ getFragment().ifPresent(GarminRealtimeSettingsFragment::refreshFromDevice);
+ }
+
+ private Optional<GarminRealtimeSettingsFragment> getFragment() {
+ final FragmentManager fragmentManager = getSupportFragmentManager();
+ final Fragment fragment = fragmentManager.findFragmentByTag(GarminRealtimeSettingsFragment.FRAGMENT_TAG);
+ if (fragment == null) {
+ return Optional.empty();
+ }
+
+ if (fragment instanceof GarminRealtimeSettingsFragment) {
+ return Optional.of((GarminRealtimeSettingsFragment) fragment);
+ }
+
+ return Optional.empty();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java
new file mode 100644
index 0000000..b21d6c2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java
@@ -0,0 +1,897 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import android.content.ActivityNotFoundException;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.widget.ArrayAdapter;
+import android.widget.Toast;
+
+import androidx.annotation.DrawableRes;
+import androidx.fragment.app.FragmentActivity;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.preference.DialogPreference;
+import androidx.preference.EditTextPreference;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceScreen;
+import androidx.preference.SwitchPreferenceCompat;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.protobuf.InvalidProtocolBufferException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSettingsService;
+import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSmartProto;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.XDatePreference;
+import nodomain.freeyourgadget.gadgetbridge.util.XTimePreference;
+import nodomain.freeyourgadget.gadgetbridge.util.preferences.MinMaxTextWatcher;
+
+public class GarminRealtimeSettingsFragment extends AbstractPreferenceFragment {
+ private static final Logger LOG = LoggerFactory.getLogger(GarminRealtimeSettingsFragment.class);
+
+ public static final String EXTRA_SCREEN_ID = "screenId";
+ public static final String PREF_DEBUG = "garmin_rt_debug_mode";
+
+ public static final int ROOT_SCREEN_ID = 36352;
+
+ static final String FRAGMENT_TAG = "GARMIN_REALTIME_SETTINGS_FRAGMENT";
+
+ private GBDevice device;
+ private int screenId = ROOT_SCREEN_ID;
+
+ private GdiSettingsService.ScreenDefinition screenDefinition;
+ private GdiSettingsService.ScreenState screenState;
+
+ public static final String EXTRA_PROTOBUF = "protobuf";
+
+ public static final String ACTION_SCREEN_DEFINITION = "nodomain.freeyourgadget.gadgetbridge.garmin.realtime_settings.screen_definition";
+ public static final String ACTION_SCREEN_STATE = "nodomain.freeyourgadget.gadgetbridge.garmin.realtime_settings.screen_state";
+ public static final String ACTION_CHANGE = "nodomain.freeyourgadget.gadgetbridge.garmin.realtime_settings.change";
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ final String action = intent.getAction();
+ if (action == null) {
+ LOG.error("Got null action");
+ return;
+ }
+
+ switch (action) {
+ case ACTION_SCREEN_DEFINITION:
+ final GdiSettingsService.ScreenDefinition incomingScreen;
+ try {
+ incomingScreen = GdiSettingsService.ScreenDefinition.parseFrom(intent.getByteArrayExtra(EXTRA_PROTOBUF));
+ } catch (final InvalidProtocolBufferException e) {
+ // should never happen
+ LOG.error("Failed to parse protobuf for screen definition on {}", screenId, e);
+ return;
+ }
+ if (incomingScreen.getScreenId() != screenId) {
+ return;
+ }
+ LOG.debug("Got screen definition for screenId={}", screenId);
+ screenDefinition = incomingScreen;
+ break;
+ case ACTION_SCREEN_STATE:
+ final GdiSettingsService.ScreenState incomingState;
+ try {
+ incomingState = GdiSettingsService.ScreenState.parseFrom(intent.getByteArrayExtra(EXTRA_PROTOBUF));
+ } catch (final InvalidProtocolBufferException e) {
+ // should never happen
+ LOG.error("Failed to parse protobuf for screen state on {}", screenId, e);
+ return;
+ }
+ if (incomingState.getScreenId() != screenId) {
+ return;
+ }
+ LOG.debug("Got screen state for screenId={}", screenId);
+ screenState = incomingState;
+ break;
+ case ACTION_CHANGE:
+ final GdiSettingsService.ChangeResponse incomingChange;
+ try {
+ incomingChange = GdiSettingsService.ChangeResponse.parseFrom(intent.getByteArrayExtra(EXTRA_PROTOBUF));
+ } catch (final InvalidProtocolBufferException e) {
+ // should never happen
+ LOG.error("Failed to parse protobuf for change", e);
+ return;
+ }
+ if (incomingChange.getState().getScreenId() != screenId) {
+ return;
+ }
+
+ if (incomingChange.getShouldReturn()) {
+ LOG.debug("Returning from {}", screenId);
+ requireActivity().finish();
+ return;
+ }
+
+ LOG.debug("Got screen change for screenId={}", screenId);
+
+ GBApplication.deviceService(device).onReadConfiguration("screenId:" + screenId);
+ return;
+ default:
+ LOG.error("Unknown action {}", action);
+ return;
+ }
+
+ reload();
+ }
+ };
+
+ private void setDevice(final GBDevice device) {
+ final Bundle args = getArguments() != null ? getArguments() : new Bundle();
+ args.putParcelable("device", device);
+ setArguments(args);
+ }
+
+ private void setScreenId(final int screenId) {
+ final Bundle args = getArguments() != null ? getArguments() : new Bundle();
+ args.putInt("screenId", screenId);
+ setArguments(args);
+ }
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ final Bundle arguments = getArguments();
+ if (arguments == null) {
+ return;
+ }
+ this.device = arguments.getParcelable(GBDevice.EXTRA_DEVICE);
+ if (device == null) {
+ return;
+ }
+ this.screenId = arguments.getInt(EXTRA_SCREEN_ID, ROOT_SCREEN_ID);
+ if (screenId == 0) {
+ return;
+ }
+
+ LOG.info("Opened realtime preferences screen for {}", screenId);
+
+ getPreferenceManager().setSharedPreferencesName("garmin_rt_" + device.getAddress());
+ setPreferencesFromResource(R.xml.garmin_realtime_settings, rootKey);
+
+ final IntentFilter filter = new IntentFilter();
+ filter.addAction(ACTION_SCREEN_DEFINITION);
+ filter.addAction(ACTION_SCREEN_STATE);
+ filter.addAction(ACTION_CHANGE);
+ LocalBroadcastManager.getInstance(requireContext()).registerReceiver(mReceiver, filter);
+
+ GBApplication.deviceService(device).onReadConfiguration("screenId:" + screenId);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ reload();
+ }
+
+ @Override
+ public void onDestroyView() {
+ LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(mReceiver);
+ super.onDestroyView();
+ }
+
+ static GarminRealtimeSettingsFragment newInstance(final GBDevice device, final int screenId) {
+ final GarminRealtimeSettingsFragment fragment = new GarminRealtimeSettingsFragment();
+ fragment.setDevice(device);
+ fragment.setScreenId(screenId);
+
+ return fragment;
+ }
+
+ void refreshFromDevice() {
+ screenDefinition = null;
+ screenState = null;
+ reload();
+ GBApplication.deviceService(device).onReadConfiguration("screenId:" + screenId);
+ }
+
+ void reload() {
+ final boolean debug = GBApplication.getDevicePrefs(device).getBoolean(PREF_DEBUG, BuildConfig.DEBUG);
+
+ final FragmentActivity activity = getActivity();
+ if (activity == null) {
+ LOG.error("Activity is null");
+ return;
+ }
+
+ final PreferenceScreen prefScreen = findPreference(GarminPreferences.PREF_GARMIN_REALTIME_SETTINGS);
+ if (prefScreen == null) {
+ LOG.error("Preference screen for {} is null", GarminPreferences.PREF_GARMIN_REALTIME_SETTINGS);
+ activity.finish();
+ return;
+ }
+
+ if (screenDefinition == null || screenState == null) {
+ ((GarminRealtimeSettingsActivity) activity).setActionBarTitle(activity.getString(R.string.loading));
+
+ // Disable all existing preferences while loading
+ for (int i = 0; i < prefScreen.getPreferenceCount(); i++) {
+ prefScreen.getPreference(i).setEnabled(false);
+ }
+
+ return;
+ }
+
+ prefScreen.removeAll();
+
+ if (debug) {
+ final Preference pref = new PreferenceCategory(activity);
+ pref.setIconSpaceReserved(false);
+ pref.setTitle("Screen ID: " + screenId);
+ pref.setPersistent(false);
+ pref.setKey("rt_pref_header_" + screenId);
+ prefScreen.addPreference(pref);
+ }
+
+ // Update the screen title, if any
+ if (screenDefinition.hasTitle()) {
+ final String title = screenDefinition.getTitle().getText();
+
+ ((GarminRealtimeSettingsActivity) activity).setActionBarTitle(title);
+ }
+
+ final Map<Integer, GdiSettingsService.EntryState> stateById = new HashMap<>();
+ for (final GdiSettingsService.EntryState state : screenState.getStateList()) {
+ stateById.put(state.getId(), state);
+ }
+
+ for (final GdiSettingsService.ScreenEntry entry : screenDefinition.getEntryList()) {
+ final GdiSettingsService.EntryState state = stateById.get(entry.getId());
+
+ final Preference pref;
+ boolean supported = true;
+
+ if (entry.hasTarget()) {
+ switch (entry.getTarget().getType()) {
+ case 0: // subscreen
+ case 9: // subscreen with options for a specific preference
+ pref = new Preference(activity);
+ pref.setOnPreferenceClickListener(preference -> {
+ final Intent newIntent = new Intent(requireContext(), GarminRealtimeSettingsActivity.class);
+ newIntent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ newIntent.putExtra(GarminRealtimeSettingsActivity.EXTRA_SCREEN_ID, entry.getTarget().getSubscreen());
+ activity.startActivityForResult(newIntent, 0);
+ return true;
+ });
+ break;
+ case 1: // list preference
+ pref = new ListPreference(activity);
+ final CharSequence[] entries = new String[entry.getTarget().getOptions().getOptionList().size()];
+ int optionIndex = 0;
+ for (final GdiSettingsService.TargetOptionEntry option : entry.getTarget().getOptions().getOptionList()) {
+ entries[optionIndex++] = option.getTitle().getText();
+ }
+ final ListPreference listPreference = (ListPreference) pref;
+ listPreference.setEntries(entries);
+ listPreference.setEntryValues(entries);
+ listPreference.setValue(entries[Objects.requireNonNull(state).getSummary().getValueList().getIndex()].toString());
+ listPreference.setOnPreferenceChangeListener((preference, newValue) -> {
+ int newValueIdx = -1;
+ for (int i = 0; i < entries.length; i++) {
+ if (entries[i].equals(newValue.toString())) {
+ newValueIdx = i;
+ break;
+ }
+ }
+ if (newValueIdx < 0) {
+ LOG.error("Failed to find index for {}", newValue);
+ return false;
+ }
+
+ pref.setEnabled(false);
+ sendChangeRequest(
+ GdiSettingsService.ChangeRequest.newBuilder()
+ .setScreenId(screenId)
+ .setEntryId(entry.getId())
+ .setOption(GdiSettingsService.ChangeRequest.Option.newBuilder()
+ .setIndex(newValueIdx)
+ )
+ );
+ return true;
+ });
+ break;
+
+ case 3: // time
+ pref = new XTimePreference(activity, null);
+ ((XTimePreference) pref).setValue(
+ Objects.requireNonNull(state).getSummary().getValueTime().getSeconds() / 3600,
+ (Objects.requireNonNull(state).getSummary().getValueTime().getSeconds() % 3600) / 60
+ );
+ if (state.getSummary().getValueTime().hasTimeFormat()) {
+ final int timeFormat = state.getSummary().getValueTime().getTimeFormat();
+ switch (timeFormat) {
+ case 0: // 12h
+ ((XTimePreference) pref).setFormat(XTimePreference.Format.FORMAT_12H);
+ break;
+ case 1: // 24h
+ ((XTimePreference) pref).setFormat(XTimePreference.Format.FORMAT_24H);
+ break;
+ }
+ }
+ pref.setSummary(state.getSummary().getValueDate().getSubtitle().getText());
+ pref.setOnPreferenceChangeListener((preference, newValue) -> {
+ final String[] pieces = newValue.toString().split(":");
+
+ final int hour = Integer.parseInt(pieces[0]);
+ final int minute = Integer.parseInt(pieces[1]);
+
+ pref.setEnabled(false);
+ sendChangeRequest(
+ GdiSettingsService.ChangeRequest.newBuilder()
+ .setScreenId(screenId)
+ .setEntryId(entry.getId())
+ .setTime(GdiSettingsService.ChangeRequest.Time.newBuilder()
+ .setSeconds(hour * 3600 + minute * 60)
+ )
+ );
+ return true;
+ });
+ break;
+ case 5: // number picker
+ pref = new EditTextPreference(activity);
+ ((EditTextPreference) pref).setText(String.valueOf(state.getSummary().getValueNumber().getValue()));
+ ((EditTextPreference) pref).setSummary(state.getSummary().getValueNumber().getSubtitle().getText());
+
+ ((EditTextPreference) pref).setOnBindEditTextListener(p -> {
+ p.setInputType(InputType.TYPE_CLASS_NUMBER);
+ int minValue = Integer.MIN_VALUE;
+ int maxValue = Integer.MAX_VALUE;
+ if (entry.getTarget().getNumberPicker().hasMin()) {
+ minValue = entry.getTarget().getNumberPicker().getMin();
+ }
+ if (entry.getTarget().getNumberPicker().hasMax()) {
+ maxValue = entry.getTarget().getNumberPicker().getMax();
+ }
+ p.addTextChangedListener(new MinMaxTextWatcher(p, minValue, maxValue));
+ p.setSelection(p.getText().length());
+ });
+ ((EditTextPreference) pref).setOnPreferenceChangeListener((preference, newValue) -> {
+ final int newValueInt = Integer.parseInt(newValue.toString());
+
+ pref.setEnabled(false);
+ sendChangeRequest(
+ GdiSettingsService.ChangeRequest.newBuilder()
+ .setScreenId(screenId)
+ .setEntryId(entry.getId())
+ .setNumber(GdiSettingsService.ChangeRequest.Number.newBuilder()
+ .setValue(newValueInt)
+ )
+ );
+ return true;
+ });
+ break;
+ case 6: // activity
+ switch (entry.getTarget().getActivity()) {
+ case 2: // garmin pay
+ case 7: // text responses
+ case 8: // music providers
+ case 17: // Solar Intensity
+ case 29: // Set Up ECG App
+ case 30: // ECG
+ pref = new Preference(activity);
+ pref.setVisible(debug);
+ pref.setEnabled(false);
+ break;
+ default:
+ supported = false;
+ pref = new Preference(activity);
+ break;
+ }
+
+ break;
+ case 7: // hidden?
+ pref = new Preference(activity);
+ pref.setVisible(debug);
+ pref.setEnabled(false);
+ break;
+ case 10: // date picker
+ pref = new XDatePreference(activity, null);
+ ((XDatePreference) pref).setValue(
+ Objects.requireNonNull(state).getSummary().getValueDate().getCurrentDate().getYear(),
+ Objects.requireNonNull(state).getSummary().getValueDate().getCurrentDate().getMonth(),
+ Objects.requireNonNull(state).getSummary().getValueDate().getCurrentDate().getDay()
+ );
+ if (state.getSummary().getValueDate().hasMinDate()) {
+ final Calendar calendar = GregorianCalendar.getInstance();
+ calendar.set(
+ state.getSummary().getValueDate().getMinDate().getYear(),
+ state.getSummary().getValueDate().getMinDate().getMonth() - 1,
+ state.getSummary().getValueDate().getMinDate().getDay()
+ );
+ ((XDatePreference) pref).setMinDate(calendar.getTimeInMillis());
+ }
+ if (state.getSummary().getValueDate().hasMaxDate()) {
+ final Calendar calendar = GregorianCalendar.getInstance();
+ calendar.set(
+ state.getSummary().getValueDate().getMaxDate().getYear(),
+ state.getSummary().getValueDate().getMaxDate().getMonth() - 1,
+ state.getSummary().getValueDate().getMaxDate().getDay()
+ );
+ ((XDatePreference) pref).setMaxDate(calendar.getTimeInMillis());
+ }
+ pref.setSummary(state.getSummary().getValueDate().getSubtitle().getText());
+ pref.setOnPreferenceChangeListener((preference, newValue) -> {
+ final String[] pieces = newValue.toString().split("-");
+
+ final int year = Integer.parseInt(pieces[0]);
+ final int month = Integer.parseInt(pieces[1]);
+ final int day = Integer.parseInt(pieces[2]);
+
+ pref.setEnabled(false);
+ sendChangeRequest(
+ GdiSettingsService.ChangeRequest.newBuilder()
+ .setScreenId(screenId)
+ .setEntryId(entry.getId())
+ .setNewDate(GdiSettingsService.ChangeRequest.NewDate.newBuilder()
+ .setValue(GdiSettingsService.Date.newBuilder()
+ .setYear(year).setMonth(month).setDay(day)
+ )
+ )
+ );
+
+ return true;
+ });
+ break;
+ case 12: // Connect IQ Store
+ pref = new Preference(activity);
+ pref.setVisible(debug);
+ pref.setEnabled(false);
+ break;
+ case 13: // height
+ pref = new EditTextPreference(activity);
+ ((EditTextPreference) pref).setText(String.valueOf(state.getSummary().getValueHeight().getValue()));
+ ((EditTextPreference) pref).setSummary(state.getSummary().getValueHeight().getSubtitle().getText());
+ if (state.getSummary().getValueHeight().getUnit() == 0) {
+ ((EditTextPreference) pref).setDialogTitle(R.string.activity_prefs_height_cm);
+ ((EditTextPreference) pref).setTitle(R.string.activity_prefs_height_cm);
+ } else {
+ ((EditTextPreference) pref).setDialogTitle(R.string.activity_prefs_height_inches);
+ ((EditTextPreference) pref).setTitle(R.string.activity_prefs_height_inches);
+ }
+ ((EditTextPreference) pref).setOnBindEditTextListener(p -> {
+ p.setInputType(InputType.TYPE_CLASS_NUMBER);
+ p.addTextChangedListener(new MinMaxTextWatcher(p, 0, 300));
+ p.setSelection(p.getText().length());
+ });
+ ((EditTextPreference) pref).setOnPreferenceChangeListener((preference, newValue) -> {
+ final int newValueInt = Integer.parseInt(newValue.toString());
+
+ pref.setEnabled(false);
+ sendChangeRequest(
+ GdiSettingsService.ChangeRequest.newBuilder()
+ .setScreenId(screenId)
+ .setEntryId(entry.getId())
+ .setHeight(GdiSettingsService.ChangeRequest.Height.newBuilder()
+ .setValue(newValueInt)
+ .setUnit(state.getSummary().getValueHeight().getUnit())
+ )
+ );
+ return true;
+ });
+ break;
+ default:
+ supported = false;
+ pref = new Preference(activity);
+ }
+ } else { // No target
+ switch (entry.getType()) {
+ case 0: // notice
+ pref = new Preference(activity);
+ pref.setSummary(entry.getTitle().getText());
+ break;
+ case 1: // category
+ pref = new PreferenceCategory(activity);
+ break;
+ case 2: // space
+ pref = new PreferenceCategory(activity);
+ pref.setTitle("");
+ break;
+ case 3: // switch
+ pref = new SwitchPreferenceCompat(activity);
+ pref.setLayoutResource(R.layout.preference_checkbox);
+ ((SwitchPreferenceCompat) pref).setChecked(Objects.requireNonNull(state).getSwitch().getEnabled());
+ ((SwitchPreferenceCompat) pref).setSummary(Objects.requireNonNull(state).getSwitch().getTitle().getText());
+ pref.setOnPreferenceChangeListener((preference, newValue) -> {
+ pref.setEnabled(false);
+ sendChangeRequest(
+ GdiSettingsService.ChangeRequest.newBuilder()
+ .setScreenId(screenId)
+ .setEntryId(entry.getId())
+ .setSwitch(GdiSettingsService.ChangeRequest.Switch.newBuilder()
+ .setValue((Boolean) newValue)
+ )
+ );
+ return true;
+ });
+
+ break;
+ case 4: // single line + optional icon
+ case 5: // double line
+ pref = new Preference(activity);
+ break;
+ case 18: // single line with action (eg. glances)
+ case 7: // single line, normally in list for selection?
+ pref = new Preference(activity);
+ pref.setOnPreferenceClickListener(preference -> {
+ pref.setEnabled(false);
+ sendChangeRequest(
+ GdiSettingsService.ChangeRequest.newBuilder()
+ .setScreenId(screenId)
+ .setEntryId(entry.getId())
+ );
+ return true;
+ });
+ break;
+ case 8: // device + status?
+ case 9: // finish setup
+ case 10: // find my device
+ case 11: // preferred activity tracker
+ case 13: // help & info
+ case 24: // available accessories?
+ pref = new Preference(activity);
+ pref.setVisible(debug);
+ pref.setEnabled(false);
+ break;
+ case 15: // sortable + delete
+ // Add all sortable items and then continue
+ final String moveUpStr = activity.getString(R.string.widget_move_up);
+ final String moveDownStr = activity.getString(R.string.widget_move_down);
+ final String deleteStr = activity.getString(R.string.appmananger_app_delete);
+
+ for (int i = 0; i < entry.getSortOptions().getEntriesCount(); i++) {
+ final GdiSettingsService.SortEntry sortEntry = entry.getSortOptions().getEntries(i);
+ final List<String> sortableOptions = new ArrayList<>(3);
+ if (i > 0) {
+ sortableOptions.add(moveUpStr);
+ }
+ if (i < entry.getSortOptions().getEntriesCount() - 1) {
+ sortableOptions.add(moveDownStr);
+ }
+ sortableOptions.add(deleteStr);
+ final ArrayAdapter<String> sortOptionsAdapter = new ArrayAdapter<>(activity, android.R.layout.simple_list_item_1, sortableOptions);
+ final int iFinal = i;
+ final Preference sortPref = new Preference(activity);
+ sortPref.setTitle(sortEntry.getTitle().getText());
+ sortPref.setPersistent(false);
+ sortPref.setIconSpaceReserved(false);
+ sortPref.setKey("rt_pref_" + screenId + "_" + entry.getId() + "__" + sortEntry.getId());
+ sortPref.setOnPreferenceClickListener(preference -> {
+ new MaterialAlertDialogBuilder(activity)
+ .setTitle(sortPref.getTitle())
+ .setAdapter(sortOptionsAdapter, (dialogInterface, j) -> {
+ final String option = sortableOptions.get(j);
+ int moveOffset = 0;
+ if (option.equals(moveUpStr)) {
+ moveOffset = -1;
+ } else if (option.equals(moveDownStr)) {
+ moveOffset = 1;
+ }
+
+ if (moveOffset != 0) {
+ sortPref.setEnabled(false);
+ sendChangeRequest(
+ GdiSettingsService.ChangeRequest.newBuilder()
+ .setScreenId(screenId)
+ .setEntryId(sortEntry.getId())
+ .setPosition(GdiSettingsService.ChangeRequest.Position.newBuilder()
+ .setIndex(iFinal + moveOffset)
+ )
+ );
+ return;
+ }
+
+ if (option.equals(deleteStr)) {
+ sortPref.setEnabled(false);
+ sendChangeRequest(
+ GdiSettingsService.ChangeRequest.newBuilder()
+ .setScreenId(screenId)
+ .setEntryId(sortEntry.getId())
+ .setPosition(GdiSettingsService.ChangeRequest.Position.newBuilder()
+ .setDelete(true)
+ )
+ );
+ }
+ }).setNegativeButton(android.R.string.cancel, null)
+ .create().show();
+ return true;
+ });
+ prefScreen.addPreference(sortPref);
+ }
+
+ continue; // We already added all options above, continue
+ case 16: // text
+ pref = new EditTextPreference(activity);
+
+ ((EditTextPreference) pref).setOnBindEditTextListener(p -> {
+ int maxValue = Integer.MAX_VALUE;
+ if (entry.getTextOption().hasLimits() && entry.getTextOption().getLimits().hasMaxLength()) {
+ p.setFilters(new InputFilter[]{new InputFilter.LengthFilter(entry.getTextOption().getLimits().getMaxLength())});
+ }
+ p.setSelection(p.getText().length());
+ });
+ ((EditTextPreference) pref).setOnPreferenceChangeListener((preference, newValue) -> {
+ if (StringUtils.isNullOrEmpty(newValue.toString())) {
+ return true;
+ }
+ pref.setEnabled(false);
+ sendChangeRequest(
+ GdiSettingsService.ChangeRequest.newBuilder()
+ .setScreenId(screenId)
+ .setEntryId(entry.getId())
+ .setText(GdiSettingsService.ChangeRequest.Text.newBuilder()
+ .setValue(newValue.toString())
+ )
+ );
+ return true;
+ });
+ break;
+ default:
+ supported = false;
+ pref = new Preference(activity);
+ }
+ }
+
+ if (StringUtils.isNullOrEmpty(pref.getTitle()) && entry.getType() != 0 && entry.getType() != 2) {
+ pref.setTitle(!StringUtils.isEmpty(entry.getTitle().getText()) ? entry.getTitle().getText() : activity.getString(R.string.unknown));
+
+ if (pref instanceof DialogPreference) {
+ ((DialogPreference) pref).setDialogTitle(pref.getTitle());
+ }
+ }
+
+ final int icon = getIcon(entry);
+ if (icon != 0) {
+ pref.setIcon(icon);
+ } else {
+ pref.setIconSpaceReserved(false);
+ }
+
+ if (state != null && !StringUtils.isEmpty(state.getSummary().getTitle().getText())) {
+ pref.setSummary(state.getSummary().getTitle().getText());
+ }
+
+ if (state != null && state.hasState()) {
+ switch (state.getState()) {
+ case 1:
+ pref.setVisible(false);
+ break;
+ case 2:
+ pref.setEnabled(false);
+ break;
+ default:
+ LOG.warn("Unknown state value {}", state.getState());
+ }
+ }
+
+ if (!supported) {
+ pref.setEnabled(false);
+
+ if (StringUtils.isNullOrEmpty(pref.getSummary())) {
+ pref.setSummary(R.string.unsupported);
+ } else {
+ pref.setSummary(activity.getString(R.string.menuitem_unsupported, pref.getSummary()));
+ }
+ }
+
+ if (debug) {
+ final StringBuilder sb = new StringBuilder();
+
+ if (pref.getSummary() != null && pref.getSummary().length() != 0) {
+ sb.append(pref.getSummary()).append("\n");
+ }
+
+ sb.append("id=").append(entry.getId());
+ sb.append(", type=").append(entry.getType());
+
+ if (icon == 0 && entry.hasIcon()) {
+ sb.append(", icon=").append(entry.getIcon());
+ }
+
+ if (entry.hasTarget()) {
+ sb.append(", targetType=").append(entry.getTarget().getType());
+ if (entry.getTarget().hasActivity()) {
+ sb.append(", targetActivity=").append(entry.getTarget().getActivity());
+ }
+ }
+
+ pref.setSummary(sb.toString());
+ }
+
+ pref.setPersistent(false);
+ pref.setKey("rt_pref_" + screenId + "_" + entry.getId());
+ prefScreen.addPreference(pref);
+ }
+
+ // If no preferences after the last visible preference category are visible, hide it
+ boolean previousWasVisible = false;
+ PreferenceCategory lastSeenCategory = null;
+ for (int i = prefScreen.getPreferenceCount() - 1; i >= 0; i--) {
+ final Preference pref = prefScreen.getPreference(i);
+ if (pref instanceof PreferenceCategory) {
+ lastSeenCategory = (PreferenceCategory) pref;
+
+ if (!previousWasVisible) {
+ lastSeenCategory.setVisible(false);
+ }
+
+ previousWasVisible = false;
+ } else {
+ previousWasVisible |= pref.isVisible();
+ }
+ }
+
+ if (!previousWasVisible && lastSeenCategory != null) {
+ lastSeenCategory.setVisible(false);
+ }
+ }
+
+ @DrawableRes
+ private int getIcon(final GdiSettingsService.ScreenEntry entry) {
+ if (entry.hasIcon()) {
+ switch (entry.getIcon()) {
+ //
+ // Main menu
+ case 20: // Garmin Pay
+ return 0;
+ case 21: // Text Responses
+ return R.drawable.ic_reply;
+ case 4: // Clocks
+ return R.drawable.ic_access_time;
+ case 2: // Glances
+ return R.drawable.ic_widgets;
+ case 3: // Controls
+ return R.drawable.ic_menu;
+ case 1: // Activities / Apps, have the same icon
+ return R.drawable.ic_activity_unknown_small;
+ case 39: // Shortcut
+ return R.drawable.ic_shortcut;
+ case 27: // Notifications & Alerts
+ return R.drawable.ic_notifications;
+ case 30: // Wrist heart rate frequency
+ return R.drawable.ic_heartrate;
+ case 38: // Alarms
+ return R.drawable.ic_access_alarms;
+ case 5: // Sensors & accessories
+ case 46: // Watch Sensors
+ return R.drawable.ic_sensor_calibration;
+ case 47: // Accessories
+ return R.drawable.ic_bluetooth_searching;
+ case 6: // Map
+ return R.drawable.ic_map;
+ case 7: // Music
+ return R.drawable.ic_music_note;
+ case 8: // Phone
+ return R.drawable.ic_phone;
+ case 11: // Connectivity
+ return R.drawable.ic_bluetooth_searching;
+ case 13: // Audio Prompts
+ case 60: // Sound & Vibe
+ return R.drawable.ic_volume_up;
+ case 61: // Display & Brightness
+ return R.drawable.ic_wb_sunny;
+ case 62: // Focus Modes
+ return R.drawable.ic_focus;
+ case 14: // User Profile
+ return R.drawable.ic_person;
+ case 15: // Safety & Tracking
+ return R.drawable.ic_emergency;
+ case 16: // Activity Tracking
+ return R.drawable.ic_activity_unknown_small;
+ case 17: // Navigation
+ return R.drawable.ic_navigation;
+ case 18: // Power manager
+ return R.drawable.ic_battery;
+ case 19: // System
+ return R.drawable.ic_settings;
+ case 26: // Appearance
+ return R.drawable.ic_paint;
+ case 44: // Health & wellness
+ return R.drawable.ic_health;
+
+ //
+ // Sortable screens (glances, apps, etc)
+ case 33:
+ return R.drawable.ic_add_gray;
+ }
+ }
+
+ return 0;
+ }
+
+ void toggleDebug() {
+ final Prefs prefs = GBApplication.getDevicePrefs(device);
+ prefs.getPreferences().edit()
+ .putBoolean(PREF_DEBUG, !prefs.getBoolean(PREF_DEBUG, BuildConfig.DEBUG))
+ .apply();
+
+ reload();
+ }
+
+ void shareDebug() {
+ final Intent intent = new Intent(android.content.Intent.ACTION_SEND);
+ intent.setType("text/plain");
+
+ final StringBuilder sb = new StringBuilder();
+
+ sb.append("screenId: ").append(screenId);
+ sb.append("\n");
+
+ sb.append("settingsScreen: ");
+ if (screenDefinition != null) {
+ sb.append(GB.hexdump(screenDefinition.toByteArray()));
+ } else {
+ sb.append("null");
+ }
+ sb.append("\n");
+
+ sb.append("settingsState: ");
+ if (screenState != null) {
+ sb.append(GB.hexdump(screenState.toByteArray()));
+ } else {
+ sb.append("null");
+ }
+ sb.append("\n");
+
+ intent.putExtra(android.content.Intent.EXTRA_SUBJECT, "Garmin Settings Screen " + screenId);
+ intent.putExtra(android.content.Intent.EXTRA_TEXT, sb.toString());
+
+ try {
+ startActivity(Intent.createChooser(intent, "Share debug info"));
+ } catch (final ActivityNotFoundException e) {
+ Toast.makeText(requireContext(), "Failed to share text", Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private void sendChangeRequest(final GdiSettingsService.ChangeRequest.Builder changeRequest) {
+ screenDefinition = null;
+ screenState = null;
+ final GdiSmartProto.Smart smart = GdiSmartProto.Smart.newBuilder()
+ .setSettingsService(GdiSettingsService.SettingsService.newBuilder()
+ .setChangeRequest(changeRequest)
+ ).build();
+ GBApplication.deviceService(device).onSendConfiguration("protobuf:" + GB.hexdump(smart.toByteArray()));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRespiratoryRateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRespiratoryRateSampleProvider.java
new file mode 100644
index 0000000..eca393f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRespiratoryRateSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminRespiratoryRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminRespiratoryRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminRespiratoryRateSampleProvider extends AbstractTimeSampleProvider<GarminRespiratoryRateSample> {
+ public GarminRespiratoryRateSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminRespiratoryRateSample, ?> getSampleDao() {
+ return getSession().getGarminRespiratoryRateSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminRespiratoryRateSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminRespiratoryRateSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminRespiratoryRateSample createSample() {
+ return new GarminRespiratoryRateSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRestingMetabolicRateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRestingMetabolicRateSampleProvider.java
new file mode 100644
index 0000000..969f6e4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRestingMetabolicRateSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminRestingMetabolicRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminRestingMetabolicRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminRestingMetabolicRateSampleProvider extends AbstractTimeSampleProvider<GarminRestingMetabolicRateSample> {
+ public GarminRestingMetabolicRateSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminRestingMetabolicRateSample, ?> getSampleDao() {
+ return getSession().getGarminRestingMetabolicRateSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminRestingMetabolicRateSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminRestingMetabolicRateSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminRestingMetabolicRateSample createSample() {
+ return new GarminRestingMetabolicRateSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java
new file mode 100644
index 0000000..71a1eab
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java
@@ -0,0 +1,262 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.GB.toast;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Parcel;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.documentfile.provider.DocumentFile;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps.GarminAgpsStatus;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class GarminSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ private static final Logger LOG = LoggerFactory.getLogger(GarminSettingsCustomizer.class);
+
+ private final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ final Preference realtimeSettings = handler.findPreference(GarminPreferences.PREF_GARMIN_REALTIME_SETTINGS);
+ if (realtimeSettings != null) {
+ realtimeSettings.setOnPreferenceClickListener(preference -> {
+ final Intent intent = new Intent(handler.getContext(), GarminRealtimeSettingsActivity.class);
+ intent.putExtra(GBDevice.EXTRA_DEVICE, handler.getDevice());
+ handler.getContext().startActivity(intent);
+ return true;
+ });
+ }
+
+ final PreferenceCategory prefAgpsHeader = handler.findPreference(DeviceSettingsPreferenceConst.PREF_HEADER_AGPS);
+ if (prefAgpsHeader != null) {
+ final List<String> urls = prefs.getList(GarminPreferences.PREF_AGPS_KNOWN_URLS, Collections.emptyList(), "\n");
+ if (urls.isEmpty()) {
+ return;
+ }
+
+ final String currentFolder = prefs.getString(GarminPreferences.PREF_GARMIN_AGPS_FOLDER, "");
+
+ final Preference prefFolder = handler.findPreference(GarminPreferences.PREF_GARMIN_AGPS_FOLDER);
+ final ActivityResultLauncher<Uri> agpsFolderChooser = handler.registerForActivityResult(
+ new ActivityResultContracts.OpenDocumentTree(),
+ localUri -> {
+ LOG.info("Garmin agps folder: {}", localUri);
+ if (localUri != null) {
+ handler.getContext().getContentResolver().takePersistableUriPermission(localUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ prefs.getPreferences().edit()
+ .putString(GarminPreferences.PREF_GARMIN_AGPS_FOLDER, localUri.toString())
+ .apply();
+ prefFolder.setSummary(localUri.toString());
+
+ for (final String url : urls) {
+ updateAgpsStatus(handler, prefs, url);
+ }
+ }
+ }
+ );
+ prefFolder.setOnPreferenceClickListener(preference -> {
+ agpsFolderChooser.launch(null);
+ return true;
+ });
+ prefFolder.setSummary(currentFolder);
+ prefAgpsHeader.addPreference(prefFolder);
+
+ int i = 0;
+ for (final String url : urls) {
+ i++;
+
+ final Preference prefHeader = new PreferenceCategory(handler.getContext());
+ prefHeader.setKey("pref_agps_url_header_" + i);
+ prefHeader.setIconSpaceReserved(false);
+ prefHeader.setTitle(handler.getContext().getString(R.string.garmin_agps_url_i, i));
+ prefAgpsHeader.addPreference(prefHeader);
+
+ final Preference prefUrl = new Preference(handler.getContext());
+ prefUrl.setOnPreferenceClickListener(preference -> {
+ final ClipboardManager clipboard = (ClipboardManager) handler.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ final ClipData clip = ClipData.newPlainText(handler.getContext().getString(R.string.url), url);
+ clipboard.setPrimaryClip(clip);
+ toast(handler.getContext(), handler.getContext().getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT, GB.INFO);
+ return true;
+ });
+ prefUrl.setKey("pref_garmin_agps_url_" + i);
+ prefUrl.setIcon(R.drawable.ic_link);
+ prefUrl.setTitle(R.string.url);
+ prefUrl.setSummary(url);
+ prefAgpsHeader.addPreference(prefUrl);
+
+ final Preference prefLocalFile = new Preference(handler.getContext());
+ prefLocalFile.setOnPreferenceClickListener(preference -> {
+ selectAgpsFile(handler, prefs, url, prefLocalFile);
+ return true;
+ });
+ prefLocalFile.setKey(GarminPreferences.agpsFilename(url));
+ prefLocalFile.setIcon(R.drawable.ic_file_open);
+ prefLocalFile.setTitle(R.string.garmin_agps_local_file);
+ prefLocalFile.setSummary(prefs.getString(GarminPreferences.agpsFilename(url), ""));
+ prefAgpsHeader.addPreference(prefLocalFile);
+
+ final Preference prefStatus = new Preference(handler.getContext());
+ prefStatus.setKey(GarminPreferences.agpsStatus(url));
+ prefStatus.setIcon(R.drawable.ic_health);
+ prefStatus.setTitle(R.string.status);
+ prefAgpsHeader.addPreference(prefStatus);
+ updateAgpsStatus(handler, prefs, url);
+
+ final Preference prefUpdateTime = new Preference(handler.getContext());
+ prefUpdateTime.setKey(GarminPreferences.agpsUpdateTime(url));
+ prefUpdateTime.setIcon(R.drawable.ic_calendar_today);
+ prefUpdateTime.setTitle(R.string.pref_agps_update_time);
+ final long ts = prefs.getLong(GarminPreferences.agpsUpdateTime(url), 0L);
+ if (ts > 0) {
+ prefUpdateTime.setSummary(String.format("%s (%s)",
+ SDF.format(new Date(ts)),
+ DateTimeUtils.formatDurationHoursMinutes(System.currentTimeMillis() - ts, TimeUnit.MILLISECONDS)
+ ));
+ } else {
+ prefUpdateTime.setSummary(handler.getContext().getString(R.string.unknown));
+ }
+ prefAgpsHeader.addPreference(prefUpdateTime);
+ }
+ }
+ }
+
+ private void selectAgpsFile(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String url, final Preference prefLocalFile) {
+ final String currentFolder = prefs.getString(GarminPreferences.PREF_GARMIN_AGPS_FOLDER, "");
+
+ final String folderUri = prefs.getString(GarminPreferences.PREF_GARMIN_AGPS_FOLDER, "");
+ if (folderUri.isEmpty()) {
+ GB.toast(handler.getContext().getString(R.string.no_folder_selected), Toast.LENGTH_SHORT, GB.INFO);
+ return;
+ }
+
+ final DocumentFile folder = DocumentFile.fromTreeUri(handler.getContext(), Uri.parse(currentFolder));
+ if (folder == null || folder.listFiles().length == 0) {
+ GB.toast(handler.getContext().getString(R.string.folder_is_empty), Toast.LENGTH_SHORT, GB.INFO);
+ return;
+ }
+
+ final DocumentFile[] documentFiles = folder.listFiles();
+ final String[] files = new String[documentFiles.length + 1];
+ files[0] = handler.getContext().getString(R.string.none);
+ final String selectedFile = prefs.getString(GarminPreferences.agpsFilename(url), "");
+ int checkedItem = 0;
+ for (int j = 0; j < documentFiles.length; j++) {
+ files[j + 1] = documentFiles[j].getName();
+ if (selectedFile.equals(files[j + 1])) {
+ checkedItem = j + 1;
+ }
+ }
+
+ final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(handler.getContext());
+ builder.setTitle(R.string.garmin_agps_local_file);
+
+ final AtomicInteger selectedIdx = new AtomicInteger(0);
+ builder.setSingleChoiceItems(files, checkedItem, (dialog, which) -> {
+ selectedIdx.set(which);
+ });
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ final String selectedFilename = selectedIdx.get() > 0 ? files[selectedIdx.get()] : null;
+ prefs.getPreferences().edit()
+ .putString(GarminPreferences.agpsFilename(url), selectedFilename)
+ .apply();
+ prefLocalFile.setSummary(selectedFilename);
+ updateAgpsStatus(handler, prefs, url);
+ });
+ builder.setNegativeButton(android.R.string.cancel, null);
+ builder.create().show();
+ }
+
+ private void updateAgpsStatus(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String url) {
+ final Preference prefStatus = handler.findPreference(GarminPreferences.agpsStatus(url));
+
+ final String filename = prefs.getString(GarminPreferences.agpsFilename(url), "");
+ if (filename.isEmpty()) {
+ prefStatus.setSummary("");
+ return;
+ }
+ final String folderUri = prefs.getString(GarminPreferences.PREF_GARMIN_AGPS_FOLDER, "");
+ if (folderUri.isEmpty()) {
+ prefStatus.setSummary("");
+ return;
+ }
+ final DocumentFile folder = DocumentFile.fromTreeUri(handler.getContext(), Uri.parse(folderUri));
+ if (folder == null) {
+ prefStatus.setSummary("");
+ return;
+ }
+ final GarminAgpsStatus agpsStatus;
+ final DocumentFile localFile = folder.findFile(filename);
+ if (localFile != null && localFile.isFile() && localFile.canRead()) {
+ if (localFile.lastModified() < prefs.getLong(GarminPreferences.agpsUpdateTime(url), 0L)) {
+ agpsStatus = GarminAgpsStatus.CURRENT;
+ } else {
+ agpsStatus = GarminAgpsStatus.PENDING;
+ }
+ } else {
+ agpsStatus = GarminAgpsStatus.MISSING;
+ }
+ prefStatus.setSummary(handler.getContext().getString(agpsStatus.getText()));
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ public static final Creator<GarminSettingsCustomizer> CREATOR = new Creator<GarminSettingsCustomizer>() {
+ @Override
+ public GarminSettingsCustomizer createFromParcel(final Parcel in) {
+ return new GarminSettingsCustomizer();
+ }
+
+ @Override
+ public GarminSettingsCustomizer[] newArray(final int size) {
+ return new GarminSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStageSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStageSampleProvider.java
new file mode 100644
index 0000000..bf34a50
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStageSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminSleepStageSampleProvider extends AbstractTimeSampleProvider<GarminSleepStageSample> {
+ public GarminSleepStageSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminSleepStageSample, ?> getSampleDao() {
+ return getSession().getGarminSleepStageSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminSleepStageSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminSleepStageSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminSleepStageSample createSample() {
+ return new GarminSleepStageSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStatsSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStatsSampleProvider.java
new file mode 100644
index 0000000..607a0d7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStatsSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStatsSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStatsSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminSleepStatsSampleProvider extends AbstractTimeSampleProvider<GarminSleepStatsSample> {
+ public GarminSleepStatsSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminSleepStatsSample, ?> getSampleDao() {
+ return getSession().getGarminSleepStatsSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminSleepStatsSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminSleepStatsSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminSleepStatsSample createSample() {
+ return new GarminSleepStatsSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSpo2SampleProvider.java
new file mode 100644
index 0000000..0705aa1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSpo2SampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminSpo2SampleProvider extends AbstractTimeSampleProvider<GarminSpo2Sample> {
+ public GarminSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminSpo2Sample, ?> getSampleDao() {
+ return getSession().getGarminSpo2SampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminSpo2SampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminSpo2SampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminSpo2Sample createSample() {
+ return new GarminSpo2Sample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminStressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminStressSampleProvider.java
new file mode 100644
index 0000000..163b1d9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminStressSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminStressSampleProvider extends AbstractTimeSampleProvider<GarminStressSample> {
+ public GarminStressSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<GarminStressSample, ?> getSampleDao() {
+ return getSession().getGarminStressSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GarminStressSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GarminStressSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GarminStressSample createSample() {
+ return new GarminStressSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java
new file mode 100644
index 0000000..ae95af1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminWorkoutParser.java
@@ -0,0 +1,369 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.*;
+
+import android.content.Context;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Optional;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryProgressEntry;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryTableRowEntry;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryValue;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums.GarminSport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitPhysiologicalMetrics;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSession;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSet;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitTimeInZone;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+
+public class GarminWorkoutParser implements ActivitySummaryParser {
+ private static final Logger LOG = LoggerFactory.getLogger(GarminWorkoutParser.class);
+
+ private final Context context;
+
+ private final List<FitTimeInZone> timesInZone = new ArrayList<>();
+ private final List<ActivityPoint> activityPoints = new ArrayList<>();
+ private FitSession session = null;
+ private FitSport sport = null;
+ private FitPhysiologicalMetrics physiologicalMetrics = null;
+ private final List<FitSet> sets = new ArrayList<>();
+
+ public GarminWorkoutParser(final Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public BaseActivitySummary parseBinaryData(final BaseActivitySummary summary, final boolean forDetails) {
+ if (!forDetails) {
+ // Our parsing is too slow, especially without a RecyclerView
+ return summary;
+ }
+
+ final long nanoStart = System.nanoTime();
+
+ reset();
+
+ final String rawDetailsPath = summary.getRawDetailsPath();
+ if (rawDetailsPath == null) {
+ LOG.warn("No rawDetailsPath");
+ return summary;
+ }
+ final File file = FileUtils.tryFixPath(new File(rawDetailsPath));
+ if (file == null || !file.isFile() || !file.canRead()) {
+ LOG.warn("Unable to read {}", rawDetailsPath);
+ return summary;
+ }
+
+ final FitFile fitFile;
+ try {
+ fitFile = FitFile.parseIncoming(file);
+ } catch (final IOException e) {
+ LOG.error("Failed to parse fit file", e);
+ return summary;
+ }
+
+ for (final RecordData record : fitFile.getRecords()) {
+ handleRecord(record);
+ }
+
+ updateSummary(summary);
+
+ final long nanoEnd = System.nanoTime();
+ final long executionTime = (nanoEnd - nanoStart) / 1000000;
+ LOG.trace("Updating summary took {}ms", executionTime);
+
+ return summary;
+ }
+
+ public void reset() {
+ timesInZone.clear();
+ activityPoints.clear();
+ session = null;
+ sport = null;
+ physiologicalMetrics = null;
+ sets.clear();
+ }
+
+ public boolean handleRecord(final RecordData record) {
+ if (record instanceof FitRecord) {
+ activityPoints.add(((FitRecord) record).toActivityPoint());
+ } else if (record instanceof FitSession) {
+ LOG.debug("Session: {}", record);
+ if (session != null) {
+ LOG.warn("Got multiple sessions - NOT SUPPORTED: {}", record);
+ } else {
+ // We only support 1 session
+ session = (FitSession) record;
+ }
+ } else if (record instanceof FitPhysiologicalMetrics) {
+ LOG.debug("Physiological Metrics: {}", record);
+ physiologicalMetrics = (FitPhysiologicalMetrics) record;
+ } else if (record instanceof FitSport) {
+ LOG.debug("Sport: {}", record);
+ if (sport != null) {
+ LOG.warn("Got multiple sports - NOT SUPPORTED: {}", record);
+ } else {
+ // We only support 1 sport
+ sport = (FitSport) record;
+ }
+ } else if (record instanceof FitTimeInZone) {
+ LOG.trace("Time in zone: {}", record);
+ timesInZone.add((FitTimeInZone) record);
+ } else if (record instanceof FitSet) {
+ LOG.trace("Set: {}", record);
+ sets.add((FitSet) record);
+ } else {
+ return false;
+ }
+
+ return true;
+ }
+
+ public void updateSummary(final BaseActivitySummary summary) {
+ if (session == null) {
+ LOG.error("Got workout, but no session");
+ return;
+ }
+
+ final ActivitySummaryData summaryData = new ActivitySummaryData();
+
+ final ActivityKind activityKind;
+ if (sport != null) {
+ summary.setName(sport.getName());
+ activityKind = getActivityKind(sport.getSport(), sport.getSubSport());
+ } else {
+ activityKind = getActivityKind(session.getSport(), session.getSubSport());
+ }
+ summary.setActivityKind(activityKind.getCode());
+
+ if (session.getTotalElapsedTime() != null) {
+ summary.setEndTime(new Date(summary.getStartTime().getTime() + session.getTotalElapsedTime().intValue()));
+ }
+
+ if (session.getTotalTimerTime() != null) {
+ summaryData.add(ACTIVE_SECONDS, session.getTotalTimerTime() / 1000f, UNIT_SECONDS);
+ }
+ if (session.getTotalDistance() != null) {
+ summaryData.add(DISTANCE_METERS, session.getTotalDistance() / 100f, UNIT_METERS);
+ }
+ if (session.getTotalSteps() != null) {
+ summaryData.add(STEPS, session.getTotalSteps(), UNIT_STEPS);
+ }
+ if (session.getTotalCalories() != null) {
+ summaryData.add(CALORIES_BURNT, session.getTotalCalories(), UNIT_KCAL);
+ }
+ if (session.getEstimatedSweatLoss() != null) {
+ summaryData.add(ESTIMATED_SWEAT_LOSS, session.getEstimatedSweatLoss(), UNIT_ML);
+ }
+ if (session.getAverageHeartRate() != null) {
+ summaryData.add(HR_AVG, session.getAverageHeartRate(), UNIT_BPM);
+ }
+ if (session.getMaxHeartRate() != null) {
+ summaryData.add(HR_MAX, session.getMaxHeartRate(), UNIT_BPM);
+ }
+ if (session.getHrvSdrr() != null) {
+ summaryData.add(HRV_SDRR, session.getHrvSdrr(), UNIT_MILLISECONDS);
+ }
+ if (session.getHrvRmssd() != null) {
+ summaryData.add(HRV_RMSSD, session.getHrvRmssd(), UNIT_MILLISECONDS);
+ }
+ if (session.getAvgSpo2() != null) {
+ summaryData.add(SPO2_AVG, session.getAvgSpo2(), UNIT_PERCENTAGE);
+ }
+ if (session.getEnhancedAvgRespirationRate() != null) {
+ summaryData.add(RESPIRATION_AVG, session.getEnhancedAvgRespirationRate(), UNIT_BREATHS_PER_MIN);
+ }
+ if (session.getEnhancedMaxRespirationRate() != null) {
+ summaryData.add(RESPIRATION_MAX, session.getEnhancedMaxRespirationRate(), UNIT_BREATHS_PER_MIN);
+ }
+ if (session.getEnhancedMinRespirationRate() != null) {
+ summaryData.add(RESPIRATION_MIN, session.getEnhancedMinRespirationRate(), UNIT_BREATHS_PER_MIN);
+ }
+ if (session.getAvgStress() != null) {
+ summaryData.add(STRESS_AVG, session.getAvgStress(), UNIT_NONE);
+ }
+ if (session.getAverageCadence() != null) {
+ summaryData.add(CADENCE_AVG, session.getAverageCadence(), UNIT_SPM);
+ }
+ if (session.getMaxCadence() != null) {
+ summaryData.add(CADENCE_MAX, session.getMaxCadence(), UNIT_SPM);
+ }
+ if (session.getTotalAscent() != null) {
+ summaryData.add(ASCENT_DISTANCE, session.getTotalAscent(), UNIT_METERS);
+ }
+ if (session.getTotalDescent() != null) {
+ summaryData.add(DESCENT_DISTANCE, session.getTotalDescent(), UNIT_METERS);
+ }
+
+ if (session.getEnhancedAvgSpeed() != null) {
+ if (ActivityKind.isPaceActivity(activityKind)) {
+ summaryData.add(PACE_AVG_SECONDS_KM, Math.round((60 / (session.getEnhancedAvgSpeed() * 3.6)) * 60), UNIT_SECONDS);
+ } else {
+ summaryData.add(SPEED_AVG, Math.round((session.getEnhancedAvgSpeed() * 3600 / 1000) * 100.0) / 100.0, UNIT_KMPH);
+ }
+ }
+
+ if (session.getEnhancedMaxSpeed() != null) {
+ if (ActivityKind.isPaceActivity(activityKind)) {
+ summaryData.add(PACE_MAX, Math.round((60 / (session.getEnhancedMaxSpeed() * 3.6)) * 60), UNIT_SECONDS);
+ } else {
+ summaryData.add(SPEED_MAX, Math.round((session.getEnhancedMaxSpeed() * 3600 / 1000) * 100.0) / 100.0, UNIT_KMPH);
+ }
+ }
+
+ for (final FitTimeInZone fitTimeInZone : timesInZone) {
+ // Find the first time in zone for the session (assumes single-session)
+ if (fitTimeInZone.getReferenceMessage() != null && fitTimeInZone.getReferenceMessage() == 18) {
+ final Double[] timeInZones = fitTimeInZone.getTimeInZone();
+ if (timeInZones == null) {
+ continue;
+ }
+ final double totalTime = Arrays.stream(timeInZones).mapToDouble(Number::doubleValue).sum();
+ if (totalTime == 0) {
+ continue;
+ }
+ if (timeInZones[0] != null && timeInZones[0] == totalTime) {
+ // The total time is N/A, so do not add the section
+ continue;
+ }
+ final List<String> zoneOrder = Arrays.asList(HR_ZONE_NA, HR_ZONE_WARM_UP, HR_ZONE_EASY, HR_ZONE_AEROBIC, HR_ZONE_THRESHOLD, HR_ZONE_MAXIMUM);
+ final int[] zoneColors = new int[]{
+ 0,
+ context.getResources().getColor(R.color.hr_zone_warm_up_color),
+ context.getResources().getColor(R.color.hr_zone_easy_color),
+ context.getResources().getColor(R.color.hr_zone_aerobic_color),
+ context.getResources().getColor(R.color.hr_zone_threshold_color),
+ context.getResources().getColor(R.color.hr_zone_maximum_color),
+ };
+ for (int i = 0; i < zoneOrder.size(); i++) {
+ double timeInZone = timeInZones[i] != null ? Math.rint(timeInZones[i]) : 0;
+ summaryData.add(
+ zoneOrder.get(i),
+ new ActivitySummaryProgressEntry(
+ timeInZone,
+ UNIT_SECONDS,
+ (int) (100 * timeInZone / totalTime),
+ zoneColors[i]
+ )
+ );
+ }
+ break;
+ }
+ }
+
+ if (physiologicalMetrics != null) {
+ if (physiologicalMetrics.getAerobicEffect() != null) {
+ summaryData.add(TRAINING_EFFECT_AEROBIC, physiologicalMetrics.getAerobicEffect(), UNIT_NONE, true);
+ }
+ if (physiologicalMetrics.getAnaerobicEffect() != null) {
+ summaryData.add(TRAINING_EFFECT_ANAEROBIC, physiologicalMetrics.getAnaerobicEffect(), UNIT_NONE, true);
+ }
+ if (physiologicalMetrics.getMetMax() != null) {
+ summaryData.add(MAXIMUM_OXYGEN_UPTAKE, physiologicalMetrics.getMetMax().floatValue() * 3.5f, UNIT_ML_KG_MIN);
+ }
+ if (physiologicalMetrics.getRecoveryTime() != null) {
+ summaryData.add(RECOVERY_TIME, physiologicalMetrics.getRecoveryTime() * 60, UNIT_SECONDS);
+ }
+ if (physiologicalMetrics.getLactateThresholdHeartRate() != null) {
+ summaryData.add(LACTATE_THRESHOLD_HR, physiologicalMetrics.getLactateThresholdHeartRate(), UNIT_BPM);
+ }
+ }
+
+ if (!sets.isEmpty()) {
+ final boolean anyReps = sets.stream().anyMatch(s -> s.getRepetitions() != null);
+ final boolean anyWeight = sets.stream().anyMatch(s -> s.getWeight() != null);
+
+ final List<ActivitySummaryValue> header = new LinkedList<>();
+ header.add(new ActivitySummaryValue("set"));
+ header.add(new ActivitySummaryValue("workout_set_reps"));
+ header.add(new ActivitySummaryValue("menuitem_weight"));
+ header.add(new ActivitySummaryValue("activity_detail_duration_label"));
+
+ summaryData.add(
+ "sets_header",
+ new ActivitySummaryTableRowEntry(
+ SETS,
+ header,
+ true,
+ true
+ )
+ );
+
+ int i = 1;
+ for (final FitSet set : sets) {
+ if (set.getSetType() != null && set.getDuration() != null && set.getSetType() == 1) {
+ final List<ActivitySummaryValue> columns = new LinkedList<>();
+ columns.add(new ActivitySummaryValue(i, UNIT_NONE));
+
+ if (set.getRepetitions() != null) {
+ columns.add(new ActivitySummaryValue(String.valueOf(set.getRepetitions())));
+ } else {
+ columns.add(new ActivitySummaryValue("stats_empty_value"));
+ }
+
+ if (set.getWeight() != null) {
+ columns.add(new ActivitySummaryValue(set.getWeight(), UNIT_KG));
+ } else {
+ columns.add(new ActivitySummaryValue("stats_empty_value"));
+ }
+
+ columns.add(new ActivitySummaryValue(set.getDuration().longValue(), UNIT_SECONDS));
+
+ summaryData.add(
+ "set_" + i,
+ new ActivitySummaryTableRowEntry(
+ SETS,
+ columns,
+ false,
+ true
+ )
+ );
+ i++;
+ }
+ }
+ }
+
+ summaryData.add(
+ INTERNAL_HAS_GPS,
+ String.valueOf(activityPoints.stream().anyMatch(p -> p.getLocation() != null))
+ );
+
+ summary.setSummaryData(summaryData.toString());
+ }
+
+ private static ActivityKind getActivityKind(final Integer sport, final Integer subsport) {
+ final Optional<GarminSport> garminSport = GarminSport.fromCodes(sport, subsport);
+ if (garminSport.isPresent()) {
+ return garminSport.get().getActivityKind();
+ } else {
+ LOG.warn("Unknown garmin sport {}/{}", sport, subsport);
+
+ final Optional<GarminSport> optGarminSportFallback = GarminSport.fromCodes(sport, 0);
+ if (!optGarminSportFallback.isEmpty()) {
+ return optGarminSportFallback.get().getActivityKind();
+ }
+ }
+
+ return ActivityKind.UNKNOWN;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/enduro/GarminEnduro3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/enduro/GarminEnduro3Coordinator.java
new file mode 100644
index 0000000..fee46b4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/enduro/GarminEnduro3Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.enduro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminEnduro3Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Enduro 3$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_enduro_3;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/epix/GarminEpixProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/epix/GarminEpixProCoordinator.java
new file mode 100644
index 0000000..bfa5b45
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/epix/GarminEpixProCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.epix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminEpixProCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^EPIX PRO - \\d+mm$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_epix_pro;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5Coordinator.java
new file mode 100644
index 0000000..8d8c2ed
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix5Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 5$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_5;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5PlusCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5PlusCoordinator.java
new file mode 100644
index 0000000..d1e50d0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5PlusCoordinator.java
@@ -0,0 +1,24 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix5PlusCoordinator extends GarminCoordinator {
+ @Override
+ public boolean isExperimental() {
+ // https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/3963
+ return true;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 5 Plus$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_5_plus;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5XPlusCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5XPlusCoordinator.java
new file mode 100644
index 0000000..537f98b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix5XPlusCoordinator.java
@@ -0,0 +1,24 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix5XPlusCoordinator extends GarminCoordinator {
+ @Override
+ public boolean isExperimental() {
+ // https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/3963
+ return true;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 5X Plus$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_5x_plus;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6Coordinator.java
new file mode 100644
index 0000000..5fcdb5c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix6Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 6$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_6;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SProCoordinator.java
new file mode 100644
index 0000000..f6cb87c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SProCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix6SProCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 6S Pro$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_6s_pro;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SSapphireCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SSapphireCoordinator.java
new file mode 100644
index 0000000..4d59246
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SSapphireCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix6SSapphireCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 6S Sapphire$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_6s_sapphire;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SapphireCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SapphireCoordinator.java
new file mode 100644
index 0000000..106d1c9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix6SapphireCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix6SapphireCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 6 Sapphire$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_6_sapphire;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7Coordinator.java
new file mode 100644
index 0000000..f5cde59
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix7Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 7$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_7;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7ProCoordinator.java
new file mode 100644
index 0000000..92f07b8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7ProCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix7ProCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 7 Pro$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_7_pro;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7SCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7SCoordinator.java
new file mode 100644
index 0000000..3d89769
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7SCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix7SCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 7S$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_7s;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7XCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7XCoordinator.java
new file mode 100644
index 0000000..9b43ab2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix7XCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix7XCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 7X$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_7x;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix8Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix8Coordinator.java
new file mode 100644
index 0000000..f971ba5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/fenix/GarminFenix8Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminFenix8Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^fenix 8( - [\\d+]+mm)?$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_fenix_8;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner165Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner165Coordinator.java
new file mode 100644
index 0000000..f685a9f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner165Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner165Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 165$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_165;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner235Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner235Coordinator.java
new file mode 100644
index 0000000..8085671
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner235Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner235Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 235$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_235;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner245Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner245Coordinator.java
new file mode 100644
index 0000000..5da1f73
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner245Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+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;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner245MusicCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner245MusicCoordinator.java
new file mode 100644
index 0000000..1a8b8e9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner245MusicCoordinator.java
@@ -0,0 +1,24 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner245MusicCoordinator extends GarminCoordinator {
+ @Override
+ public boolean isExperimental() {
+ // https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/3986
+ return true;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 245 Music$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_245_music;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255Coordinator.java
new file mode 100644
index 0000000..c9e72cb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner255Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 255$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_255;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255MusicCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255MusicCoordinator.java
new file mode 100644
index 0000000..bb477f5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255MusicCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner255MusicCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 255 Music$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_255_music;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255SCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255SCoordinator.java
new file mode 100644
index 0000000..06979ed
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255SCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner255SCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 255S$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_255s;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255SMusicCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255SMusicCoordinator.java
new file mode 100644
index 0000000..02e7e7f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner255SMusicCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner255SMusicCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 255S Music$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_255s_music;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner265Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner265Coordinator.java
new file mode 100644
index 0000000..8677319
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner265Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner265Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 265$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_265;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner265SCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner265SCoordinator.java
new file mode 100644
index 0000000..a07a400
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner265SCoordinator.java
@@ -0,0 +1,19 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner265SCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ // #4131 - including variants of the name just in case
+ return Pattern.compile("^(Garmin )?Forerunner 265[sS]$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_265s;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner55Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner55Coordinator.java
new file mode 100644
index 0000000..090c9ae
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner55Coordinator.java
@@ -0,0 +1,34 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner55Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 55$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_55;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner620Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner620Coordinator.java
new file mode 100644
index 0000000..c679bb5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner620Coordinator.java
@@ -0,0 +1,34 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner620Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 620$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_620;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner955Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner955Coordinator.java
new file mode 100644
index 0000000..d80f722
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner955Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner955Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 955$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_955;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner965Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner965Coordinator.java
new file mode 100644
index 0000000..d25a9b1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/forerunner/GarminForerunner965Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminForerunner965Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Forerunner 965|UAT-4315$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_forerunner_965;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2Coordinator.java
new file mode 100644
index 0000000..56ab03a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminInstinct2Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Instinct 2$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_instinct_2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SCoordinator.java
new file mode 100644
index 0000000..020ac99
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct;
+
+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;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SSolarCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SSolarCoordinator.java
new file mode 100644
index 0000000..3911a1d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SSolarCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminInstinct2SSolarCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Instinct 2S Solar$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_instinct_2s_solar;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SolTacCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SolTacCoordinator.java
new file mode 100644
index 0000000..a6c02bf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SolTacCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminInstinct2SolTacCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Instinct 2 SolTac$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_instinct_2_soltac;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SolarCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SolarCoordinator.java
new file mode 100644
index 0000000..81e5334
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2SolarCoordinator.java
@@ -0,0 +1,19 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct;
+
+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;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2XSolarCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2XSolarCoordinator.java
new file mode 100644
index 0000000..268c821
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinct2XSolarCoordinator.java
@@ -0,0 +1,19 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminInstinct2XSolarCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ // Allow ending both with "Sol" (#3063) and "Solar" (reported on Matrix).
+ return Pattern.compile("^Instinct 2X Sol(ar)?$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_instinct_2x_solar;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctCoordinator.java
new file mode 100644
index 0000000..c756664
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminInstinctCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Instinct$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_instinct;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctCrossoverCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctCrossoverCoordinator.java
new file mode 100644
index 0000000..38e1225
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctCrossoverCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+import java.util.regex.Pattern;
+
+public class GarminInstinctCrossoverCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Instinct Crossover$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_instinct_crossover;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctSolarCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctSolarCoordinator.java
new file mode 100644
index 0000000..83b3d28
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/instinct/GarminInstinctSolarCoordinator.java
@@ -0,0 +1,24 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GarminInstinctSolarCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Instinct (Solar|Dual Power)$");
+ }
+
+ @Override
+ public int getCannedRepliesSlotCount(final GBDevice device) {
+ return 16;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_instinct_solar;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/swim/GarminSwim2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/swim/GarminSwim2Coordinator.java
new file mode 100644
index 0000000..54f7ff3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/swim/GarminSwim2Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.swim;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminSwim2Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Swim 2$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_swim_2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2Coordinator.java
new file mode 100644
index 0000000..8aaa8bb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVenu2Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Venu 2$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_venu_2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2PlusCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2PlusCoordinator.java
new file mode 100644
index 0000000..97d151f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2PlusCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVenu2PlusCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Venu 2 Plus$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_venu_2_plus;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2SCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2SCoordinator.java
new file mode 100644
index 0000000..805f4ed
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu2SCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVenu2SCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Venu 2S$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_venu_2s;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu3Coordinator.java
new file mode 100644
index 0000000..66edd79
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu3Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu;
+
+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_venu_3;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu3SCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu3SCoordinator.java
new file mode 100644
index 0000000..c103f09
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenu3SCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVenu3SCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Venu 3S|UAT-4261$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_venu_3s;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuCoordinator.java
new file mode 100644
index 0000000..ae6cd5d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVenuCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Venu$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_venu;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuSq2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuSq2Coordinator.java
new file mode 100644
index 0000000..b76e854
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuSq2Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVenuSq2Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Venu Sq 2$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_venu_sq_2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuSqCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuSqCoordinator.java
new file mode 100644
index 0000000..c4a2967
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/venu/GarminVenuSqCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVenuSqCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Venu Sq$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_venu_sq;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive3Coordinator.java
new file mode 100644
index 0000000..5179bed
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive3Coordinator.java
@@ -0,0 +1,20 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivoactive;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVivoActive3Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ // The report on matrix did not include a space, but let's make it
+ // optional just in case
+ return Pattern.compile("^v铆voactive *3$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_vivoactive_3;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive4Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive4Coordinator.java
new file mode 100644
index 0000000..fd37ad9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive4Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivoactive;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVivoActive4Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^v铆voactive 4$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_vivoactive_4;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive4SCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive4SCoordinator.java
new file mode 100644
index 0000000..afbb437
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive4SCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivoactive;
+
+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;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive5Coordinator.java
new file mode 100644
index 0000000..51fb392
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivoactive/GarminVivoActive5Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivoactive;
+
+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;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveHrCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveHrCoordinator.java
new file mode 100644
index 0000000..49b12e3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveHrCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivomove;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVivomoveHrCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^v铆vomove HR$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_vivomove_hr;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveStyleCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveStyleCoordinator.java
new file mode 100644
index 0000000..1dcb41b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveStyleCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.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;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveTrendCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveTrendCoordinator.java
new file mode 100644
index 0000000..4325598
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivomove/GarminVivomoveTrendCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivomove;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVivomoveTrendCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^v铆vomove Trend$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_vivomove_trend;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivosmart/GarminVivosmart5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivosmart/GarminVivosmart5Coordinator.java
new file mode 100644
index 0000000..1d41a55
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivosmart/GarminVivosmart5Coordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivosmart;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVivosmart5Coordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^v铆vosmart 5$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_vivosmart_5;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivosport/GarminVivosportCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivosport/GarminVivosportCoordinator.java
new file mode 100644
index 0000000..695fc84
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/watches/vivosport/GarminVivosportCoordinator.java
@@ -0,0 +1,18 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivosport;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
+
+public class GarminVivosportCoordinator extends GarminCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^v铆vosport$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_garmin_vivosport;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900Constants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900Constants.java
new file mode 100644
index 0000000..bfbdd07
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900Constants.java
@@ -0,0 +1,28 @@
+/*
+Copyright (C) 2024 enoint
+
+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 <http://www.gnu.org/licenses/>.
+*/
+
+package nodomain.freeyourgadget.gadgetbridge.devices.hama.fit6900;
+
+import java.util.UUID;
+
+public class HamaFit6900Constants {
+ public static final UUID UUID_SERVICE_RXTX = UUID.fromString("c3e6fea0-e966-1000-8000-be99c223df6a");
+ public static final UUID UUID_CHARACTERISTIC_TX = UUID.fromString("c3e6fea1-e966-1000-8000-be99c223df6a");
+ public static final UUID UUID_CHARACTERISTIC_RX = UUID.fromString("c3e6fea2-e966-1000-8000-be99c223df6a");
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900DeviceCoordinator.java
new file mode 100644
index 0000000..43aa75c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900DeviceCoordinator.java
@@ -0,0 +1,105 @@
+/*
+Copyright (C) 2024 enoint
+
+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 <http://www.gnu.org/licenses/>.
+*/
+
+package nodomain.freeyourgadget.gadgetbridge.devices.hama.fit6900;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+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.hama.fit6900.HamaFit6900DeviceSupport;
+
+public final class HamaFit6900DeviceCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Fit6900$");
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 5;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_allow_accept_reject_calls, // reject only
+ R.xml.devicesettings_camera_remote,
+ R.xml.devicesettings_find_phone,
+ R.xml.devicesettings_liftwrist_display_no_on,
+ R.xml.devicesettings_notifications_enable,
+ R.xml.devicesettings_timeformat,
+ R.xml.devicesettings_transliteration,
+ R.xml.devicesettings_donotdisturb_no_auto,
+ R.xml.devicesettings_autoheartrate,
+ R.xml.devicesettings_hydration_reminder
+ };
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(GBDevice device) {
+ return new String[]{
+ "auto",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "sv_SE"
+ };
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Hama";
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return HamaFit6900DeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_hama_fit6900;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/Message.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/Message.java
new file mode 100644
index 0000000..49cbfba
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/Message.java
@@ -0,0 +1,440 @@
+/*
+Copyright (C) 2024 enoint
+
+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 <http://www.gnu.org/licenses/>.
+*/
+
+package nodomain.freeyourgadget.gadgetbridge.devices.hama.fit6900;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+public final class Message {
+ private static byte encodeBoolean(boolean value) {
+ return (byte) ((value) ? 1 : 0);
+ }
+
+ private static void encodeInt16(byte[] data, int offset, int value) {
+ data[offset + 0] = (byte) (value & 0xFF);
+ data[offset + 1] = (byte) ((value >> 8) & 0xFF);
+ }
+
+ private static void encodeInt32(byte[] data, int offset, int value) {
+ data[offset + 0] = (byte) (value & 0xFF);
+ data[offset + 1] = (byte) ((value >> 8) & 0xFF);
+ data[offset + 2] = (byte) ((value >> 16) & 0xFF);
+ data[offset + 3] = (byte) ((value >> 24) & 0xFF);
+ }
+
+ private static void encodeInt32Goal(byte[] data, int offset, int value) {
+ encodeInt32(data, offset, value);
+ data[offset + 3] = 0;
+ }
+
+ private static byte encodeTimeFormat(TimeFormat tf) {
+ return (byte) ((tf == TimeFormat.Format12H) ? 1 : 0);
+ }
+
+ public static int decodeInt32(byte[] data, int offset) {
+ return ((data[offset] & 0xFF) << 24) | ((data[offset + 1] & 0xFF) << 16) | ((data[offset + 2] & 0xFF) << 8) | (data[offset + 3] & 0xFF);
+ }
+
+ private static byte[] byteArrayAdd(byte[] b1, byte[] b2) {
+ if (b2 == null || b2.length == 0)
+ return b1;
+
+ byte[] result = new byte[b1.length + b2.length];
+ System.arraycopy(b1, 0, result, 0, b1.length);
+ System.arraycopy(b2, 0, result, b1.length, b2.length);
+
+ return result;
+ }
+
+ public static int calculateCrc(byte[] data) {
+ int crc = 0xFF;
+ for (byte b : data) {
+ crc ^= b & 0xFF;
+ for (int k = 0; k < 8; k++) {
+ if ((crc & 1) != 0) {
+ crc = (crc >> 1) ^ 184;
+ } else {
+ crc = (crc >> 1);
+ }
+ }
+ }
+ return crc;
+ }
+
+ private static final byte HEADER_MAGIC_NUMBER = (byte) 186;
+ private static final byte PROTOCOL_VERSION_0 = (byte) 0;
+
+ private static byte[] encodeMessage(byte[] commandData) {
+ // msgType | 1: single message or end of multi-part message, 3: one part of a multi-part message
+ byte msgType = 1;
+ byte unknown1 = 0; // 1 bit
+ byte unknown2 = 0; // 4 bits
+ int length = commandData.length;
+ int crc = calculateCrc(commandData);
+ int msgCounter = 0; // returned with the response
+
+ byte[] header = new byte[8];
+ header[0] = HEADER_MAGIC_NUMBER;
+ header[1] = (byte) ((msgType << 5) | (unknown1 << 4) | unknown2);
+ header[2] = (byte) ((length >> 8) & 0xFF);
+ header[3] = (byte) (length & 0xFF);
+ header[4] = (byte) ((crc >> 8) & 0xFF);
+ header[5] = (byte) (crc & 0xFF);
+ header[6] = (byte) ((msgCounter >> 8) & 0xFF);
+ header[7] = (byte) (msgCounter & 0xFF);
+
+ return byteArrayAdd(header, commandData);
+ }
+
+
+ private static byte[] encodeCommand(byte cmd, byte cmdKey, byte[] argsData) {
+ int length = (argsData != null) ? argsData.length : 0;
+
+ byte[] header = new byte[5];
+ header[0] = cmd;
+ header[1] = PROTOCOL_VERSION_0;
+ header[2] = cmdKey;
+ header[3] = (byte) ((length >> 8) & 1);
+ header[4] = (byte) (length & 0xFF);
+
+ return byteArrayAdd(header, argsData);
+ }
+
+ public static byte[] encodeCommandMessage(int cmd, int cmdKey, byte[] cmdArgsData) {
+ assert (cmd > 0);
+ assert (cmd <= 0xFF);
+ assert (cmdKey > 0);
+ assert (cmdKey <= 0xFF);
+ return encodeMessage(encodeCommand((byte) cmd, (byte) cmdKey, cmdArgsData));
+ }
+
+ public static class CommandMessage { // decoded message
+ public byte msgType;
+
+ public int cmd;
+ public int key;
+ public byte[] cmdArgs;
+ }
+
+
+ public static CommandMessage decodeCommandMessage(byte[] data) {
+ final int MESSAGE_HEADER_SIZE = 8;
+ final int COMMAND_HEADER_SIZE = 5;
+
+ if (data[0] != HEADER_MAGIC_NUMBER) {
+ return null;
+ }
+
+ byte messageType = (byte) (data[1] >> 5);
+ int messageLength = ((data[2] & 0xFF) << 8) | (data[3] & 0xFF);
+ int crcReceived = ((data[4] & 0xFF) << 8) | (data[5] & 0xFF);
+ //int msgCounter = ((data[6] & 0xFF) << 8) | (data[7] & 0xFF);
+
+ if (data.length != MESSAGE_HEADER_SIZE + messageLength) {
+ return null;
+ }
+
+ {
+ byte[] commandData = new byte[messageLength];
+ System.arraycopy(data, MESSAGE_HEADER_SIZE, commandData, 0, messageLength);
+
+ int crc = calculateCrc(commandData);
+ if (crc != crcReceived) {
+ return null;
+ }
+ }
+
+ int cmd = data[MESSAGE_HEADER_SIZE + 0] & 0xFF;
+ int version = data[MESSAGE_HEADER_SIZE + 1] & 0xFF;
+ int key = data[MESSAGE_HEADER_SIZE + 2] & 0xFF;
+ int commandLength = ((data[MESSAGE_HEADER_SIZE + 3] & 1) << 8) | (data[MESSAGE_HEADER_SIZE + 4] & 0xFF);
+
+ if (version != PROTOCOL_VERSION_0) {
+ return null;
+ }
+
+ if (data.length != MESSAGE_HEADER_SIZE + COMMAND_HEADER_SIZE + commandLength) {
+ return null;
+ }
+
+ byte[] commandArgs = new byte[commandLength];
+ System.arraycopy(data, MESSAGE_HEADER_SIZE + COMMAND_HEADER_SIZE, commandArgs, 0, commandLength);
+
+ CommandMessage result = new CommandMessage();
+ result.msgType = messageType;
+ result.cmd = cmd;
+ result.key = key;
+ result.cmdArgs = commandArgs;
+
+ return result;
+ }
+
+ public static byte[] encodeGetFirmwareVersion() {
+ return encodeCommandMessage(1, 18, null);
+ }
+
+ public static byte[] encodeGetBatteryStatus() {
+ return encodeCommandMessage(4, 64, null);
+ }
+
+ private static void encodeDateTime(byte[] data, int dataOffset, int year, int month, int day, int hour, int minute, int second) {
+ data[dataOffset + 0] = (byte) ((year % 100) & 0xFF);
+ data[dataOffset + 1] = (byte) (month & 0xFF);
+ data[dataOffset + 2] = (byte) (day & 0xFF);
+ data[dataOffset + 3] = (byte) (hour & 0xFF);
+ data[dataOffset + 4] = (byte) (minute & 0xFF);
+ data[dataOffset + 5] = (byte) (second & 0xFF);
+ }
+
+ public enum TimeFormat {
+ Format12H,
+ Format24H
+ }
+
+ public static byte[] encodeSetDateTime(Calendar dt, TimeFormat timeFormat) {
+ byte[] args = new byte[7];
+ encodeDateTime(args, 0, dt.get(Calendar.YEAR), dt.get(Calendar.MONTH) + 1,
+ dt.get(Calendar.DAY_OF_MONTH), dt.get(Calendar.HOUR_OF_DAY), dt.get(Calendar.MINUTE), dt.get(Calendar.SECOND));
+ // time format value is optional. shorter message is also accepted
+ args[6] = encodeTimeFormat(timeFormat);
+ return encodeCommandMessage(2, 32, args);
+ }
+
+ public enum Gender {
+ FEMALE,
+ MALE
+ }
+
+ public static byte[] encodeSetUserInfo(Gender gender, int age, int heightCm, int weightKg, int stepsGoal) {
+ byte[] args = new byte[8];
+ args[0] = (byte) ((gender == Gender.MALE) ? 1 : 0);
+ args[1] = (byte) (age & 0xFF);
+ args[2] = (byte) (heightCm & 0xFF);
+ args[3] = (byte) (weightKg & 0xFF);
+ encodeInt32Goal(args, 4, stepsGoal);
+
+ return encodeCommandMessage(2, 35, args);
+ }
+
+ public static byte[] encodeFindDevice() {
+ return encodeCommandMessage(5, 80, null);
+ }
+
+ public enum NotificationType {
+ INCOMING_CALL(0), // shows popup with hang up button
+
+ SMS(1),
+ MQQ(2),
+ WEIXIN(3),
+ FACEBOOK(4),
+ TWITTER(6),
+ WHATSAPP(7),
+ INSTAGRAM(8),
+ LINKEDIN(9),
+
+ CALL_REJECT(15), // closes INCOMING_CALL notification
+ CALL_ACCEPT(16), // closes INCOMING call notification
+
+ UNKNOWN(255);
+
+ private final byte value;
+
+ NotificationType(final int value) {
+ this.value = (byte) value;
+ }
+
+ public byte getValue() {
+ return this.value;
+ }
+ }
+
+ public static byte[] encodeShowNotification(NotificationType type, String text) {
+ final int TEXT_LENGTH_MAX = 64;
+
+ text = text.trim();
+ if (text.length() > TEXT_LENGTH_MAX) {
+ text = StringUtils.truncate(text, TEXT_LENGTH_MAX);
+ text = text.trim(); // trim again so text is centered on screen
+ }
+
+ byte[] args = byteArrayAdd(new byte[]{type.getValue()}, text.getBytes(StandardCharsets.UTF_16LE));
+ return encodeCommandMessage(6, 96, args);
+ }
+
+ public static byte[] encodeSetAlarms(ArrayList<? extends Alarm> alarms) {
+ final int ENTRY_COUNT = 5;
+ final int ENTRY_SIZE = 5;
+
+ byte[] args = new byte[ENTRY_COUNT * ENTRY_SIZE];
+ Arrays.fill(args, (byte) 0);
+
+ int offset = 0;
+ for (Alarm alarm : alarms) {
+ // When all properties of an alarm are 0, it will not be listed in the watch UI.
+ // Disabled alarms with non-0 properties will be shown in disabled state.
+ // Show only enabled:
+ if (alarm.getEnabled() && !alarm.getUnused()) {
+ args[offset + 0] = (byte) alarm.getHour();
+ args[offset + 1] = (byte) alarm.getMinute();
+ args[offset + 2] = (byte) alarm.getRepetition();
+ args[offset + 3] = (byte) 1;
+ args[offset + 4] = encodeBoolean(alarm.getEnabled());
+
+ offset += ENTRY_SIZE;
+ }
+ }
+
+ return encodeCommandMessage(2, 33, args);
+ }
+
+ private static final Map<String, Integer> LANGUAGES = new HashMap<String, Integer>() {{
+ put("en", 1);
+ put("es", 3);
+ put("de", 4);
+ put("it", 5);
+ put("fr", 6);
+ put("sv", 18);
+
+ put("ru", 2);
+ put("pt", 7);
+ put("pl", 8);
+ put("nl", 9);
+ put("el", 10);
+ put("tr", 11);
+ put("ro", 12);
+ put("ja", 13);
+ put("he", 15);
+ put("da", 16);
+ put("sr", 17);
+ put("cs", 19);
+ put("sk", 20);
+ put("hu", 21);
+ put("ar", 22);
+ put("bg", 23);
+ put("th", 24);
+ put("uk", 25);
+ put("fi", 26);
+ put("nb", 27);
+ put("ko", 28);
+ put("id", 29);
+ put("lv", 30);
+ put("lt", 31);
+ put("et", 32);
+ put("my", 33);
+ put("vi", 34);
+ put("hr", 35);
+ }};
+
+ private static int resolveLanguageId(String language_, String country_) {
+ String language = language_.toLowerCase();
+ String country = country_.toLowerCase();
+
+ if (language.equals("zh")) {
+ switch (country) {
+ case "cn":
+ return 0;
+ case "tw":
+ case "hk":
+ case "mo":
+ return 14;
+ }
+ } else if (language.equals("pt") && country.equals("br")) {
+ return 36;
+ } else {
+ Integer languageId = LANGUAGES.get(language);
+ if (languageId != null)
+ return languageId;
+ }
+
+ return LANGUAGES.get("en");
+ }
+
+ public static byte[] encodeSetSystemData(String lang, String country, TimeFormat timeFormat) {
+ byte[] args = new byte[4];
+ args[0] = (byte) resolveLanguageId(lang, country);
+ args[1] = encodeTimeFormat(timeFormat);
+ args[2] = (byte) 60; // screen. unclear
+ args[3] = (byte) 0; // pair. 1 has no effect
+
+ return encodeCommandMessage(2, 39, args);
+ }
+
+ public static byte[] encodeSetDoNotDisturb(boolean enable, int startHour, int startMinute, int endHour, int endMinute) {
+ byte[] args = new byte[5];
+ args[0] = encodeBoolean(enable);
+ args[1] = (byte) startHour;
+ args[2] = (byte) startMinute;
+ args[3] = (byte) endHour;
+ args[4] = (byte) endMinute;
+
+ return encodeCommandMessage(6, 100, args);
+ }
+
+ public static byte[] encodeSetUnit(boolean isMetric) {
+ byte[] args = new byte[2];
+ args[0] = args[1] = (byte) ((isMetric) ? 0 : 1); // 0: metric, 1: imperial
+ return encodeCommandMessage(2, 1, args);
+ }
+
+ public static byte[] encodeSetLiftWristDisplayOn(boolean enable) {
+ byte[] args = new byte[3];
+ args[0] = (byte) 1; // hand
+ args[1] = encodeBoolean(enable); // raise
+ args[2] = encodeBoolean(enable); // wrist
+
+ return encodeCommandMessage(4, 74, args);
+ }
+
+ public static byte[] encodeSetAutoHeartRate(boolean enable, int startHour, int startMinute, int endHour, int endMinute, int intervalMinutes) {
+ byte[] args = new byte[7];
+ args[0] = encodeBoolean(enable);
+ args[1] = (byte) startHour;
+ args[2] = (byte) startMinute;
+ args[3] = (byte) endHour;
+ args[4] = (byte) endMinute;
+ encodeInt16(args, 5, intervalMinutes);
+
+ return encodeCommandMessage(9, 146, args);
+ }
+
+ public static byte[] encodeSetHydrationReminder(boolean enable, int startHour, int startMinute, int endHour, int endMinute, int intervalMinutes) {
+ byte[] args = new byte[8];
+ args[0] = encodeBoolean(enable);
+ args[1] = (byte) startHour;
+ args[2] = (byte) startMinute;
+ args[3] = (byte) endHour;
+ args[4] = (byte) endMinute;
+ args[5] = (byte) 0x7F; // repeat: bitmask for days of week, bit 0=Monday
+ encodeInt16(args, 6, intervalMinutes);
+
+ return encodeCommandMessage(2, 40, args);
+ }
+
+ public static byte[] encodeFactoryReset() {
+ return encodeCommandMessage(2, 199, null);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/EXRIZUK8Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/EXRIZUK8Coordinator.java
new file mode 100644
index 0000000..ce7784f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/EXRIZUK8Coordinator.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2017-2024 Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo,
+ Quallenauge
+
+ 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.devices.hplus;
+
+/*
+* @author Quallenauge <Hamsi2k@freenet.de>
+*/
+
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+/**
+ * Pseudo Coordinator for the EXRIZU K8, a sub type of the HPLUS devices
+ */
+public class EXRIZUK8Coordinator extends HPlusCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("iRun .*");
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Exrizu";
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_exrizu_k8;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusConstants.java
new file mode 100644
index 0000000..bf94879
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusConstants.java
@@ -0,0 +1,191 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Joa虄o
+ Paulo Barraca, Lesur Frederic
+
+ 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.devices.hplus;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+public final class HPlusConstants {
+
+ public static final UUID UUID_CHARACTERISTIC_CONTROL = UUID.fromString("14702856-620a-3973-7c78-9cfff0876abd");
+ public static final UUID UUID_CHARACTERISTIC_MEASURE = UUID.fromString("14702853-620a-3973-7c78-9cfff0876abd");
+ public static final UUID UUID_SERVICE_HP = UUID.fromString("14701820-620a-3973-7c78-9cfff0876abd");
+
+ public static final byte ARG_WRIST_LEFT = 0; //Guess...
+ public static final byte ARG_WRIST_RIGHT = 1; //Guess...
+
+ public static final byte ARG_LANGUAGE_CN = 1;
+ public static final byte ARG_LANGUAGE_EN = 2;
+
+ public static final byte ARG_TIMEMODE_24H = 1;
+ public static final byte ARG_TIMEMODE_12H = 0;
+
+ public static final byte ARG_UNIT_METRIC = 0;
+ public static final byte ARG_UNIT_IMPERIAL = 1;
+
+ public static final byte ARG_GENDER_MALE = 0;
+ public static final byte ARG_GENDER_FEMALE = 1;
+
+ public static final byte ARG_HEARTRATE_MEASURE_ON = 11;
+ public static final byte ARG_HEARTRATE_MEASURE_OFF = 22;
+
+ public static final byte ARG_HEARTRATE_ALLDAY_ON = 0x0A;
+ public static final byte ARG_HEARTRATE_ALLDAY_OFF = (byte) 0xff;
+
+ public static final byte INCOMING_CALL_STATE_DISABLED_THRESHOLD = 0x7B;
+ public static final byte INCOMING_CALL_STATE_ENABLED = (byte) 0xAA;
+
+ public static final byte ARG_ALARM_DISABLE = (byte) -1;
+
+ public static final byte[] CMD_SET_PREF_START = new byte[]{0x4f, 0x5a};
+ public static final byte[] CMD_SET_PREF_START1 = new byte[]{0x4d};
+ //public static final byte CMD_SET_ALARM = 0x4c; Unknown
+ public static final byte CMD_SET_ALARM = 0x0c;
+ public static final byte CMD_SET_LANGUAGE = 0x22;
+ public static final byte CMD_SET_TIMEMODE = 0x47;
+ public static final byte CMD_SET_UNITS = 0x48;
+ public static final byte CMD_SET_GENDER = 0x2d;
+ public static final byte CMD_SET_DATE = 0x08;
+ public static final byte CMD_SET_TIME = 0x09;
+ public static final byte CMD_SET_WEEK = 0x2a;
+ public static final byte CMD_SET_PREF_SIT = 0x1e;
+ public static final byte CMD_SET_WEIGHT = 0x05;
+ public static final byte CMD_HEIGHT = 0x04;
+ public static final byte CMD_SET_AGE = 0x2c;
+ public static final byte CMD_SET_GOAL = 0x26;
+ public static final byte CMD_SET_SCREENTIME = 0x0b;
+ public static final byte CMD_SET_BLOOD = 0x4e; //??
+
+ public static final byte CMD_SET_FINDME = 0x0a;
+ public static final byte ARG_FINDME_ON = 0x01;
+ public static final byte ARG_FINDME_OFF = 0x02;
+
+ public static final byte CMD_SET_END = 0x4f;
+ public static final byte CMD_SET_INCOMING_CALL_NUMBER = 0x23;
+ public static final byte CMD_SET_ALLDAY_HRM = 0x35;
+ public static final byte CMD_ACTION_INCOMING_CALL = 0x41;
+ public static final byte CMD_SET_CONF_END = 0x4f;
+ public static final byte CMD_SET_PREFS = 0x50;
+ public static final byte CMD_SET_SIT_INTERVAL = 0x51;
+ public static final byte CMD_SET_HEARTRATE_STATE = 0x32;
+ public static final byte CMD_SET_WEATHER_STATE = 0x5f;
+
+ //GET messages
+ public static final byte CMD_GET_VERSION = 0x17;
+ public static final byte CMD_GET_ACTIVE_DAY = 0x27;
+ public static final byte CMD_GET_DAY_DATA = 0x15;
+ public static final byte CMD_GET_SLEEP = 0x19;
+ public static final byte CMD_GET_CURR_DATA = 0x16;
+ public static final byte CMD_GET_DEVICE_ID = 0x24;
+
+ public static final byte CMD_ACTION_INCOMING_SOCIAL = 0x31;
+ //public static final byte COMMAND_ACTION_INCOMING_SMS = 0x40; //Unknown
+ public static final byte CMD_ACTION_DISPLAY_TEXT = 0x43;
+
+ public static final byte CMD_ACTION_DISPLAY_TEXT_NAME = 0x3F;
+ public static final byte CMD_ACTION_DISPLAY_TEXT_NAME_CN = 0x3E; //Text in GB2312?
+ public static final byte[] CMD_ACTION_HELLO = new byte[]{0x01, 0x00};
+ public static final byte CMD_SHUTDOWN = 0x5B;
+ public static final byte ARG_SHUTDOWN_EN = 0x5A;
+
+ public static final byte CMD_FACTORY_RESET = -74;
+ public static final byte ARG_FACTORY_RESET_EN = 0x5A;
+
+ public static final byte CMD_SET_INCOMING_MESSAGE = 0x07;
+ public static final byte CMD_SET_INCOMING_CALL = 0x06;
+ public static final byte ARG_INCOMING_CALL = (byte) -86;
+ public static final byte ARG_INCOMING_MESSAGE = (byte) -86;
+
+ //Incoming Messages
+ public static final byte DATA_STATS = 0x33;
+ public static final byte DATA_STEPS = 0x36;
+ public static final byte DATA_DAY_SUMMARY = 0x38;
+ public static final byte DATA_DAY_SUMMARY_ALT = 0x39;
+ public static final byte DATA_SLEEP = 0x1A;
+ public static final byte DATA_VERSION = 0x18;
+ public static final byte DATA_VERSION1 = 0x2E;
+ public static final byte DATA_DAY_UNKNOWN = 0x52; //To be defined
+ public static final byte DATA_UNKNOWN = 0x4d;
+
+ public static final String PREF_HPLUS_SCREENTIME = "hplus_screentime";
+ public static final String PREF_HPLUS_ALLDAYHR = "hplus_alldayhr";
+ public static final String PREF_HPLUS_SIT_START_TIME = "hplus_sit_start_time";
+ public static final String PREF_HPLUS_SIT_END_TIME = "hplus_sit_end_time";
+ public static final String PREF_HPLUS_UNICODE = "hplus_unicode";
+ public static final String PREF_HPLUS_DISPLAY_NOTIFICATION_ICON = "hplus_display_notification_icon";
+ public static final String PREF_HPLUS_NOTIFICATION_LINES = "hplus_notification_lines";
+
+ public static final Map<Character, byte[]> transliterateMap = new HashMap<Character, byte[]>() {
+ {
+ //These are missing
+ put('贸', new byte[]{(byte) 111});
+ put('脫', new byte[]{(byte) 79});
+ put('铆', new byte[]{(byte) 105});
+ put('脥', new byte[]{(byte) 73});
+ put('煤', new byte[]{(byte) 117});
+ put('脷', new byte[]{(byte) 85});
+
+ //These mostly belong to the extended ASCII table
+ put('脟', new byte[]{(byte) 128});
+ put('眉', new byte[]{(byte) 129});
+ put('茅', new byte[]{(byte) 130});
+ put('芒', new byte[]{(byte) 131});
+ put('盲', new byte[]{(byte) 132});
+ put('脿', new byte[]{(byte) 133});
+ put('茫', new byte[]{(byte) 134});
+ put('莽', new byte[]{(byte) 135});
+ put('锚', new byte[]{(byte) 136});
+ put('毛', new byte[]{(byte) 137});
+ put('脧', new byte[]{(byte) 139});
+ put('猫', new byte[]{(byte) 138});
+ put('脦', new byte[]{(byte) 140});
+ put('脤', new byte[]{(byte) 141});
+ put('脙', new byte[]{(byte) 142});
+ put('脛', new byte[]{(byte) 143});
+ put('脡', new byte[]{(byte) 144});
+ put('忙', new byte[]{(byte) 145});
+ put('脝', new byte[]{(byte) 146});
+ put('么', new byte[]{(byte) 147});
+ put('枚', new byte[]{(byte) 148});
+ put('貌', new byte[]{(byte) 149});
+ put('没', new byte[]{(byte) 150});
+ put('霉', new byte[]{(byte) 151});
+ put('每', new byte[]{(byte) 152});
+ put('脰', new byte[]{(byte) 153});
+ put('脺', new byte[]{(byte) 154});
+ put('垄', new byte[]{(byte) 155});
+ put('拢', new byte[]{(byte) 156});
+ put('楼', new byte[]{(byte) 157});
+ put('茠', new byte[]{(byte) 159});
+ put('谩', new byte[]{(byte) 160});
+ put('帽', new byte[]{(byte) 164});
+ put('脩', new byte[]{(byte) 165});
+ put('陋', new byte[]{(byte) 166});
+ put('潞', new byte[]{(byte) 167});
+ put('驴', new byte[]{(byte) 168});
+ put('卢', new byte[]{(byte) 170});
+ put('陆', new byte[]{(byte) 171});
+ put('录', new byte[]{(byte) 172});
+ put('隆', new byte[]{(byte) 173});
+ put('芦', new byte[]{(byte) 174});
+ put('禄', new byte[]{(byte) 175});
+ put('掳', new byte[]{(byte) 0xa1, (byte) 0xe3});
+ }
+ };
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusCoordinator.java
new file mode 100644
index 0000000..5e06514
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusCoordinator.java
@@ -0,0 +1,273 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien
+ Gaignon, Daniel Dakhno, Daniele Gobbetti, Joa虄o Paulo Barraca, Jos茅 Rebelo,
+ Lesur Frederic, Petr Van臎k
+
+ 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.devices.hplus;
+
+import android.bluetooth.le.ScanFilter;
+import android.content.SharedPreferences;
+import android.os.ParcelUuid;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.HPlusHealthActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.hplus.HPlusSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+import static nodomain.freeyourgadget.gadgetbridge.GBApplication.getContext;
+
+public class HPlusCoordinator extends AbstractBLEDeviceCoordinator {
+ protected static final Logger LOG = LoggerFactory.getLogger(HPlusCoordinator.class);
+ protected static Prefs prefs = GBApplication.getPrefs();
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid hpService = new ParcelUuid(HPlusConstants.UUID_SERVICE_HP);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(hpService).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("HPLUS.*");
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new HPlusHealthSampleProvider(device, session);
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 3; // FIXME - check the real value
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Zeblaze";
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getHPlusHealthActivitySampleDao().queryBuilder();
+ qb.where(HPlusHealthActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ public static byte getLanguage(String address) {
+ String language = prefs.getString("language", "default");
+ Locale locale;
+
+ if (language.equals("default")) {
+ locale = Locale.getDefault();
+ } else {
+ locale = new Locale(language);
+ }
+
+ if (locale.getLanguage().equals(new Locale("cn").getLanguage())) {
+ return HPlusConstants.ARG_LANGUAGE_CN;
+ } else {
+ return HPlusConstants.ARG_LANGUAGE_EN;
+ }
+ }
+
+ public static byte getUnit(String address) {
+ String units = prefs.getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, getContext().getString(R.string.p_unit_metric));
+
+ if (units.equals(getContext().getString(R.string.p_unit_metric))) {
+ return HPlusConstants.ARG_UNIT_METRIC;
+ } else {
+ return HPlusConstants.ARG_UNIT_IMPERIAL;
+ }
+ }
+
+ public static byte getUserWeight() {
+ ActivityUser activityUser = new ActivityUser();
+
+ return (byte) (activityUser.getWeightKg() & 0xFF);
+ }
+
+ public static byte getUserHeight() {
+ ActivityUser activityUser = new ActivityUser();
+
+ return (byte) (activityUser.getHeightCm() & 0xFF);
+ }
+
+ public static byte getUserAge() {
+ ActivityUser activityUser = new ActivityUser();
+
+ return (byte) (activityUser.getAge() & 0xFF);
+ }
+
+ public static byte getUserGender() {
+ ActivityUser activityUser = new ActivityUser();
+
+ if (activityUser.getGender() == ActivityUser.GENDER_MALE)
+ return HPlusConstants.ARG_GENDER_MALE;
+
+ return HPlusConstants.ARG_GENDER_FEMALE;
+ }
+
+ public static int getGoal() {
+ ActivityUser activityUser = new ActivityUser();
+
+ return activityUser.getStepsGoal();
+ }
+
+ public static byte getSocial(String address) {
+ //TODO: Figure what this is. Returning the default value
+
+ return (byte) 255;
+ }
+
+ //FIXME: unused
+ public static byte getUserWrist(String deviceAddress) {
+ SharedPreferences sharedPreferences = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ String value = sharedPreferences.getString(DeviceSettingsPreferenceConst.PREF_WEARLOCATION, "left");
+
+ if ("left".equals(value)) {
+ return HPlusConstants.ARG_WRIST_LEFT;
+ } else {
+ return HPlusConstants.ARG_WRIST_RIGHT;
+ }
+ }
+
+ public static int getSITStartTime(String address) {
+ return prefs.getInt(HPlusConstants.PREF_HPLUS_SIT_START_TIME, 0);
+ }
+
+ public static int getSITEndTime(String address) {
+ return prefs.getInt(HPlusConstants.PREF_HPLUS_SIT_END_TIME, 0);
+ }
+
+ public static void setDisplayIncomingMessageIcon(String address, boolean state) {
+ SharedPreferences.Editor editor = prefs.getPreferences().edit();
+ editor.putBoolean(HPlusConstants.PREF_HPLUS_DISPLAY_NOTIFICATION_ICON + "_" + address, state);
+ editor.apply();
+ }
+
+ public static boolean getDisplayIncomingMessageIcon(String address) {
+ return (prefs.getBoolean(HPlusConstants.PREF_HPLUS_DISPLAY_NOTIFICATION_ICON + "_" + address, false));
+ }
+
+ public static void setUnicodeSupport(String address, boolean state) {
+ SharedPreferences.Editor editor = prefs.getPreferences().edit();
+ editor.putBoolean(HPlusConstants.PREF_HPLUS_UNICODE + "_" + address, state);
+ editor.apply();
+ }
+
+ public static boolean getUnicodeSupport(String address) {
+ return (prefs.getBoolean(HPlusConstants.PREF_HPLUS_UNICODE + "_" + address, false));
+ }
+
+ public static void setNotificationLinesNumber(String address, int lineNumber) {
+ SharedPreferences.Editor editor = prefs.getPreferences().edit();
+ editor.putInt(HPlusConstants.PREF_HPLUS_NOTIFICATION_LINES + "_" + address, lineNumber);
+ editor.apply();
+ }
+
+ public static int getNotificationLinesNumber(String address) {
+ return (prefs.getInt(HPlusConstants.PREF_HPLUS_NOTIFICATION_LINES + "_" + address, 5));
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ //R.xml.devicesettings_wearlocation, // disabled, since it is never used in code
+ R.xml.devicesettings_timeformat,
+ R.xml.devicesettings_hplus,
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return HPlusSupport.class;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_hplus;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_hplus;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_hplus_disabled;
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusHealthSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusHealthSampleProvider.java
new file mode 100644
index 0000000..17d8ef8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusHealthSampleProvider.java
@@ -0,0 +1,240 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Daniele Gobbetti, Joa虄o
+ Paulo Barraca
+
+ 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.devices.hplus;
+
+/*
+ * @author Jo茫o Paulo Barraca <jpbarraca@gmail.com>
+ */
+
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.GregorianCalendar;
+import java.util.List;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.HPlusHealthActivityOverlay;
+import nodomain.freeyourgadget.gadgetbridge.entities.HPlusHealthActivityOverlayDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HPlusHealthActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HPlusHealthActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.hplus.HPlusDataRecord;
+
+public class HPlusHealthSampleProvider extends AbstractSampleProvider<HPlusHealthActivitySample> {
+ public HPlusHealthSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ public ActivityKind normalizeType(int rawType) {
+ switch (rawType) {
+ case HPlusDataRecord.TYPE_DAY_SLOT:
+ case HPlusDataRecord.TYPE_DAY_SUMMARY:
+ case HPlusDataRecord.TYPE_REALTIME:
+ case HPlusDataRecord.TYPE_SLEEP:
+ case HPlusDataRecord.TYPE_UNKNOWN:
+ return ActivityKind.UNKNOWN;
+ default:
+ return ActivityKind.fromCode(rawType);
+ }
+ }
+
+ public int toRawActivityKind(ActivityKind activityKind) {
+ switch (activityKind) {
+ case DEEP_SLEEP:
+ case LIGHT_SLEEP:
+ return activityKind.getCode();
+ default:
+ return HPlusDataRecord.TYPE_DAY_SLOT;
+ }
+
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HPlusHealthActivitySampleDao.Properties.Timestamp;
+ }
+
+ @Override
+ public HPlusHealthActivitySample createActivitySample() {
+ return new HPlusHealthActivitySample();
+ }
+
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return null; // HPlusHealthActivitySampleDao.Properties.RawKind;
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / (float) 100.0;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HPlusHealthActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public AbstractDao<HPlusHealthActivitySample, ?> getSampleDao() {
+ return getSession().getHPlusHealthActivitySampleDao();
+ }
+
+ @NonNull
+ @Override
+ public List<HPlusHealthActivitySample> getAllActivitySamples(int timestamp_from, int timestamp_to) {
+ List<HPlusHealthActivitySample> samples = super.getGBActivitySamples(timestamp_from, timestamp_to);
+
+ Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ return Collections.emptyList();
+ }
+
+ QueryBuilder<HPlusHealthActivityOverlay> qb = getSession().getHPlusHealthActivityOverlayDao().queryBuilder();
+
+ qb.where(HPlusHealthActivityOverlayDao.Properties.DeviceId.eq(dbDevice.getId()),
+ HPlusHealthActivityOverlayDao.Properties.TimestampFrom.ge(timestamp_from - 3600 * 24),
+ HPlusHealthActivityOverlayDao.Properties.TimestampTo.le(timestamp_to),
+ HPlusHealthActivityOverlayDao.Properties.TimestampTo.ge(timestamp_from));
+
+ List<HPlusHealthActivityOverlay> overlayRecords = qb.build().list();
+
+
+ //Apply Overlays
+ for (HPlusHealthActivityOverlay overlay : overlayRecords) {
+
+ //Create fake events to improve activity counters if there are no events around the overlay
+ //timestamp boundaries
+ //Insert one before, one at the beginning, one at the end, and one 1s after.
+ insertVirtualItem(samples, Math.max(overlay.getTimestampFrom() - 1, timestamp_from), overlay.getDeviceId(), overlay.getUserId());
+ insertVirtualItem(samples, Math.max(overlay.getTimestampFrom(), timestamp_from), overlay.getDeviceId(), overlay.getUserId());
+ insertVirtualItem(samples, Math.min(overlay.getTimestampTo() - 1, timestamp_to - 1), overlay.getDeviceId(), overlay.getUserId());
+ insertVirtualItem(samples, Math.min(overlay.getTimestampTo(), timestamp_to), overlay.getDeviceId(), overlay.getUserId());
+ }
+
+ Collections.sort(samples, new Comparator<HPlusHealthActivitySample>() {
+ public int compare(HPlusHealthActivitySample one, HPlusHealthActivitySample other) {
+ return one.getTimestamp() - other.getTimestamp();
+ }
+ });
+
+ //Apply Overlays
+ for (HPlusHealthActivityOverlay overlay : overlayRecords) {
+
+ long nonSleepTimeEnd = 0;
+ for (HPlusHealthActivitySample sample : samples) {
+ if (sample.getRawKind() == ActivityKind.NOT_WORN.getCode())
+ continue;
+
+ if (sample.getTimestamp() >= overlay.getTimestampFrom() && sample.getTimestamp() < overlay.getTimestampTo()) {
+ if (overlay.getRawKind() == ActivityKind.NOT_WORN.getCode() || overlay.getRawKind() == ActivityKind.LIGHT_SLEEP.getCode() || overlay.getRawKind() == ActivityKind.DEEP_SLEEP.getCode()) {
+ if (sample.getRawKind() == HPlusDataRecord.TYPE_DAY_SLOT && sample.getSteps() > 0) {
+ nonSleepTimeEnd = sample.getTimestamp() + 10 * 60; // 10 minutes
+ continue;
+ } else if (sample.getRawKind() == HPlusDataRecord.TYPE_REALTIME && sample.getTimestamp() <= nonSleepTimeEnd) {
+ continue;
+ }
+
+ if (overlay.getRawKind() == ActivityKind.NOT_WORN.getCode())
+ sample.setHeartRate(0);
+
+ if (sample.getRawKind() != ActivityKind.NOT_WORN.getCode())
+ sample.setRawKind(overlay.getRawKind());
+
+ sample.setRawIntensity(10);
+ }
+ }
+ }
+ }
+
+
+ //Fix Step counters
+ //Todays sample steps will come from the Day Slots messages
+ //Historical steps will be provided by Day Summaries messages
+ //This will allow both week and current day results to be consistent
+ Calendar today = GregorianCalendar.getInstance();
+ today.set(Calendar.HOUR_OF_DAY, 0);
+ today.set(Calendar.MINUTE, 0);
+ today.set(Calendar.SECOND, 0);
+ today.set(Calendar.MILLISECOND, 0);
+
+ int stepsTodayMax = 0;
+ int stepsTodayCount = 0;
+ HPlusHealthActivitySample lastSample = null;
+
+ for (HPlusHealthActivitySample sample : samples) {
+ if (sample.getTimestamp() >= today.getTimeInMillis() / 1000) {
+
+ /* Strategy is:
+ * Calculate max steps from realtime messages
+ * Calculate sum of steps from day 10 minute slot summaries
+ */
+
+ if (sample.getRawKind() == HPlusDataRecord.TYPE_REALTIME) {
+ stepsTodayMax = Math.max(stepsTodayMax, sample.getSteps());
+ } else if (sample.getRawKind() == HPlusDataRecord.TYPE_DAY_SLOT) {
+ stepsTodayCount += sample.getSteps();
+ }
+
+ sample.setSteps(ActivitySample.NOT_MEASURED);
+ lastSample = sample;
+ } else {
+ if (sample.getRawKind() != HPlusDataRecord.TYPE_DAY_SUMMARY) {
+ sample.setSteps(ActivitySample.NOT_MEASURED);
+ }
+ }
+ }
+
+ if (lastSample != null)
+ lastSample.setSteps(Math.max(stepsTodayCount, stepsTodayMax));
+
+ detachFromSession();
+
+ return samples;
+ }
+
+ private void insertVirtualItem(List<HPlusHealthActivitySample> samples, int timestamp, long deviceId, long userId) {
+ HPlusHealthActivitySample sample = new HPlusHealthActivitySample(
+ timestamp, // ts
+ deviceId,
+ userId, // User id
+ null, // Raw Data
+ ActivityKind.UNKNOWN.getCode(),
+ 1, // Intensity
+ ActivitySample.NOT_MEASURED, // Steps
+ ActivitySample.NOT_MEASURED, // HR
+ ActivitySample.NOT_MEASURED, // Distance
+ ActivitySample.NOT_MEASURED // Calories
+ );
+
+ sample.setProvider(this);
+ samples.add(sample);
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusWeatherCode.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusWeatherCode.java
new file mode 100644
index 0000000..e9216f3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/HPlusWeatherCode.java
@@ -0,0 +1,197 @@
+/* Copyright (C) 2020-2024 Lesur Frederic
+
+ 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.devices.hplus;
+
+public class HPlusWeatherCode {
+ // Weather code from https://github.com/heweather/WeatherIcon
+ public static final int SUNNY = 100;
+ public static final int CLOUDY = 101;
+ public static final int FEW_CLOUDS = 102;
+ public static final int PARTLY_CLOUDY = 103;
+ public static final int OVERCAST = 104;
+ public static final int CLEAR = 150;
+ public static final int PARTLY_CLOUDY_NIGHT = 153;
+ public static final int OVERCAST_NIGHT = 154;
+ public static final int SHOWER_RAIN = 300;
+ public static final int HEAVY_SHOWER_RAIN = 301;
+ public static final int THUNDERSHOWER = 302;
+ public static final int HEAVY_THUNDERSTORM = 303;
+ public static final int THUNDERSHOWER_WITH_HAIL = 304;
+ public static final int LIGHT_RAIN = 305;
+ public static final int MODERATE_RAIN = 306;
+ public static final int HEAVY_RAIN = 307;
+ public static final int EXTREME_RAIN = 308;
+ public static final int DRIZZLE_RAIN = 309;
+ public static final int STORM = 310;
+ public static final int HEAVY_STORM = 311;
+ public static final int SEVERE_STORM = 312;
+ public static final int FREEZING_RAIN = 313;
+ public static final int LIGHT_TO_MODERATE_RAIN = 314;
+ public static final int MODERATE_TO_HEAVY_RAIN = 315;
+ public static final int HEAVY_RAIN_TO_STORM = 316;
+ public static final int STORM_TO_HEAVY_STORM = 317;
+ public static final int HEAVY_TO_SEVERE_STORM = 318;
+ public static final int RAIN = 399;
+ public static final int SHOWER_RAIN_NIGHT = 350;
+ public static final int HEAVY_SHOWER_RAIN_NIGHT = 351;
+ public static final int LIGHT_SNOW = 400;
+ public static final int MODERATE_SNOW = 401;
+ public static final int HEAVY_SNOW = 402;
+ public static final int SNOWSTORM = 403;
+ public static final int SLEET = 404;
+ public static final int RAIN_AND_SNOW = 405;
+ public static final int SHOWER_SNOW = 406;
+ public static final int SNOW_FLURRY = 407;
+ public static final int LIGHT_TO_MODERATE_SNOW = 408;
+ public static final int MODERATE_TO_HEAVY_SNOW = 409;
+ public static final int HEAVY_SNOW_TO_SNOWSTORM = 410;
+ public static final int SNOW = 499;
+ public static final int SHOWER_SNOW_NIGHT = 456;
+ public static final int SNOW_FLURRY_NIGHT = 457;
+ public static final int MIST = 500;
+ public static final int FOGGY = 501;
+ public static final int HAZE = 502;
+ public static final int SAND = 503;
+ public static final int DUST = 504;
+ public static final int DUSTSTORM = 507;
+ public static final int SANDSTORM = 508;
+ public static final int DENSE_FOG = 509;
+ public static final int STRONG_FOG = 510;
+ public static final int MODERATE_HAZE = 511;
+ public static final int HEAVY_HAZE = 512;
+ public static final int SEVERE_HAZE = 513;
+ public static final int HEAVY_FOG = 514;
+ public static final int EXTRA_HEAVY_FOG = 515;
+ public static final int HOT = 900;
+ public static final int COLD = 901;
+ public static final int UNKNOWN = 999;
+
+ public static final int mapOpenWeatherConditionToHPlusCondition(int openWeatherMapCondition) {
+ switch (openWeatherMapCondition) {
+ //Group 2xx: Thunderstorm
+ case 200: //thunderstorm with light rain: //11d
+ return HPlusWeatherCode.STORM;
+ case 201: //thunderstorm with rain: //11d
+ case 202: //thunderstorm with heavy rain: //11d
+ return HPlusWeatherCode.HEAVY_RAIN_TO_STORM;
+ case 210: //light thunderstorm:: //11d
+ case 211: //thunderstorm: //11d
+ case 230: //thunderstorm with light drizzle: //11d
+ case 231: //thunderstorm with drizzle: //11d
+ case 232: //thunderstorm with heavy drizzle: //11d
+ case 212: //heavy thunderstorm: //11d
+ case 221: //ragged thunderstorm: //11d
+ return HPlusWeatherCode.HEAVY_THUNDERSTORM;
+ //Group 3xx: Drizzle
+ case 300: //light intensity drizzle: //09d
+ case 301: //drizzle: //09d
+ case 302: //heavy intensity drizzle: //09d
+ case 310: //light intensity drizzle rain: //09d
+ case 311: //drizzle rain: //09d
+ case 312: //heavy intensity drizzle rain: //09d
+ case 313: //shower rain and drizzle: //09d
+ case 314: //heavy shower rain and drizzle: //09d
+ case 321: //shower drizzle: //09d
+ return HPlusWeatherCode.DRIZZLE_RAIN;
+ case 500: //light rain: //10d
+ return HPlusWeatherCode.LIGHT_RAIN;
+ case 501: //moderate rain: //10d
+ return HPlusWeatherCode.MODERATE_RAIN;
+ //Group 5xx: Rain
+ case 502: //heavy intensity rain: //10d
+ return HPlusWeatherCode.HEAVY_RAIN;
+ case 503: //very heavy rain: //10d
+ return HPlusWeatherCode.HEAVY_RAIN_TO_STORM;
+ case 504: //extreme rain: //10d
+ return HPlusWeatherCode.EXTREME_RAIN;
+ case 511: //freezing rain: //13d
+ return HPlusWeatherCode.FREEZING_RAIN;
+ case 520: //light intensity shower rain: //09d
+ case 521: //shower rain: //09d
+ return HPlusWeatherCode.SHOWER_RAIN;
+ case 522: //heavy intensity shower rain: //09d
+ case 531: //ragged shower rain: //09d
+ return HPlusWeatherCode.HEAVY_SHOWER_RAIN;
+ //Group 6xx: Snow
+ case 600: //light snow: //[[file:13d.png]]
+ return HPlusWeatherCode.LIGHT_SNOW;
+ case 601: //snow: //[[file:13d.png]]
+ return HPlusWeatherCode.SNOW;
+ case 620: //light shower snow: //[[file:13d.png]]
+ return HPlusWeatherCode.LIGHT_TO_MODERATE_SNOW;
+ case 602: //heavy snow: //[[file:13d.png]]
+ return HPlusWeatherCode.MODERATE_TO_HEAVY_SNOW;
+ case 611: //sleet: //[[file:13d.png]]
+ case 612: //shower sleet: //[[file:13d.png]]
+ return HPlusWeatherCode.SNOW_FLURRY;
+ case 621: //shower snow: //[[file:13d.png]]
+ case 622: //heavy shower snow: //[[file:13d.png]]
+ return HPlusWeatherCode.SHOWER_SNOW;
+ case 615: //light rain and snow: //[[file:13d.png]]
+ case 616: //rain and snow: //[[file:13d.png]]
+ return HPlusWeatherCode.RAIN_AND_SNOW;
+ //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 741: //fog: //[[file:50d.png]]
+ case 751: //sand: //[[file:50d.png]]
+ case 761: //dust: //[[file:50d.png]]
+ case 762: //volcanic ash: //[[file:50d.png]]
+ case 771: //squalls: //[[file:50d.png]]
+ case 781: //tornado: //[[file:50d.png]]
+ case 900: //tornado
+ return HPlusWeatherCode.SANDSTORM;
+ //Group 800: Clear
+ case 800: //clear sky: //[[file:01d.png]] [[file:01n.png]]
+ return HPlusWeatherCode.CLEAR;
+ //Group 80x: Clouds
+ case 801: //few clouds: //[[file:02d.png]] [[file:02n.png]]
+ return HPlusWeatherCode.FEW_CLOUDS;
+ case 802: //scattered clouds: //[[file:03d.png]] [[file:03d.png]]
+ case 803: //broken clouds: //[[file:04d.png]] [[file:03d.png]]
+ return HPlusWeatherCode.PARTLY_CLOUDY;
+ case 804: //overcast clouds: //[[file:04d.png]] [[file:04d.png]]
+ return HPlusWeatherCode.CLOUDY;
+
+ //Group 90x: Extreme
+ case 901: //tropical storm
+ case 903: //cold
+ case 904: //hot
+ case 905: //windy
+ case 906: //hail
+ //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
+ return HPlusWeatherCode.SEVERE_HAZE;
+ default:
+ return HPlusWeatherCode.UNKNOWN;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/MakibesF68Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/MakibesF68Coordinator.java
new file mode 100644
index 0000000..e005a14
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/MakibesF68Coordinator.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2017-2024 Daniel Dakhno, Daniele Gobbetti, Joa虄o Paulo
+ Barraca, Jos茅 Rebelo, Stan Gomin
+
+ 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.devices.hplus;
+
+/*
+* @author Jo茫o Paulo Barraca <jpbarraca@gmail.com>
+*/
+
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+/**
+ * Pseudo Coordinator for the Makibes F68, a sub type of the HPLUS devices
+ */
+public class MakibesF68Coordinator extends HPlusCoordinator {
+
+ @NonNull
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ String name = candidate.getName();
+ if(name != null && name.startsWith("SPORT") && !name.startsWith("SPORTAGE")){
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Makibes";
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_makibes_f68;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/Q8Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/Q8Coordinator.java
new file mode 100644
index 0000000..3ed48db
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/Q8Coordinator.java
@@ -0,0 +1,51 @@
+/* Copyright (C) 2018-2024 Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo,
+ tiparega
+
+ 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.devices.hplus;
+
+/*
+* @author Alejandro Ladera Chamorro <11555126+tiparega@users.noreply.github.com>
+*/
+
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+/**
+ * Pseudo Coordinator for the Q8, a sub type of the HPLUS devices
+ */
+public class Q8Coordinator extends HPlusCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Q8.*");
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Makibes";
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_q8;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/SG2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/SG2Coordinator.java
new file mode 100644
index 0000000..09e9615
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hplus/SG2Coordinator.java
@@ -0,0 +1,69 @@
+/* Copyright (C) 2020-2024 Daniel Dakhno, Jos茅 Rebelo, Lesur Frederic
+
+ 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.devices.hplus;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+/**
+ * Pseudo Coordinator for the Lemfo SG2, a sub type of the HPLUS devices
+ */
+public class SG2Coordinator extends HPlusCoordinator {
+
+ @NonNull
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ String name = candidate.getName();
+ if (name != null && name.startsWith("SG2")) {
+ HPlusCoordinator.setNotificationLinesNumber(candidate.getDevice().getAddress(), 9);
+ HPlusCoordinator.setUnicodeSupport(candidate.getDevice().getAddress(), true);
+ HPlusCoordinator.setDisplayIncomingMessageIcon(candidate.getDevice().getAddress(), false);
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Lemfo";
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(GBDevice device, int position) {
+ return true;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_ASK;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sg2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/ActivateDisplayOnLift.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/ActivateDisplayOnLift.java
new file mode 100644
index 0000000..776b895
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/ActivateDisplayOnLift.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+public enum ActivateDisplayOnLift {
+ ON,
+ OFF,
+ SCHEDULED,
+ SMART
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/ActivateDisplayOnLiftSensitivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/ActivateDisplayOnLiftSensitivity.java
new file mode 100644
index 0000000..dc05df7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/ActivateDisplayOnLiftSensitivity.java
@@ -0,0 +1,22 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+public enum ActivateDisplayOnLiftSensitivity {
+ NORMAL,
+ SENSITIVE
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/AlwaysOnDisplay.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/AlwaysOnDisplay.java
new file mode 100644
index 0000000..d32c800
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/AlwaysOnDisplay.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+public enum AlwaysOnDisplay {
+ OFF,
+ AUTOMATIC,
+ SCHEDULED,
+ ALWAYS
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/DisconnectNotificationSetting.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/DisconnectNotificationSetting.java
new file mode 100644
index 0000000..13680c2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/DisconnectNotificationSetting.java
@@ -0,0 +1,23 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.devices.huami;
+
+public enum DisconnectNotificationSetting {
+ ON,
+ OFF,
+ SCHEDULED
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Service.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Service.java
new file mode 100644
index 0000000..4a7ecd6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Service.java
@@ -0,0 +1,136 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+public class Huami2021Service {
+ /**
+ * Endpoints for 2021 chunked protocol
+ */
+ public static final short CHUNKED2021_ENDPOINT_WEATHER = 0x000e;
+ public static final short CHUNKED2021_ENDPOINT_CONNECTION = 0x0015;
+ public static final short CHUNKED2021_ENDPOINT_USER_INFO = 0x0017;
+ public static final short CHUNKED2021_ENDPOINT_STEPS = 0x0016;
+ public static final short CHUNKED2021_ENDPOINT_VIBRATION_PATTERNS = 0x0018;
+ public static final short CHUNKED2021_ENDPOINT_WORKOUT = 0x0019;
+ public static final short CHUNKED2021_ENDPOINT_FIND_DEVICE = 0x001a;
+ public static final short CHUNKED2021_ENDPOINT_HEARTRATE = 0x001d;
+ public static final short CHUNKED2021_ENDPOINT_BATTERY = 0x0029;
+ public static final short CHUNKED2021_ENDPOINT_SILENT_MODE = 0x003b;
+ public static final short CHUNKED2021_ENDPOINT_AUTH = 0x0082;
+ public static final short CHUNKED2021_ENDPOINT_COMPAT = 0x0090;
+
+ /**
+ * Find Device, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_FIND_DEVICE}.
+ */
+ public static final byte FIND_BAND_START = 0x03;
+ public static final byte FIND_BAND_ACK = 0x04;
+ public static final byte FIND_BAND_STOP_FROM_PHONE = 0x06;
+ public static final byte FIND_BAND_STOP_FROM_BAND = 0x07;
+ public static final byte FIND_PHONE_START = 0x11;
+ public static final byte FIND_PHONE_ACK = 0x12;
+ public static final byte FIND_PHONE_STOP_FROM_BAND = 0x13;
+ public static final byte FIND_PHONE_STOP_FROM_PHONE = 0x14;
+ public static final byte FIND_PHONE_MODE = 0x15;
+
+ /**
+ * Steps, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_STEPS}.
+ */
+ public static final byte STEPS_CMD_GET = 0x03;
+ public static final byte STEPS_CMD_REPLY = 0x04;
+ public static final byte STEPS_CMD_ENABLE_REALTIME = 0x05;
+ public static final byte STEPS_CMD_ENABLE_REALTIME_ACK = 0x06;
+ public static final byte STEPS_CMD_REALTIME_NOTIFICATION = 0x07;
+
+ /**
+ * Vibration Patterns, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_VIBRATION_PATTERNS}.
+ */
+ public static final byte VIBRATION_PATTERN_SET = 0x03;
+ public static final byte VIBRATION_PATTERN_ACK = 0x04;
+
+ /**
+ * Battery, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_BATTERY}.
+ */
+ public static final byte BATTERY_REQUEST = 0x03;
+ public static final byte BATTERY_REPLY = 0x04;
+
+ /**
+ * Silent Mode, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_SILENT_MODE}.
+ */
+ public static final byte SILENT_MODE_CMD_CAPABILITIES_REQUEST = 0x01;
+ public static final byte SILENT_MODE_CMD_CAPABILITIES_RESPONSE = 0x02;
+ // Notify silent mode, from phone
+ public static final byte SILENT_MODE_CMD_NOTIFY_BAND = 0x03;
+ public static final byte SILENT_MODE_CMD_NOTIFY_BAND_ACK = 0x04;
+ // Query silent mode on phone, from band
+ public static final byte SILENT_MODE_CMD_QUERY = 0x05;
+ public static final byte SILENT_MODE_CMD_REPLY = 0x06;
+ // Set silent mode on phone, from band
+ // After this, phone sends ACK + NOTIFY
+ public static final byte SILENT_MODE_CMD_SET = 0x07;
+ public static final byte SILENT_MODE_CMD_ACK = 0x08;
+
+ /**
+ * Connection, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_CONNECTION}.
+ */
+ public static final byte CONNECTION_CMD_MTU_REQUEST = 0x01;
+ public static final byte CONNECTION_CMD_MTU_RESPONSE = 0x02;
+ public static final byte CONNECTION_CMD_UNKNOWN_3 = 0x03;
+ public static final byte CONNECTION_CMD_UNKNOWN_4 = 0x04;
+
+ /**
+ * Notifications, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_HEARTRATE}.
+ */
+ public static final byte HEART_RATE_CMD_REALTIME_SET = 0x04;
+ public static final byte HEART_RATE_CMD_REALTIME_ACK = 0x05;
+ public static final byte HEART_RATE_CMD_SLEEP = 0x06;
+ public static final byte HEART_RATE_FALL_ASLEEP = 0x01;
+ public static final byte HEART_RATE_WAKE_UP = 0x00;
+ public static final byte HEART_RATE_REALTIME_MODE_STOP = 0x00;
+ public static final byte HEART_RATE_REALTIME_MODE_START = 0x01;
+ public static final byte HEART_RATE_REALTIME_MODE_CONTINUE = 0x02;
+
+ /**
+ * Workout, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_WORKOUT}.
+ */
+ public static final byte WORKOUT_CMD_GPS_LOCATION = 0x04;
+ public static final byte WORKOUT_CMD_APP_OPEN = 0x20;
+ public static final byte WORKOUT_CMD_STATUS = 0x11;
+ public static final int WORKOUT_GPS_FLAG_STATUS = 0x1;
+ public static final int WORKOUT_GPS_FLAG_POSITION = 0x40000;
+ public static final byte WORKOUT_STATUS_START = 0x01;
+ public static final byte WORKOUT_STATUS_END = 0x04;
+
+ /**
+ * Weather, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_WEATHER}.
+ */
+ public static final byte WEATHER_CMD_SET_DEFAULT_LOCATION = 0x09;
+ public static final byte WEATHER_CMD_DEFAULT_LOCATION_ACK = 0x0a;
+
+ /**
+ * User Info, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_USER_INFO}.
+ */
+ public static final byte USER_INFO_CMD_SET = 0x01;
+ public static final byte USER_INFO_CMD_SET_ACK = 0x02;
+
+ /**
+ * Raw sensor control.
+ */
+ public static final byte[] CMD_RAW_SENSOR_START_1 = new byte[]{0x01, 0x03, 0x19}; // band replies 10:01:03:05
+ public static final byte[] CMD_RAW_SENSOR_START_2 = new byte[]{0x01, 0x03, 0x00, 0x00, 0x00, 0x19}; // band replies 10:01:01:05
+ public static final byte[] CMD_RAW_SENSOR_START_3 = new byte[]{0x02}; // band replies 10:02:01
+ public static final byte[] CMD_RAW_SENSOR_STOP = new byte[]{0x03}; // band replies 10:03:01
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiActivitySummaryParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiActivitySummaryParser.java
new file mode 100644
index 0000000..1ae3737
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiActivitySummaryParser.java
@@ -0,0 +1,391 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Jos茅 Rebelo, Sebastian Krey,
+ Your Name
+
+ 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.devices.huami;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.*;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Date;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiActivityDetailsParser;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiActivityDetailsParser;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSportsActivityType;
+
+public class HuamiActivitySummaryParser implements ActivitySummaryParser {
+
+ private static final Logger LOG = LoggerFactory.getLogger(HuamiActivityDetailsParser.class);
+ protected ActivitySummaryData summaryData = new ActivitySummaryData();
+
+ public BaseActivitySummary parseBinaryData(BaseActivitySummary summary, final boolean forDetails) {
+ Date startTime = summary.getStartTime();
+ if (startTime == null) {
+ LOG.error("Due to a bug, we can only parse the summary when startTime is already set");
+ return null;
+ }
+ summaryData = new ActivitySummaryData();
+ parseBinaryData(summary, startTime);
+ summary.setSummaryData(summaryData.toString());
+ return summary;
+ }
+
+ public AbstractHuamiActivityDetailsParser getDetailsParser(final BaseActivitySummary summary) {
+ return new HuamiActivityDetailsParser(summary);
+ }
+
+ protected void parseBinaryData(BaseActivitySummary summary, Date startTime) {
+ final byte[] rawSummaryData = summary.getRawSummaryData();
+ if (rawSummaryData == null) {
+ return;
+ }
+ final ByteBuffer buffer = ByteBuffer.wrap(rawSummaryData).order(ByteOrder.LITTLE_ENDIAN);
+
+ short version = buffer.getShort(); // version
+ LOG.debug("Got sport summary version " + version + " total bytes=" + buffer.capacity());
+ ActivityKind activityKind = ActivityKind.UNKNOWN;
+ int rawKind = BLETypeConversions.toUnsigned(buffer.getShort());
+ try {
+ HuamiSportsActivityType activityType = HuamiSportsActivityType.fromCode(rawKind);
+ activityKind = activityType.toActivityKind();
+ } catch (Exception ex) {
+ LOG.error("Error mapping activity kind: " + ex.getMessage(), ex);
+ summaryData.add("Raw Activity Kind", rawKind, UNIT_NONE);
+ }
+ summary.setActivityKind(activityKind.getCode());
+
+ // FIXME: should honor timezone we were in at that time etc
+ long timestamp_start = BLETypeConversions.toUnsigned(buffer.getInt()) * 1000;
+ long timestamp_end = BLETypeConversions.toUnsigned(buffer.getInt()) * 1000;
+
+
+ // FIXME: should be done like this but seems to return crap when in DST
+ //summary.setStartTime(new Date(timestamp_start));
+ //summary.setEndTime(new Date(timestamp_end));
+
+ // FIXME ... so do it like this
+ long duration = timestamp_end - timestamp_start;
+ summary.setEndTime(new Date(startTime.getTime() + duration));
+
+ int baseLongitude = buffer.getInt();
+ int baseLatitude = buffer.getInt();
+ int baseAltitude = buffer.getInt();
+ summary.setBaseLongitude(baseLongitude);
+ summary.setBaseLatitude(baseLatitude);
+ summary.setBaseAltitude(baseAltitude);
+
+ int steps;
+ int activeSeconds;
+ int maxLatitude;
+ int minLatitude;
+ int maxLongitude;
+ int minLongitude;
+ float caloriesBurnt;
+ float distanceMeters;
+ float distanceMeters2 = 0;
+ float ascentDistance = 0;
+ float descentDistance = 0;
+ float flatDistance = 0;
+ float ascentMeters = 0;
+ float descentMeters = 0;
+ float maxAltitude = 0;
+ float minAltitude = 0;
+ float averageAltitude = 0;
+ float maxSpeed = 0;
+ float minSpeed = 0;
+ float averageSpeed = 0;
+ float minPace = 0;
+ float maxPace = 0;
+ float averagePace = 0;
+ int maxCadence = 0;
+ int minCadence = 0;
+ int averageCadence = 0;
+ int maxStride = 0;
+ int minStride = 0;
+ int averageStride2 = 0;
+ float totalStride = 0;
+ float averageStride;
+ short averageHR;
+ short maxHR = 0;
+ short minHR = 0;
+ short averageKMPaceSeconds;
+ int ascentSeconds = 0;
+ int descentSeconds = 0;
+ int flatSeconds = 0;
+
+ // Swimming
+ float averageStrokeDistance = 0;
+ float averageStrokesPerSecond = 0;
+ float averageLapPace = 0;
+ short strokes = 0;
+ short swolfIndex = 0; // this is called SWOLF score on bip s, SWOLF index on mi band 4
+ byte swimStyle = 0;
+ byte laps = 0;
+
+ // Just assuming, Bip has 259 which seems like 256+x
+ // Bip S now has 518 so assuming 512+x, might be wrong
+
+ if (version >= 512) {
+ if (version == 519) {
+ buffer.get(); // skip one byte
+ minHR = buffer.getShort();
+ // hack that skips data on yet unknown summary version 519 data
+ buffer.position(0x8c);
+ } else if (version == 516) {
+ // hack that skips data on yet unknown summary version 516 data
+ buffer.position(buffer.position() + 4);
+ }
+ steps = buffer.getInt();
+ activeSeconds = buffer.getInt();
+
+ maxLatitude = buffer.getInt();
+ minLatitude = buffer.getInt();
+ maxLongitude = buffer.getInt();
+ minLongitude = buffer.getInt();
+
+ caloriesBurnt = buffer.getFloat();
+ distanceMeters = buffer.getFloat();
+
+ ascentMeters = buffer.getFloat();
+ descentMeters = buffer.getFloat();
+ maxAltitude = buffer.getFloat();
+ minAltitude = buffer.getFloat();
+ averageAltitude = buffer.getFloat();
+
+ maxSpeed = buffer.getFloat(); // in meter/second
+ minSpeed = buffer.getFloat();
+ averageSpeed = buffer.getFloat();
+ minPace = buffer.getFloat(); // in seconds/meter
+ maxPace = buffer.getFloat();
+ averagePace = buffer.getFloat();
+
+ maxCadence = Math.round(buffer.getFloat() * 60);
+ minCadence = Math.round(buffer.getFloat() * 60);
+ averageCadence = Math.round(buffer.getFloat() * 60);
+
+ maxStride = Math.round(buffer.getFloat() * 100);
+ minStride = Math.round(buffer.getFloat() * 100);
+ averageStride2 = Math.round(buffer.getFloat() * 100);
+
+ distanceMeters2 = buffer.getFloat(); // this distance is 87-97% of distanceMeters, so probably length of the GPS track (difference is larger, when GPS took longer to get a precise position)
+ buffer.getInt();
+ averageHR = buffer.getShort();
+ averageKMPaceSeconds = buffer.getShort();
+ averageStride = buffer.getShort();
+ maxHR = buffer.getShort();
+
+ if (activityKind == ActivityKind.CYCLING || activityKind == ActivityKind.RUNNING || activityKind == ActivityKind.HIKING || activityKind == ActivityKind.CLIMBING) {
+ // this had nonsense data with treadmill on bip s, need to test it with running
+ // for cycling it seems to work... hmm...
+ // 28 bytes
+ buffer.getInt(); // unknown
+ ascentDistance = buffer.getFloat();
+ ascentSeconds = buffer.getInt() / 1000; //ms?
+ descentDistance = buffer.getFloat();
+ descentSeconds = buffer.getInt() / 1000; //ms?
+ flatDistance = buffer.getFloat();
+ flatSeconds = buffer.getInt() / 1000; // ms?
+ } else if (activityKind == ActivityKind.SWIMMING || activityKind == ActivityKind.SWIMMING_OPENWATER) {
+ // offset 0x8c
+ /*
+ data on the bip s display (example)
+ main style backstroke
+ SWOLF score 92
+ total laps 1
+ avg. pace 2,09/100
+ strokes 36
+ avg stroke rate 26 spm
+ single stroke distance 1,79m
+ max stroke rate 39
+ */
+
+ averageStrokeDistance = buffer.getFloat();
+ buffer.getInt(); // unknown
+ buffer.getInt(); // unknown
+ buffer.getInt(); // unknown
+ buffer.getInt(); // unknown
+ buffer.getInt(); // unknown
+ averageStrokesPerSecond = buffer.getFloat();
+ averageLapPace = buffer.getFloat();
+ buffer.getInt(); // unknown
+ strokes = buffer.getShort();
+ swolfIndex = buffer.getShort();
+ swimStyle = buffer.get();
+ laps = buffer.get();
+ buffer.getInt(); // unknown
+ buffer.getInt(); // unknown
+ }
+ } else {
+ distanceMeters = buffer.getFloat();
+ ascentMeters = buffer.getFloat();
+ descentMeters = buffer.getFloat();
+ minAltitude = buffer.getFloat();
+ maxAltitude = buffer.getFloat();
+ maxLatitude = buffer.getInt(); // format?
+ minLatitude = buffer.getInt(); // format?
+ maxLongitude = buffer.getInt(); // format?
+ minLongitude = buffer.getInt(); // format?
+ steps = buffer.getInt();
+ activeSeconds = buffer.getInt();
+ caloriesBurnt = buffer.getFloat();
+ maxSpeed = buffer.getFloat();
+ maxPace = buffer.getFloat();
+ minPace = buffer.getFloat();
+ totalStride = buffer.getFloat();
+
+ buffer.getInt(); // unknown
+ if (activityKind == ActivityKind.SWIMMING) {
+ // 28 bytes
+ averageStrokeDistance = buffer.getFloat();
+ averageStrokesPerSecond = buffer.getFloat();
+ averageLapPace = buffer.getFloat();
+ strokes = buffer.getShort();
+ swolfIndex = buffer.getShort();
+ swimStyle = buffer.get();
+ laps = buffer.get();
+ buffer.getInt(); // unknown
+ buffer.getInt(); // unknown
+ buffer.getShort(); // unknown
+ } else {
+ // 28 bytes
+ buffer.getInt(); // unknown
+ buffer.getInt(); // unknown probably ascentDistance = buffer.getFloat();
+ ascentSeconds = buffer.getInt() / 1000; //ms?
+ buffer.getInt(); // unknown probably descentDistance = buffer.getFloat();
+ descentSeconds = buffer.getInt() / 1000; //ms?
+ buffer.getInt(); // unknown probably flatDistance = buffer.getFloat();
+ flatSeconds = buffer.getInt() / 1000; // ms?
+
+ summaryData.add(ASCENT_SECONDS, ascentSeconds, UNIT_SECONDS);
+ summaryData.add(DESCENT_SECONDS, descentSeconds, UNIT_SECONDS);
+ summaryData.add(FLAT_SECONDS, flatSeconds, UNIT_SECONDS);
+ }
+
+ averageHR = buffer.getShort();
+
+ averageKMPaceSeconds = buffer.getShort();
+ averageStride = buffer.getShort();
+ }
+
+// summary.setBaseCoordinate(new GPSCoordinate(baseLatitude, baseLongitude, baseAltitude));
+// summary.setDistanceMeters(distanceMeters);
+// summary.setAscentMeters(ascentMeters);
+// summary.setDescentMeters(descentMeters);
+// summary.setMinAltitude(maxAltitude);
+// summary.setMaxAltitude(maxAltitude);
+// summary.setMinLatitude(minLatitude);
+// summary.setMaxLatitude(maxLatitude);
+// summary.setMinLongitude(minLatitude);
+// summary.setMaxLongitude(maxLatitude);
+// summary.setSteps(steps);
+// summary.setActiveTimeSeconds(secondsActive);
+// summary.setCaloriesBurnt(caloriesBurnt);
+// summary.setMaxSpeed(maxSpeed);
+// summary.setMinPace(minPace);
+// summary.setMaxPace(maxPace);
+// summary.setTotalStride(totalStride);
+// summary.setTimeAscent(BLETypeConversions.toUnsigned(ascentSeconds);
+// summary.setTimeDescent(BLETypeConversions.toUnsigned(descentSeconds);
+// summary.setTimeFlat(BLETypeConversions.toUnsigned(flatSeconds);
+// summary.setAverageHR(BLETypeConversions.toUnsigned(averageHR);
+// summary.setAveragePace(BLETypeConversions.toUnsigned(averagePace);
+// summary.setAverageStride(BLETypeConversions.toUnsigned(averageStride);
+
+ summaryData.add(ASCENT_SECONDS, ascentSeconds, UNIT_SECONDS);
+ summaryData.add(DESCENT_SECONDS, descentSeconds, UNIT_SECONDS);
+ summaryData.add(FLAT_SECONDS, flatSeconds, UNIT_SECONDS);
+ summaryData.add(ASCENT_DISTANCE, ascentDistance, UNIT_METERS);
+ summaryData.add(DESCENT_DISTANCE, descentDistance, UNIT_METERS);
+ summaryData.add(FLAT_DISTANCE, flatDistance, UNIT_METERS);
+
+ summaryData.add(DISTANCE_METERS, distanceMeters, UNIT_METERS);
+ // summaryData.add("distanceMeters2", distanceMeters2, UNIT_METERS);
+ summaryData.add(ASCENT_METERS, ascentMeters, UNIT_METERS);
+ summaryData.add(DESCENT_METERS, descentMeters, UNIT_METERS);
+ if (maxAltitude != -100000) {
+ summaryData.add(ALTITUDE_MAX, maxAltitude, UNIT_METERS);
+ }
+ if (minAltitude != 100000) {
+ summaryData.add(ALTITUDE_MIN, minAltitude, UNIT_METERS);
+ }
+ if (minAltitude != 100000) {
+ summaryData.add(ALTITUDE_AVG, averageAltitude, UNIT_METERS);
+ }
+ summaryData.add(STEPS, steps, UNIT_STEPS);
+ summaryData.add(ACTIVE_SECONDS, activeSeconds, UNIT_SECONDS);
+ summaryData.add(CALORIES_BURNT, caloriesBurnt, UNIT_KCAL);
+ summaryData.add(SPEED_MAX, maxSpeed, UNIT_METERS_PER_SECOND);
+ summaryData.add(SPEED_MIN, minSpeed, UNIT_METERS_PER_SECOND);
+ summaryData.add(SPEED_AVG, averageSpeed, UNIT_METERS_PER_SECOND);
+ summaryData.add(CADENCE_MAX, maxCadence, UNIT_SPM);
+ summaryData.add(CADENCE_MIN, minCadence, UNIT_SPM);
+ summaryData.add(CADENCE_AVG, averageCadence, UNIT_SPM);
+
+ if (!(activityKind == ActivityKind.ELLIPTICAL_TRAINER ||
+ activityKind == ActivityKind.JUMP_ROPING ||
+ activityKind == ActivityKind.EXERCISE ||
+ activityKind == ActivityKind.YOGA ||
+ activityKind == ActivityKind.INDOOR_CYCLING)) {
+ summaryData.add(PACE_MIN, minPace, UNIT_SECONDS_PER_M);
+ summaryData.add(PACE_MAX, maxPace, UNIT_SECONDS_PER_M);
+ // summaryData.add("averagePace", averagePace, UNIT_SECONDS_PER_M);
+ }
+
+ summaryData.add(STRIDE_TOTAL, totalStride, UNIT_METERS);
+ summaryData.add(HR_AVG, averageHR, UNIT_BPM);
+ summaryData.add(HR_MAX, maxHR, UNIT_BPM);
+ summaryData.add(HR_MIN, minHR, UNIT_BPM);
+ summaryData.add(PACE_AVG_SECONDS_KM, averageKMPaceSeconds, UNIT_SECONDS_PER_KM);
+ summaryData.add(STRIDE_AVG, averageStride, UNIT_CM);
+ summaryData.add(STRIDE_MAX, maxStride, UNIT_CM);
+ summaryData.add(STRIDE_MIN, minStride, UNIT_CM);
+ // summaryData.add("averageStride2", averageStride2, UNIT_CM);
+
+ if (activityKind == ActivityKind.SWIMMING || activityKind == ActivityKind.SWIMMING_OPENWATER) {
+ summaryData.add(STROKE_DISTANCE_AVG, averageStrokeDistance, UNIT_METERS);
+ summaryData.add(STROKE_AVG_PER_SECOND, averageStrokesPerSecond, UNIT_STROKES_PER_SECOND);
+ summaryData.add(LAP_PACE_AVERAGE, averageLapPace, "second");
+ summaryData.add(STROKES, strokes, "strokes");
+ summaryData.add(SWOLF_INDEX, swolfIndex, "swolf_index");
+ String swimStyleName = "unknown"; // TODO: translate here or keep as string identifier here?
+ switch (swimStyle) {
+ case 1:
+ swimStyleName = "breaststroke";
+ break;
+ case 2:
+ swimStyleName = "freestyle";
+ break;
+ case 3:
+ swimStyleName = "backstroke";
+ break;
+ case 4:
+ swimStyleName = "medley";
+ break;
+ }
+ summaryData.add(SWIM_STYLE, swimStyleName);
+ summaryData.add(LAPS, laps, "laps");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiConst.java
new file mode 100644
index 0000000..c5c3a4d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiConst.java
@@ -0,0 +1,184 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Damien Gaignon, jhey, Jos茅
+ Rebelo, Maxime Reyrolle, Nephiel, odavo32nof, Petr Van臎k, Raghd Hamzeh,
+ sedy89, Stefan Bora, thermatk, xaos, Yoran Vulker, Zhong Jianxin
+
+ 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.devices.huami;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class HuamiConst {
+ // observed the following values so far:
+ // 00 01 02 09 0a 0b 0c 10 11
+
+ // 0 = same activity kind as before
+ // 1 = light activity walking?
+ // 3 = definitely non-wear
+ // 9 = probably light sleep, definitely some kind of sleep
+ // 10 = ignore, except for hr (if valid)
+ // 11 = probably deep sleep
+ // 12 = definitely wake up
+ // 17 = definitely not sleep related
+
+ public static final int TYPE_UNSET = -1;
+ public static final int TYPE_NO_CHANGE = 0;
+ public static final int TYPE_ACTIVITY = 1;
+ public static final int TYPE_RUNNING = 2;
+ public static final int TYPE_NONWEAR = 3;
+ public static final int TYPE_RIDE_BIKE = 4;
+ public static final int TYPE_CHARGING = 6;
+ public static final int TYPE_LIGHT_SLEEP = 9;
+ public static final int TYPE_IGNORE = 10;
+ public static final int TYPE_DEEP_SLEEP = 11;
+ public static final int TYPE_WAKE_UP = 12;
+
+
+ public static final String MI_BAND2_NAME = "MI Band 2";
+ public static final String MI_BAND2_NAME_HRX = "Mi Band HRX";
+ public static final String MI_BAND3_NAME = "Mi Band 3";
+ public static final String MI_BAND3_NAME_2 = "Xiaomi Band 3";
+ public static final String MI_BAND4_NAME = "Mi Smart Band 4";
+ public static final String MI_BAND5_NAME = "Mi Smart Band 5";
+ public static final String MI_BAND6_NAME = "Mi Smart Band 6";
+ public static final String AMAZFIT_ACTIVE_NAME = "Amazfit Active";
+ public static final String AMAZFIT_ACTIVE_EDGE_NAME = "Amazfit Active Edge";
+ public static final String AMAZFIT_BALANCE_NAME = "Amazfit Balance";
+ public static final String AMAZFIT_BAND5_NAME = "Amazfit Band 5";
+ public static final String AMAZFIT_BAND7_NAME = "Amazfit Band 7";
+ public static final String AMAZFIT_NEO_NAME = "Amazfit Neo";
+ public static final String AMAZFIT_X = "Amazfit X";
+ public static final String AMAZFIT_BIP5_NAME = "Amazfit Bip 5";
+ public static final String AMAZFIT_BIP5_UNITY_NAME = "Amazfit Bip 5 Unity";
+ public static final String AMAZFIT_GTS3_NAME = "Amazfit GTS 3";
+ public static final String AMAZFIT_GTS4_NAME = "Amazfit GTS 4";
+ public static final String AMAZFIT_GTS4_MINI_NAME = "Amazfit GTS 4 Mini";
+ public static final String AMAZFIT_GTR3_NAME = "Amazfit GTR 3";
+ public static final String AMAZFIT_GTR3_PRO_NAME = "Amazfit GTR 3 Pro";
+ public static final String AMAZFIT_GTR4_NAME = "Amazfit GTR 4";
+ public static final String AMAZFIT_GTR_MINI_NAME = "Amazfit GTR Mini";
+ public static final String AMAZFIT_TREX_2_NAME = "Amazfit T-Rex 2";
+ public static final String AMAZFIT_TREX_3_NAME = "Amazfit T-Rex 3";
+ public static final String AMAZFIT_TREX_ULTRA = "Amazfit T-Rex Ultra";
+ public static final String AMAZFIT_CHEETAH_PRO_NAME = "Amazfit Cheetah Pro";
+ public static final String AMAZFIT_CHEETAH_SQUARE_NAME = "Amazfit Cheetah S";
+ public static final String AMAZFIT_CHEETAH_ROUND_NAME = "Amazfit Cheetah R";
+ public static final String AMAZFIT_FALCON_NAME = "Amazfit Falcon";
+
+ public static final String XIAOMI_SMART_BAND7_NAME = "Xiaomi Smart Band 7";
+ public static final String XIAOMI_SMART_BAND7_PRO_NAME = "Xiaomi Smart Band 7 Pro";
+
+ public static final String PREF_DISPLAY_ITEMS = "display_items";
+ public static final String PREF_DISPLAY_ITEMS_SORTABLE = "display_items_sortable";
+ public static final String PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE = "workout_activity_types_sortable";
+ public static final String PREF_SHORTCUTS = "shortcuts";
+ public static final String PREF_SHORTCUTS_SORTABLE = "shortcuts_sortable";
+ public static final String PREF_CONTROL_CENTER_SORTABLE = "control_center_sortable";
+ public static final String PREF_EXPOSE_HR_THIRDPARTY = "expose_hr_thirdparty";
+ public static final String PREF_USE_CUSTOM_FONT = "use_custom_font";
+
+ public static final String PREF_BUTTON_ACTION_ENABLE = "button_action_enable";
+ public static final String PREF_BUTTON_ACTION_VIBRATE = "button_action_vibrate";
+ public static final String PREF_BUTTON_ACTION_PRESS_COUNT = "button_action_press_count";
+ public static final String PREF_BUTTON_ACTION_PRESS_MAX_INTERVAL = "button_action_press_max_interval";
+ public static final String PREF_BUTTON_ACTION_BROADCAST_DELAY = "button_action_broadcast_delay";
+ public static final String PREF_BUTTON_ACTION_BROADCAST = "button_action_broadcast";
+ public static final String PREF_BUTTON_ACTION_SELECTION_OFF = "UNKNOWN";
+ public static final String PREF_BUTTON_ACTION_SELECTION_BROADCAST = "BROADCAST";
+ public static final String PREF_BUTTON_ACTION_SELECTION_FITNESS_APP_START = "FITNESS_CONTROL_START";
+ public static final String PREF_BUTTON_ACTION_SELECTION_FITNESS_APP_STOP = "FITNESS_CONTROL_STOP";
+ public static final String PREF_BUTTON_ACTION_SELECTION_FITNESS_APP_TOGGLE = "FITNESS_CONTROL_TOGGLE";
+
+ /**
+ * The suffixes match the enum {@link nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiVibrationPatternNotificationType}.
+ */
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_KEY_PREFIX = "vibration_profile_key_";
+ public static final String PREF_HUAMI_DEFAULT_VIBRATION_PROFILE = "default";
+ // profile
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_PREFIX = "huami_vibration_profile_";
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_APP_ALERTS = PREF_HUAMI_VIBRATION_PROFILE_PREFIX + "app_alerts";
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_INCOMING_CALL = PREF_HUAMI_VIBRATION_PROFILE_PREFIX + "incoming_call";
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_INCOMING_SMS = PREF_HUAMI_VIBRATION_PROFILE_PREFIX + "incoming_sms";
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_GOAL_NOTIFICATION = PREF_HUAMI_VIBRATION_PROFILE_PREFIX + "goal_notification";
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_ALARM = PREF_HUAMI_VIBRATION_PROFILE_PREFIX + "alarm";
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_IDLE_ALERTS = PREF_HUAMI_VIBRATION_PROFILE_PREFIX + "idle_alerts";
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_EVENT_REMINDER = PREF_HUAMI_VIBRATION_PROFILE_PREFIX + "event_reminder";
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_FIND_BAND = PREF_HUAMI_VIBRATION_PROFILE_PREFIX + "find_band";
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_TODO_LIST = PREF_HUAMI_VIBRATION_PROFILE_PREFIX + "todo_list";
+ public static final String PREF_HUAMI_VIBRATION_PROFILE_SCHEDULE = PREF_HUAMI_VIBRATION_PROFILE_PREFIX + "schedule";
+ // count
+ public static final String PREF_HUAMI_VIBRATION_COUNT_PREFIX = "huami_vibration_count_";
+ public static final String PREF_HUAMI_VIBRATION_COUNT_APP_ALERTS = PREF_HUAMI_VIBRATION_COUNT_PREFIX + "app_alerts";
+ public static final String PREF_HUAMI_VIBRATION_COUNT_INCOMING_CALL = PREF_HUAMI_VIBRATION_COUNT_PREFIX + "incoming_call";
+ public static final String PREF_HUAMI_VIBRATION_COUNT_INCOMING_SMS = PREF_HUAMI_VIBRATION_COUNT_PREFIX + "incoming_sms";
+ public static final String PREF_HUAMI_VIBRATION_COUNT_GOAL_NOTIFICATION = PREF_HUAMI_VIBRATION_COUNT_PREFIX + "goal_notification";
+ public static final String PREF_HUAMI_VIBRATION_COUNT_ALARM = PREF_HUAMI_VIBRATION_COUNT_PREFIX + "alarm";
+ public static final String PREF_HUAMI_VIBRATION_COUNT_IDLE_ALERTS = PREF_HUAMI_VIBRATION_COUNT_PREFIX + "idle_alerts";
+ public static final String PREF_HUAMI_VIBRATION_COUNT_EVENT_REMINDER = PREF_HUAMI_VIBRATION_COUNT_PREFIX + "event_reminder";
+ public static final String PREF_HUAMI_VIBRATION_COUNT_FIND_BAND = PREF_HUAMI_VIBRATION_COUNT_PREFIX + "find_band";
+ public static final String PREF_HUAMI_VIBRATION_COUNT_TODO_LIST = PREF_HUAMI_VIBRATION_COUNT_PREFIX + "todo_list";
+ public static final String PREF_HUAMI_VIBRATION_COUNT_SCHEDULE = PREF_HUAMI_VIBRATION_COUNT_PREFIX + "schedule";
+ // try
+ public static final String PREF_HUAMI_VIBRATION_TRY_PREFIX = "huami_vibration_try_";
+ public static final String PREF_HUAMI_VIBRATION_TRY_APP_ALERTS = PREF_HUAMI_VIBRATION_TRY_PREFIX + "app_alerts";
+ public static final String PREF_HUAMI_VIBRATION_TRY_INCOMING_CALL = PREF_HUAMI_VIBRATION_TRY_PREFIX + "incoming_call";
+ public static final String PREF_HUAMI_VIBRATION_TRY_INCOMING_SMS = PREF_HUAMI_VIBRATION_TRY_PREFIX + "incoming_sms";
+ public static final String PREF_HUAMI_VIBRATION_TRY_GOAL_NOTIFICATION = PREF_HUAMI_VIBRATION_TRY_PREFIX + "goal_notification";
+ public static final String PREF_HUAMI_VIBRATION_TRY_ALARM = PREF_HUAMI_VIBRATION_TRY_PREFIX + "alarm";
+ public static final String PREF_HUAMI_VIBRATION_TRY_IDLE_ALERTS = PREF_HUAMI_VIBRATION_TRY_PREFIX + "idle_alerts";
+ public static final String PREF_HUAMI_VIBRATION_TRY_EVENT_REMINDER = PREF_HUAMI_VIBRATION_TRY_PREFIX + "event_reminder";
+ public static final String PREF_HUAMI_VIBRATION_TRY_FIND_BAND = PREF_HUAMI_VIBRATION_TRY_PREFIX + "find_band";
+ public static final String PREF_HUAMI_VIBRATION_TRY_TODO_LIST = PREF_HUAMI_VIBRATION_TRY_PREFIX + "todo_list";
+ public static final String PREF_HUAMI_VIBRATION_TRY_SCHEDULE = PREF_HUAMI_VIBRATION_TRY_PREFIX + "schedule";
+
+ public static ActivityKind toActivityKind(int rawType) {
+ switch (rawType) {
+ case TYPE_DEEP_SLEEP:
+ return ActivityKind.DEEP_SLEEP;
+ case TYPE_LIGHT_SLEEP:
+ return ActivityKind.LIGHT_SLEEP;
+ case TYPE_ACTIVITY:
+ case TYPE_RUNNING:
+ case TYPE_WAKE_UP:
+ return ActivityKind.ACTIVITY;
+ case TYPE_NONWEAR:
+ return ActivityKind.NOT_WORN;
+ case TYPE_CHARGING:
+ return ActivityKind.NOT_WORN; //I believe it's a safe assumption
+ case TYPE_RIDE_BIKE:
+ return ActivityKind.CYCLING;
+ default:
+ case TYPE_UNSET: // fall through
+ return ActivityKind.UNKNOWN;
+ }
+ }
+
+ public static int toRawActivityType(ActivityKind activityKind) {
+ switch (activityKind) {
+ case ACTIVITY:
+ return TYPE_ACTIVITY;
+ case DEEP_SLEEP:
+ return TYPE_DEEP_SLEEP;
+ case LIGHT_SLEEP:
+ return TYPE_LIGHT_SLEEP;
+ case NOT_WORN:
+ return TYPE_NONWEAR;
+ case UNKNOWN: // fall through
+ default:
+ return TYPE_UNSET;
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java
new file mode 100644
index 0000000..33ac998
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java
@@ -0,0 +1,587 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Daniele Gobbetti, Jos茅 Rebelo, NekoBox, Nephiel, Petr Van臎k
+
+ 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.devices.huami;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.DateTimeDisplay;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.DoNotDisturb;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBand2SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandPairingActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandService;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.VibrationProfile;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.MiBandActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiVibrationPatternNotificationType;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return MiBandPairingActivity.class;
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid mi2Service = new ParcelUuid(MiBandService.UUID_SERVICE_MIBAND2_SERVICE);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(mi2Service).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ public boolean suggestUnbindBeforePair() {
+ return false;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getMiBandActivitySampleDao().queryBuilder();
+ qb.where(MiBandActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Huami";
+ }
+
+ @Override
+ public boolean supportsFlashing() {
+ return true;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 10;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateRestingMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificAuthenticationSettings() {
+ return new int[]{R.xml.devicesettings_pairingkey};
+ }
+
+ @Override
+ public SampleProvider<? extends AbstractActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new MiBand2SampleProvider(device, session);
+ }
+
+ @Override
+ public HuamiStressSampleProvider getStressSampleProvider(final GBDevice device, final DaoSession session) {
+ return new HuamiStressSampleProvider(device, session);
+ }
+
+ @Override
+ public HuamiSpo2SampleProvider getSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ return new HuamiSpo2SampleProvider(device, session);
+ }
+
+ @Override
+ public HuamiHeartRateMaxSampleProvider getHeartRateMaxSampleProvider(final GBDevice device, final DaoSession session) {
+ return new HuamiHeartRateMaxSampleProvider(device, session);
+ }
+
+ @Override
+ public HuamiHeartRateRestingSampleProvider getHeartRateRestingSampleProvider(final GBDevice device, final DaoSession session) {
+ return new HuamiHeartRateRestingSampleProvider(device, session);
+ }
+
+ @Override
+ public HuamiHeartRateManualSampleProvider getHeartRateManualSampleProvider(final GBDevice device, final DaoSession session) {
+ return new HuamiHeartRateManualSampleProvider(device, session);
+ }
+
+ @Override
+ public HuamiPaiSampleProvider getPaiSampleProvider(GBDevice device, DaoSession session) {
+ return new HuamiPaiSampleProvider(device, session);
+ }
+
+ @Override
+ public HuamiSleepRespiratoryRateSampleProvider getRespiratoryRateSampleProvider(GBDevice device, DaoSession session) {
+ return new HuamiSleepRespiratoryRateSampleProvider(device, session);
+ }
+
+ @Override
+ public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
+ return new HuamiActivitySummaryParser();
+ }
+
+ protected static Prefs getPrefs(final GBDevice device) {
+ return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
+ }
+
+ public static DateTimeDisplay getDateDisplay(Context context, String deviceAddress) throws IllegalArgumentException {
+ SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ String dateFormatTime = context.getString(R.string.p_dateformat_time);
+ if (dateFormatTime.equals(sharedPrefs.getString(MiBandConst.PREF_MI2_DATEFORMAT, dateFormatTime))) {
+ return DateTimeDisplay.TIME;
+ }
+ return DateTimeDisplay.DATE_TIME;
+ }
+
+ public static AlwaysOnDisplay getAlwaysOnDisplay(final String deviceAddress) {
+ final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ final String pref = prefs.getString(DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_MODE, DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_OFF);
+ return AlwaysOnDisplay.valueOf(pref.toUpperCase(Locale.ROOT));
+ }
+
+ public static Date getAlwaysOnDisplayStart(final String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_START, "00:00", deviceAddress);
+ }
+
+ public static Date getAlwaysOnDisplayEnd(final String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_END, "00:00", deviceAddress);
+ }
+
+ public static ActivateDisplayOnLift getActivateDisplayOnLiftWrist(Context context, String deviceAddress) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ String liftOff = context.getString(R.string.p_off);
+ String pref = prefs.getString(DeviceSettingsPreferenceConst.PREF_ACTIVATE_DISPLAY_ON_LIFT, liftOff);
+
+ return ActivateDisplayOnLift.valueOf(pref.toUpperCase(Locale.ROOT));
+ }
+
+ public static Date getDisplayOnLiftStart(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_DISPLAY_ON_LIFT_START, "00:00", deviceAddress);
+ }
+
+ public static Date getDisplayOnLiftEnd(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_DISPLAY_ON_LIFT_END, "00:00", deviceAddress);
+ }
+
+ public static ActivateDisplayOnLiftSensitivity getDisplayOnLiftSensitivity(String deviceAddress) {
+ final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+
+ final String pref = prefs.getString(DeviceSettingsPreferenceConst.PREF_DISPLAY_ON_LIFT_SENSITIVITY, "sensitive");
+
+ return ActivateDisplayOnLiftSensitivity.valueOf(pref.toUpperCase(Locale.ROOT));
+ }
+
+ public static DisconnectNotificationSetting getDisconnectNotificationSetting(Context context, String deviceAddress) {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ String liftOff = context.getString(R.string.p_off);
+ String pref = prefs.getString(DeviceSettingsPreferenceConst.PREF_DISCONNECT_NOTIFICATION, liftOff);
+
+ return DisconnectNotificationSetting.valueOf(pref.toUpperCase(Locale.ROOT));
+ }
+
+ public static Date getDisconnectNotificationStart(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_DISCONNECT_NOTIFICATION_START, "00:00", deviceAddress);
+ }
+
+ public static Date getDisconnectNotificationEnd(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_DISCONNECT_NOTIFICATION_END, "00:00", deviceAddress);
+ }
+
+ public static boolean getUseCustomFont(String deviceAddress) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ return prefs.getBoolean(HuamiConst.PREF_USE_CUSTOM_FONT, false);
+ }
+
+ public static boolean getGoalNotification(String deviceAddress) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_USER_FITNESS_GOAL_NOTIFICATION, false);
+ }
+
+ public static boolean getRotateWristToSwitchInfo(String deviceAddress) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ return prefs.getBoolean(MiBandConst.PREF_MI2_ROTATE_WRIST_TO_SWITCH_INFO, false);
+ }
+
+ public static boolean getInactivityWarnings(String deviceAddress) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE, false);
+ }
+
+ public static int getInactivityWarningsThreshold(String deviceAddress) {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getInt(DeviceSettingsPreferenceConst.PREF_INACTIVITY_THRESHOLD, 60);
+ }
+
+ public static boolean getInactivityWarningsDnd(String deviceAddress) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND, false);
+ }
+
+ public static Date getInactivityWarningsStart(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_START, "06:00", deviceAddress);
+ }
+
+ public static Date getInactivityWarningsEnd(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_END, "22:00", deviceAddress);
+ }
+
+ public static Date getInactivityWarningsDndStart(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_START, "12:00", deviceAddress);
+ }
+
+ public static Date getInactivityWarningsDndEnd(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_END, "14:00", deviceAddress);
+ }
+
+ public static Date getDoNotDisturbStart(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_START, "01:00", deviceAddress);
+ }
+
+ public static Date getDoNotDisturbEnd(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_END, "06:00", deviceAddress);
+ }
+
+ public static boolean getBandScreenUnlock(String deviceAddress) {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(MiBandConst.PREF_SWIPE_UNLOCK, false);
+ }
+
+ public static boolean getScreenOnOnNotification(String deviceAddress) {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS, false);
+ }
+
+ public static int getScreenBrightness(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getInt(DeviceSettingsPreferenceConst.PREF_SCREEN_BRIGHTNESS, 50);
+ }
+
+ public static int getScreenTimeout(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getInt(DeviceSettingsPreferenceConst.PREF_SCREEN_TIMEOUT, 5);
+ }
+
+ public static boolean getExposeHRThirdParty(String deviceAddress) {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(HuamiConst.PREF_EXPOSE_HR_THIRDPARTY, false);
+ }
+
+ public static int getHeartRateMeasurementInterval(String deviceAddress) {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_MEASUREMENT_INTERVAL, 0) / 60;
+ }
+
+ public static boolean getHeartrateActivityMonitoring(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ACTIVITY_MONITORING, false);
+ }
+
+ public static boolean getPasswordEnabled(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(PasswordCapabilityImpl.PREF_PASSWORD_ENABLED, false);
+ }
+
+ public static String getPassword(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getString(PasswordCapabilityImpl.PREF_PASSWORD, null);
+ }
+
+ public static boolean getHeartrateAlert(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ENABLED, false);
+ }
+
+ public static int getHeartrateAlertHighThreshold(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD, 150);
+ }
+
+ public static int getHeartrateAlertLowThreshold(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD, 45);
+ }
+
+ public static boolean getHeartrateSleepBreathingQualityMonitoring(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING, false);
+ }
+
+ public static boolean getSPO2AllDayMonitoring(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, false);
+ }
+
+ public static int getSPO2AlertThreshold(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getInt(DeviceSettingsPreferenceConst.PREF_SPO2_LOW_ALERT_THRESHOLD, 0);
+ }
+
+ public static boolean getHeartrateStressMonitoring(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING, false);
+ }
+
+ public static boolean getHeartrateStressRelaxationReminder(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_RELAXATION_REMINDER, false);
+ }
+
+ public static boolean getBtConnectedAdvertising(String deviceAddress) {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_BT_CONNECTED_ADVERTISEMENT, false);
+ }
+
+ public static boolean getOverwriteSettingsOnConnection(String deviceAddress) {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean("overwrite_settings_on_connection", true);
+ }
+
+ public static boolean getKeepActivityDataOnDevice(String deviceAddress) {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean("keep_activity_data_on_device", false);
+ }
+
+ @Nullable
+ public static VibrationProfile getVibrationProfile(String deviceAddress, HuamiVibrationPatternNotificationType notificationType, boolean nullOnDeviceDefault) {
+ final String defaultVibrationProfileId;
+ final int defaultVibrationCount;
+
+ switch (notificationType) {
+ case APP_ALERTS:
+ defaultVibrationProfileId = VibrationProfile.ID_SHORT;
+ defaultVibrationCount = 2;
+ break;
+ case INCOMING_CALL:
+ defaultVibrationProfileId = VibrationProfile.ID_RING;
+ defaultVibrationCount = 1;
+ break;
+ case INCOMING_SMS:
+ defaultVibrationProfileId = VibrationProfile.ID_STACCATO;
+ defaultVibrationCount = 2;
+ break;
+ case GOAL_NOTIFICATION:
+ defaultVibrationProfileId = VibrationProfile.ID_LONG;
+ defaultVibrationCount = 1;
+ break;
+ case ALARM:
+ defaultVibrationProfileId = VibrationProfile.ID_LONG;
+ defaultVibrationCount = 7;
+ break;
+ case IDLE_ALERTS:
+ defaultVibrationProfileId = VibrationProfile.ID_MEDIUM;
+ defaultVibrationCount = 2;
+ break;
+ case EVENT_REMINDER:
+ defaultVibrationProfileId = VibrationProfile.ID_LONG;
+ defaultVibrationCount = 1;
+ break;
+ case FIND_BAND:
+ defaultVibrationProfileId = VibrationProfile.ID_RING;
+ defaultVibrationCount = 3;
+ break;
+ case TODO_LIST:
+ defaultVibrationProfileId = VibrationProfile.ID_SHORT;
+ defaultVibrationCount = 2;
+ break;
+ case SCHEDULE:
+ defaultVibrationProfileId = VibrationProfile.ID_SHORT;
+ defaultVibrationCount = 2;
+ break;
+ default:
+ defaultVibrationProfileId = VibrationProfile.ID_MEDIUM;
+ defaultVibrationCount = 2;
+ }
+
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ final String vibrationProfileId = prefs.getString(
+ HuamiConst.PREF_HUAMI_VIBRATION_PROFILE_PREFIX + notificationType.name().toLowerCase(Locale.ROOT),
+ HuamiConst.PREF_HUAMI_DEFAULT_VIBRATION_PROFILE
+ );
+
+ if (HuamiConst.PREF_HUAMI_DEFAULT_VIBRATION_PROFILE.equals(vibrationProfileId)) {
+ if (nullOnDeviceDefault) {
+ // Return null, so the device default is used
+ return null;
+ }
+
+ return VibrationProfile.getProfile(defaultVibrationProfileId, (short) defaultVibrationCount);
+ }
+
+ final int vibrationProfileCount = prefs.getInt(HuamiConst.PREF_HUAMI_VIBRATION_COUNT_PREFIX + notificationType.name().toLowerCase(Locale.ROOT), defaultVibrationCount);
+
+ return VibrationProfile.getProfile(vibrationProfileId, (short) vibrationProfileCount);
+ }
+
+ protected static Date getTimePreference(String key, String defaultValue, String deviceAddress) {
+ Prefs prefs;
+
+ if (deviceAddress == null) {
+ prefs = GBApplication.getPrefs();
+ } else {
+ prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ }
+
+ return prefs.getTimePreference(key, defaultValue);
+ }
+
+ public static MiBandConst.DistanceUnit getDistanceUnit() {
+ Prefs prefs = GBApplication.getPrefs();
+ String unit = prefs.getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, GBApplication.getContext().getString(R.string.p_unit_metric));
+ if (unit.equals(GBApplication.getContext().getString(R.string.p_unit_metric))) {
+ return MiBandConst.DistanceUnit.METRIC;
+ } else {
+ return MiBandConst.DistanceUnit.IMPERIAL;
+ }
+ }
+
+ public static DoNotDisturb getDoNotDisturb(String deviceAddress) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+
+ String pref = prefs.getString(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB, DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_OFF);
+
+ return DoNotDisturb.valueOf(pref.toUpperCase(Locale.ROOT));
+ }
+
+ public static boolean getDoNotDisturbLiftWrist(String deviceAddress) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_LIFT_WRIST, false);
+ }
+
+ public static boolean getWorkoutStartOnPhone(String deviceAddress) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_WORKOUT_START_ON_PHONE, false);
+ }
+
+ public static boolean getWorkoutSendGpsToBand(String deviceAddress) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_WORKOUT_SEND_GPS_TO_BAND, false);
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsAlarmSnoozing() {
+ return true;
+ }
+
+ @Override
+ public int getMaximumReminderMessageLength() {
+ return 16;
+ }
+
+ @Override
+ public int getReminderSlotCount(final GBDevice device) {
+ return 22; // At least, Mi Fit still allows more
+ }
+
+ @Override
+ public boolean getReserveReminderSlotsForCalendar() {
+ return !supportsCalendarEvents();
+ }
+
+ public boolean supportsDebugLogs() {
+ return true;
+ }
+
+ public List<HuamiVibrationPatternNotificationType> getVibrationPatternNotificationTypes(final GBDevice device) {
+ return Arrays.asList(
+ HuamiVibrationPatternNotificationType.APP_ALERTS,
+ HuamiVibrationPatternNotificationType.INCOMING_CALL,
+ HuamiVibrationPatternNotificationType.INCOMING_SMS,
+ HuamiVibrationPatternNotificationType.GOAL_NOTIFICATION,
+ HuamiVibrationPatternNotificationType.ALARM,
+ HuamiVibrationPatternNotificationType.IDLE_ALERTS,
+ HuamiVibrationPatternNotificationType.EVENT_REMINDER,
+ HuamiVibrationPatternNotificationType.FIND_BAND
+ );
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new HuamiSettingsCustomizer(device, getVibrationPatternNotificationTypes(device));
+ }
+
+ public static boolean getHourlyChime(String deviceAddress) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HOURLY_CHIME_ENABLE, false);
+ }
+
+ public static Date getHourlyChimeStart(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_HOURLY_CHIME_START, "09:00", deviceAddress);
+ }
+
+ public static Date getHourlyChimeEnd(String deviceAddress) {
+ return getTimePreference(DeviceSettingsPreferenceConst.PREF_HOURLY_CHIME_END, "22:00", deviceAddress);
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_zetime;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_zetime_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiExtendedSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiExtendedSampleProvider.java
new file mode 100644
index 0000000..bd1c317
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiExtendedSampleProvider.java
@@ -0,0 +1,144 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiExtendedActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiExtendedActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class HuamiExtendedSampleProvider extends AbstractSampleProvider<HuamiExtendedActivitySample> {
+ public static final int TYPE_CUSTOM_UNSET = -1;
+ public static final int TYPE_OUTDOOR_RUNNING = 64;
+ public static final int TYPE_NOT_WORN = 115;
+ public static final int TYPE_CHARGING = 118;
+ public static final int TYPE_SLEEP = 120;
+ public static final int TYPE_CUSTOM_DEEP_SLEEP = TYPE_SLEEP + 1;
+ public static final int TYPE_CUSTOM_REM_SLEEP = TYPE_SLEEP + 2;
+
+ public HuamiExtendedSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / 256.0f;
+ }
+
+ @Override
+ public AbstractDao<HuamiExtendedActivitySample, ?> getSampleDao() {
+ return getSession().getHuamiExtendedActivitySampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HuamiExtendedActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HuamiExtendedActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return HuamiExtendedActivitySampleDao.Properties.RawKind;
+ }
+
+ @Override
+ public HuamiExtendedActivitySample createActivitySample() {
+ return new HuamiExtendedActivitySample();
+ }
+
+ @Override
+ protected List<HuamiExtendedActivitySample> getGBActivitySamples(final int timestamp_from, final int timestamp_to) {
+ final List<HuamiExtendedActivitySample> samples = super.getGBActivitySamples(timestamp_from, timestamp_to);
+ postProcess(samples);
+ return samples;
+ }
+
+ private void postProcess(final List<HuamiExtendedActivitySample> samples) {
+ if (samples.isEmpty()) {
+ return;
+ }
+
+ for (final HuamiExtendedActivitySample sample : samples) {
+ if (sample.getRawKind() == TYPE_SLEEP) {
+ // Band reports type sleep regardless of sleep type, so we map it to custom raw types
+ // These thresholds are arbitrary, but seem to somewhat match the data that's displayed on the band
+
+ sample.setDeepSleep(sample.getDeepSleep() & 127);
+ sample.setRemSleep(sample.getRemSleep() & 127);
+
+ if (sample.getRemSleep() > 55) {
+ sample.setRawKind(TYPE_CUSTOM_REM_SLEEP);
+ sample.setRawIntensity(sample.getRemSleep());
+ } else if (sample.getDeepSleep() > 42) {
+ sample.setRawKind(TYPE_CUSTOM_DEEP_SLEEP);
+ sample.setRawIntensity(sample.getDeepSleep());
+ } else {
+ sample.setRawIntensity(sample.getSleep());
+ }
+ }
+ }
+ }
+
+ @Override
+ public ActivityKind normalizeType(final int rawType) {
+ switch (rawType) {
+ case TYPE_OUTDOOR_RUNNING:
+ return ActivityKind.RUNNING;
+ case TYPE_NOT_WORN:
+ case TYPE_CHARGING:
+ return ActivityKind.NOT_WORN;
+ case TYPE_SLEEP:
+ return ActivityKind.LIGHT_SLEEP;
+ case TYPE_CUSTOM_DEEP_SLEEP:
+ return ActivityKind.DEEP_SLEEP;
+ case TYPE_CUSTOM_REM_SLEEP:
+ return ActivityKind.REM_SLEEP;
+ }
+
+ return ActivityKind.UNKNOWN;
+ }
+
+ @Override
+ public int toRawActivityKind(final ActivityKind activityKind) {
+ switch (activityKind) {
+ case RUNNING:
+ return TYPE_OUTDOOR_RUNNING;
+ case NOT_WORN:
+ return TYPE_NOT_WORN;
+ case LIGHT_SLEEP:
+ case DEEP_SLEEP:
+ case REM_SLEEP:
+ return TYPE_SLEEP;
+ }
+
+ return TYPE_CUSTOM_UNSET;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiFWHelper.java
new file mode 100644
index 0000000..6d7248c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiFWHelper.java
@@ -0,0 +1,109 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import androidx.annotation.NonNull;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiFirmwareInfo;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareType;
+
+public abstract class HuamiFWHelper extends AbstractMiBandFWHelper {
+ protected AbstractHuamiFirmwareInfo firmwareInfo;
+
+ public HuamiFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ public String format(int version) {
+ return firmwareInfo.toVersion(version);
+ }
+
+ @NonNull
+ @Override
+ public String getFirmwareKind() {
+ return GBApplication.getContext().getString(getFirmwareInfo().getFirmwareType().getNameResId());
+ }
+
+ @Override
+ public int getFirmwareVersion() {
+ return firmwareInfo.getFirmwareVersion();
+ }
+
+ @Override
+ public int getFirmware2Version() {
+ return 0;
+ }
+
+ @Override
+ public String getHumanFirmwareVersion2() {
+ return "";
+ }
+
+ @Override
+ protected int[] getWhitelistedFirmwareVersions() {
+ return firmwareInfo.getWhitelistedVersions();
+ }
+
+ @Override
+ public boolean isFirmwareGenerallyCompatibleWith(GBDevice device) {
+ return firmwareInfo.isGenerallyCompatibleWith(device);
+ }
+
+ @Override
+ public boolean isSingleFirmware() {
+ return true;
+ }
+
+ @Override
+ public void checkValid() throws IllegalArgumentException {
+ firmwareInfo.checkValid();
+ }
+
+ @Override
+ public HuamiFirmwareType getFirmwareType() {
+ return firmwareInfo.getFirmwareType();
+ }
+
+ @Override
+ public void unsetFwBytes() {
+ super.unsetFwBytes();
+ firmwareInfo.unsetFwBytes();
+ }
+
+ public AbstractHuamiFirmwareInfo getFirmwareInfo() {
+ return firmwareInfo;
+ }
+
+ @Override
+ public Bitmap getPreview() {
+ if (firmwareInfo != null) {
+ return firmwareInfo.getPreview();
+ }
+
+ return null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateManualSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateManualSampleProvider.java
new file mode 100644
index 0000000..8d066d2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateManualSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiHeartRateManualSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiHeartRateManualSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class HuamiHeartRateManualSampleProvider extends AbstractTimeSampleProvider<HuamiHeartRateManualSample> {
+ public HuamiHeartRateManualSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<HuamiHeartRateManualSample, ?> getSampleDao() {
+ return getSession().getHuamiHeartRateManualSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HuamiHeartRateManualSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HuamiHeartRateManualSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public HuamiHeartRateManualSample createSample() {
+ return new HuamiHeartRateManualSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateMaxSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateMaxSampleProvider.java
new file mode 100644
index 0000000..0f902ef
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateMaxSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiHeartRateMaxSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiHeartRateMaxSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class HuamiHeartRateMaxSampleProvider extends AbstractTimeSampleProvider<HuamiHeartRateMaxSample> {
+ public HuamiHeartRateMaxSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<HuamiHeartRateMaxSample, ?> getSampleDao() {
+ return getSession().getHuamiHeartRateMaxSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HuamiHeartRateMaxSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HuamiHeartRateMaxSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public HuamiHeartRateMaxSample createSample() {
+ return new HuamiHeartRateMaxSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateRestingSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateRestingSampleProvider.java
new file mode 100644
index 0000000..46ff142
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiHeartRateRestingSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiHeartRateRestingSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiHeartRateRestingSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class HuamiHeartRateRestingSampleProvider extends AbstractTimeSampleProvider<HuamiHeartRateRestingSample> {
+ public HuamiHeartRateRestingSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<HuamiHeartRateRestingSample, ?> getSampleDao() {
+ return getSession().getHuamiHeartRateRestingSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HuamiHeartRateRestingSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HuamiHeartRateRestingSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public HuamiHeartRateRestingSample createSample() {
+ return new HuamiHeartRateRestingSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiPaiSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiPaiSampleProvider.java
new file mode 100644
index 0000000..39d5738
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiPaiSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiPaiSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiPaiSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class HuamiPaiSampleProvider extends AbstractTimeSampleProvider<HuamiPaiSample> {
+ public HuamiPaiSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<HuamiPaiSample, ?> getSampleDao() {
+ return getSession().getHuamiPaiSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HuamiPaiSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HuamiPaiSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public HuamiPaiSample createSample() {
+ return new HuamiPaiSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java
new file mode 100644
index 0000000..2686c06
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java
@@ -0,0 +1,260 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, gsbhat, Jos茅 Rebelo, NekoBox
+
+ 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.devices.huami;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport.BASE_UUID;
+
+public class HuamiService {
+
+
+ public static final UUID UUID_SERVICE_MIBAND_SERVICE = UUID.fromString(String.format(BASE_UUID, "FEE0"));
+ public static final UUID UUID_SERVICE_MIBAND2_SERVICE = UUID.fromString(String.format(BASE_UUID, "FEE1"));
+ public static final UUID UUID_SERVICE_HEART_RATE = UUID.fromString(String.format(BASE_UUID, "180D"));
+ public static final UUID UUID_SERVICE_FIRMWARE_SERVICE = UUID.fromString("00001530-0000-3512-2118-0009af100700");
+
+ public static final UUID UUID_CHARACTERISTIC_FIRMWARE = UUID.fromString("00001531-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_FIRMWARE_DATA = UUID.fromString("00001532-0000-3512-2118-0009af100700");
+
+ public static final UUID UUID_UNKNOWN_CHARACTERISTIC0 = UUID.fromString("00000000-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_RAW_SENSOR_CONTROL = UUID.fromString("00000001-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_RAW_SENSOR_DATA = UUID.fromString("00000002-0000-3512-2118-0009af100700");
+
+ /**
+ * Alarms, Display and other configuration.
+ */
+ public static final UUID UUID_CHARACTERISTIC_3_CONFIGURATION = UUID.fromString("00000003-0000-3512-2118-0009af100700");
+ public static final UUID UUID_UNKNOWN_CHARACTERISTIC4 = UUID.fromString("00000004-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_5_ACTIVITY_DATA = UUID.fromString("00000005-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_6_BATTERY_INFO = UUID.fromString("00000006-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_7_REALTIME_STEPS = UUID.fromString("00000007-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_8_USER_SETTINGS = UUID.fromString("00000008-0000-3512-2118-0009af100700");
+ // service uuid fee1
+ public static final UUID UUID_CHARACTERISTIC_AUTH = UUID.fromString("00000009-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_WORKOUT = UUID.fromString("0000000f-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_DEVICEEVENT = UUID.fromString("00000010-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_AUDIO = UUID.fromString("00000012-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_AUDIODATA = UUID.fromString("00000013-0000-3512-2118-0009af100700");
+ public static final UUID UUID_UNKNOWN_CHARACTERISTIC5 = UUID.fromString("00000014-0000-3512-2118-0009af100700");
+
+ public static final UUID UUID_CHARACTERISTIC_CHUNKEDTRANSFER_2021_WRITE = UUID.fromString("00000016-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_CHUNKEDTRANSFER_2021_READ = UUID.fromString("00000017-0000-3512-2118-0009af100700");
+ public static final UUID UUID_CHARACTERISTIC_CHUNKEDTRANSFER = UUID.fromString("00000020-0000-3512-2118-0009af100700");
+
+ public static final UUID UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3 = UUID.fromString("00000023-0000-3512-2118-0009af100700");
+
+ public static final int ALERT_LEVEL_NONE = 0;
+ public static final int ALERT_LEVEL_MESSAGE = 1;
+ public static final int ALERT_LEVEL_PHONE_CALL = 2;
+ public static final int ALERT_LEVEL_VIBRATE_ONLY = 3;
+
+
+ // set metric distance
+ // set 12 hour time mode
+
+
+
+ private static final Map<UUID, String> MIBAND_DEBUG;
+
+ /**
+ * Mi Band 2 authentication has three steps.
+ * This is step 1: sending a "secret" key to the band.
+ * This is byte 0, followed by {@link #AUTH_BYTE} and then the key.
+ * In the response, it is byte 1 in the byte[] value.
+ */
+ public static final byte AUTH_SEND_KEY = 0x01;
+ /**
+ * Mi Band 2 authentication has three steps.
+ * This is step 2: requesting a random authentication key from the band.
+ * This is byte 0, followed by {@link #AUTH_BYTE}.
+ * In the response, it is byte 1 in the byte[] value.
+ */
+ public static final byte AUTH_REQUEST_RANDOM_AUTH_NUMBER = 0x02;
+ /**
+ * Mi Band 2 authentication has three steps.
+ * This is step 3: sending the encrypted random authentication key to the band.
+ * This is byte 0, followed by {@link #AUTH_BYTE} and then the encrypted random authentication key.
+ * In the response, it is byte 1 in the byte[] value.
+ */
+ public static final byte AUTH_SEND_ENCRYPTED_AUTH_NUMBER = 0x03;
+
+ /**
+ * Received in response to any authentication requests (byte 0 in the byte[] value.
+ */
+ public static final byte AUTH_RESPONSE = 0x10;
+ /**
+ * Received in response to any authentication requests (byte 2 in the byte[] value.
+ * 0x01 means success.
+ */
+ public static final byte AUTH_SUCCESS = 0x01;
+ /**
+ * Received in response to any authentication requests (byte 2 in the byte[] value.
+ * 0x04 means failure.
+ */
+ public static final byte AUTH_FAIL = 0x04;
+ /**
+ * In some logs it's 0x0...
+ */
+ public static final byte AUTH_BYTE = 0x08;
+
+ // maybe not really activity data, but steps?
+ public static final byte COMMAND_FETCH_DATA = 0x02;
+ // delete/drop activity data
+ // on Huami it's just the single 03 byte
+ // on Huami 2021 it's followed by 09 to keep, 01 to drop from device
+ public static final byte COMMAND_ACK_ACTIVITY_DATA = 0x03;
+
+ public static final byte[] COMMAND_SET_FITNESS_GOAL_START = new byte[] { 0x10, 0x0, 0x0 };
+ public static final byte[] COMMAND_SET_FITNESS_GOAL_END = new byte[] { 0, 0 };
+
+ public static final byte COMMAND_SET_USERINFO = 0x4f;
+
+ public static final byte ICON_HIGH_PRIORITY = 0x7;
+
+ public static byte ENDPOINT_DISPLAY_ITEMS = 0x0a;
+
+ public static byte DISPLAY_ITEM_BIT_CLOCK = 0x01;
+ public static byte DISPLAY_ITEM_BIT_STEPS = 0x02;
+ public static byte DISPLAY_ITEM_BIT_DISTANCE = 0x04;
+ public static byte DISPLAY_ITEM_BIT_CALORIES= 0x08;
+ public static byte DISPLAY_ITEM_BIT_HEART_RATE = 0x10;
+ public static byte DISPLAY_ITEM_BIT_BATTERY = 0x20;
+
+ // Second byte must be a bitwise OR combination of the above
+ // The clock can't be disabled
+ public static int SCREEN_CHANGE_BYTE = 1;
+ public static final byte[] COMMAND_CHANGE_SCREENS = new byte[]{ENDPOINT_DISPLAY_ITEMS, DISPLAY_ITEM_BIT_CLOCK, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05};
+
+ public static byte ENDPOINT_DISPLAY = 0x06;
+
+ public static final byte[] DATEFORMAT_DATE_TIME = new byte[] {ENDPOINT_DISPLAY, 0x0a, 0x0, 0x03 };
+ public static final byte[] DATEFORMAT_TIME = new byte[] {ENDPOINT_DISPLAY, 0x0a, 0x0, 0x0 };
+ public static final byte[] DATEFORMAT_TIME_12_HOURS = new byte[] {ENDPOINT_DISPLAY, 0x02, 0x0, 0x0 };
+ public static final byte[] DATEFORMAT_TIME_24_HOURS = new byte[] {ENDPOINT_DISPLAY, 0x02, 0x0, 0x1 };
+ public static final byte[] DATEFORMAT_DATE_MM_DD_YYYY = new byte[]{ENDPOINT_DISPLAY, 30, 0x00, 'M', 'M', '/', 'd', 'd', '/', 'y', 'y', 'y', 'y'};
+ public static final byte[] COMMAND_ENBALE_HR_CONNECTION = new byte[]{ENDPOINT_DISPLAY, 0x1f, 0x00, 0x01};
+ public static final byte[] COMMAND_DISABLE_HR_CONNECTION = new byte[]{ENDPOINT_DISPLAY, 0x1f, 0x00, 0x00};
+ public static final byte[] COMMAND_ENABLE_BT_CONNECTED_ADVERTISEMENT = new byte[]{ENDPOINT_DISPLAY, 0x01, 0x00, 0x01};
+ public static final byte[] COMMAND_DISABLE_BT_CONNECTED_ADVERTISEMENT = new byte[]{ENDPOINT_DISPLAY, 0x01, 0x00, 0x00};
+ public static final byte[] COMMAND_ENABLE_DISPLAY_ON_LIFT_WRIST = new byte[]{ENDPOINT_DISPLAY, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00};
+ public static final byte[] COMMAND_DISABLE_DISPLAY_ON_LIFT_WRIST = new byte[]{ENDPOINT_DISPLAY, 0x05, 0x00, 0x00};
+ public static final byte[] COMMAND_SCHEDULE_DISPLAY_ON_LIFT_WRIST = new byte[]{ENDPOINT_DISPLAY, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00};
+ public static final byte[] COMMAND_DISPLAY_ON_LIFT_WRIST_SPEED_NORMAL = new byte[]{ENDPOINT_DISPLAY, 0x23, 0x00, 0x00};
+ public static final byte[] COMMAND_DISPLAY_ON_LIFT_WRIST_SPEED_SENSITIVE = new byte[]{ENDPOINT_DISPLAY, 0x23, 0x00, 0x01};
+ public static final byte[] COMMAND_ENABLE_GOAL_NOTIFICATION = new byte[]{ENDPOINT_DISPLAY, 0x06, 0x00, 0x01};
+ public static final byte[] COMMAND_DISABLE_GOAL_NOTIFICATION = new byte[]{ENDPOINT_DISPLAY, 0x06, 0x00, 0x00};
+ public static final byte[] COMMAND_ENABLE_ROTATE_WRIST_TO_SWITCH_INFO = new byte[]{ENDPOINT_DISPLAY, 0x0d, 0x00, 0x01};
+ public static final byte[] COMMAND_DISABLE_ROTATE_WRIST_TO_SWITCH_INFO = new byte[]{ENDPOINT_DISPLAY, 0x0d, 0x00, 0x00};
+ public static final byte[] COMMAND_ENABLE_DISPLAY_CALLER = new byte[]{ENDPOINT_DISPLAY, 0x10, 0x00, 0x00, 0x01};
+ public static final byte[] COMMAND_DISABLE_DISPLAY_CALLER = new byte[]{ENDPOINT_DISPLAY, 0x10, 0x00, 0x00, 0x00};
+ public static final byte[] DISPLAY_YYY = new byte[] {ENDPOINT_DISPLAY, 0x10, 0x0, 0x1, 0x1 };
+ public static final byte[] COMMAND_DISTANCE_UNIT_METRIC = new byte[] { ENDPOINT_DISPLAY, 0x03, 0x00, 0x00 };
+ public static final byte[] COMMAND_DISTANCE_UNIT_IMPERIAL = new byte[] { ENDPOINT_DISPLAY, 0x03, 0x00, 0x01 };
+ public static final byte[] COMMAND_SET_LANGUAGE_NEW_TEMPLATE = new byte[]{ENDPOINT_DISPLAY, 0x17, 0x00, 0, 0, 0, 0, 0};
+ public static final byte[] COMMAND_FACTORY_RESET = new byte[]{ENDPOINT_DISPLAY, 0x0b, 0x00, 0x01};
+ public static final byte[] COMMAND_ENABLE_DISCONNECT_NOTIFCATION = new byte[]{ENDPOINT_DISPLAY, 0x0c, 0x00, 0x01, 0, 0, 0, 0};
+ public static final byte[] COMMAND_DISABLE_DISCONNECT_NOTIFCATION = new byte[]{ENDPOINT_DISPLAY, 0x0c, 0x00, 0x00, 0, 0, 0, 0};
+
+ public static final byte COMMAND_ALARMS = 0x0d;
+ public static final byte COMMAND_ALARMS_WITH_TIMES = 0x01;
+ public static final byte COMMAND_WORKOUT_ACTIVITY_TYPES = 0x11;
+ public static final byte COMMAND_GPS_VERSION = 0x0e;
+
+ public static final byte[] COMMAND_REQUEST_ALARMS = new byte[]{COMMAND_ALARMS};
+ public static final byte[] COMMAND_REQUEST_ALARMS_WITH_TIMES = new byte[]{(byte) 0xff, COMMAND_ALARMS_WITH_TIMES, 0x00, 0x00, 0x00};
+ public static final byte[] COMMAND_REQUEST_WORKOUT_ACTIVITY_TYPES = new byte[]{(byte) 0xff, COMMAND_WORKOUT_ACTIVITY_TYPES, 0x00, 0x00, 0x00};
+
+ public static final byte[] COMMAND_REQUEST_GPS_VERSION = new byte[]{COMMAND_GPS_VERSION};
+
+ // The third byte controls the threshold, in minutes
+ // The last 8 bytes represent 2 separate time intervals for the inactivity warnings
+ // If there is no do not disturb interval, the last 4 bytes (the second interval) are 0
+ // and only the first interval of the command is used
+ public static int INACTIVITY_WARNINGS_THRESHOLD = 2;
+ public static int INACTIVITY_WARNINGS_INTERVAL_1_START_HOURS = 4;
+ public static int INACTIVITY_WARNINGS_INTERVAL_1_START_MINUTES = 5;
+ public static int INACTIVITY_WARNINGS_INTERVAL_1_END_HOURS = 6;
+ public static int INACTIVITY_WARNINGS_INTERVAL_1_END_MINUTES = 7;
+ public static int INACTIVITY_WARNINGS_INTERVAL_2_START_HOURS = 8;
+ public static int INACTIVITY_WARNINGS_INTERVAL_2_START_MINUTES = 9;
+ public static int INACTIVITY_WARNINGS_INTERVAL_2_END_HOURS = 10;
+ public static int INACTIVITY_WARNINGS_INTERVAL_2_END_MINUTES = 11;
+ public static final byte[] COMMAND_ENABLE_INACTIVITY_WARNINGS = new byte[] { 0x08, 0x01, 0x3c, 0x00, 0x04, 0x00, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00 };
+ public static final byte[] COMMAND_DISABLE_INACTIVITY_WARNINGS = new byte[] { 0x08, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00 };
+
+ public static byte ENDPOINT_DND = 0x09;
+
+ public static final byte[] COMMAND_DO_NOT_DISTURB_AUTOMATIC = new byte[] { ENDPOINT_DND, (byte) 0x83 };
+ public static final byte[] COMMAND_DO_NOT_DISTURB_OFF = new byte[] { ENDPOINT_DND, (byte) 0x82 };
+ public static final byte[] COMMAND_DO_NOT_DISTURB_SCHEDULED = new byte[] { ENDPOINT_DND, (byte) 0x81, 0x01, 0x00, 0x06, 0x00 };
+ // The 4 last bytes set the start and end time in 24h format
+ public static byte DND_BYTE_START_HOURS = 2;
+ public static byte DND_BYTE_START_MINUTES = 3;
+ public static byte DND_BYTE_END_HOURS = 4;
+ public static byte DND_BYTE_END_MINUTES = 5;
+
+ public static final byte MUSIC_FLAG_STATE = 0x01;
+ public static final byte MUSIC_FLAG_ARTIST = 0x02;
+ public static final byte MUSIC_FLAG_ALBUM = 0x04;
+ public static final byte MUSIC_FLAG_TRACK = 0x08;
+ public static final byte MUSIC_FLAG_DURATION = 0x10;
+ public static final byte MUSIC_FLAG_NOTHING_PLAYING = 0x20;
+ public static final byte MUSIC_FLAG_VOLUME = 0x40;
+
+ public static final byte RESPONSE = 0x10;
+
+ public static final byte SUCCESS = 0x01;
+ public static final byte COMMAND_ACTIVITY_DATA_START_DATE = 0x01;
+ public static final byte COMMAND_ACTIVITY_DATA_TYPE_ACTIVTY = 0x01;
+ public static final byte COMMAND_ACTIVITY_DATA_XXX_DATE = 0x02; // issued on first connect, followd by COMMAND_XXXX_ACTIVITY_DATA instead of COMMAND_FETCH_DATA
+
+ public static final byte COMMAND_FIRMWARE_INIT = 0x01; // to UUID_CHARACTERISTIC_FIRMWARE, followed by fw file size in bytes
+ public static final byte COMMAND_FIRMWARE_START_DATA = 0x03; // to UUID_CHARACTERISTIC_FIRMWARE
+ public static final byte COMMAND_FIRMWARE_UPDATE_SYNC = 0x00; // to UUID_CHARACTERISTIC_FIRMWARE
+ public static final byte COMMAND_FIRMWARE_CHECKSUM = 0x04; // to UUID_CHARACTERISTIC_FIRMWARE
+ public static final byte COMMAND_FIRMWARE_REBOOT = 0x05; // to UUID_CHARACTERISTIC_FIRMWARE
+
+ public static final byte[] WEAR_LOCATION_LEFT_WRIST = new byte[] { 0x20, 0x00, 0x00, 0x02 };
+ public static final byte[] WEAR_LOCATION_RIGHT_WRIST = new byte[] { 0x20, 0x00, 0x00, (byte) 0x82};
+
+ public static final byte[] COMMAND_ENABLE_HR_SLEEP_MEASUREMENT = new byte[]{0x15, 0x00, 0x01};
+ public static final byte[] COMMAND_DISABLE_HR_SLEEP_MEASUREMENT = new byte[]{0x15, 0x00, 0x00};
+
+ public static final byte COMMAND_SET_PERIODIC_HR_MEASUREMENT_INTERVAL = 0x14;
+
+ public static final byte[] COMMAND_TEXT_NOTIFICATION = new byte[] {0x05, 0x01};
+
+ public static final byte[] COMMAND_ENABLE_HOURLY_CHIME = new byte[] { (byte) 0xfe, 0x0b, 0x00, 0x01, 0x0a, 0x00, 0x16, 0x00 };
+ public static final byte[] COMMAND_DISABLE_HOURLY_CHIME = new byte[] { (byte) 0xfe, 0x0b, 0x00, 0x00 };
+
+ static {
+ MIBAND_DEBUG = new HashMap<>();
+ MIBAND_DEBUG.put(UUID_SERVICE_MIBAND_SERVICE, "MiBand Service");
+ MIBAND_DEBUG.put(UUID_SERVICE_HEART_RATE, "MiBand HR Service");
+ }
+
+ public static String lookup(UUID uuid, String fallback) {
+ String name = MIBAND_DEBUG.get(uuid);
+ if (name == null) {
+ name = fallback;
+ }
+ return name;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java
new file mode 100644
index 0000000..dd8dfe5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java
@@ -0,0 +1,135 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.huami;
+
+import android.os.Parcel;
+import android.text.InputType;
+
+import androidx.preference.Preference;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiVibrationPatternNotificationType;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class HuamiSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ final GBDevice device;
+ final List<HuamiVibrationPatternNotificationType> vibrationPatternNotificationTypes;
+
+ public HuamiSettingsCustomizer(final GBDevice device,
+ final List<HuamiVibrationPatternNotificationType> vibrationPatternNotificationTypes) {
+ this.device = device;
+ this.vibrationPatternNotificationTypes = vibrationPatternNotificationTypes;
+ }
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ // Nothing to do here
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ final Preference hrAlertActivePref = handler.findPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD);
+ if (hrAlertActivePref != null) {
+ hrAlertActivePref.setVisible(false);
+ }
+
+ // Setup the vibration patterns for all supported notification types
+ for (HuamiVibrationPatternNotificationType notificationType : HuamiVibrationPatternNotificationType.values()) {
+ final String typeKey = notificationType.name().toLowerCase(Locale.ROOT);
+
+ // Hide unsupported notification types
+ if (!vibrationPatternNotificationTypes.contains(notificationType)) {
+ final String screenKey = HuamiConst.PREF_HUAMI_VIBRATION_PROFILE_KEY_PREFIX + typeKey;
+ final Preference pref = handler.findPreference(screenKey);
+ if (pref != null) {
+ pref.setVisible(false);
+ }
+ continue;
+ }
+
+ handler.addPreferenceHandlerFor(HuamiConst.PREF_HUAMI_VIBRATION_PROFILE_PREFIX + typeKey);
+ handler.addPreferenceHandlerFor(HuamiConst.PREF_HUAMI_VIBRATION_COUNT_PREFIX + typeKey);
+ handler.setInputTypeFor(HuamiConst.PREF_HUAMI_VIBRATION_COUNT_PREFIX + typeKey, InputType.TYPE_CLASS_NUMBER);
+
+ // Setup the try pref to vibrate the device
+ final String tryPrefKey = HuamiConst.PREF_HUAMI_VIBRATION_TRY_PREFIX + typeKey;
+ final Preference tryPref = handler.findPreference(tryPrefKey);
+ if (tryPref != null) {
+ tryPref.setOnPreferenceClickListener(preference -> {
+ GBApplication.deviceService(device).onSendConfiguration(tryPrefKey);
+ return true;
+ });
+ }
+
+ // Setup the default preference - disable count if default preference is selected
+ final String profilePrefKey = HuamiConst.PREF_HUAMI_VIBRATION_PROFILE_PREFIX + typeKey;
+ final String countPrefKey = HuamiConst.PREF_HUAMI_VIBRATION_COUNT_PREFIX + typeKey;
+ final Preference countPref = handler.findPreference(countPrefKey);
+
+ final Preference.OnPreferenceChangeListener profilePrefListener = (preference, newValue) -> {
+ if (countPref != null) {
+ countPref.setEnabled(!HuamiConst.PREF_HUAMI_DEFAULT_VIBRATION_PROFILE.equals(newValue));
+ }
+ return true;
+ };
+
+ profilePrefListener.onPreferenceChange(null, prefs.getString(profilePrefKey, HuamiConst.PREF_HUAMI_DEFAULT_VIBRATION_PROFILE));
+ handler.addPreferenceHandlerFor(profilePrefKey, profilePrefListener);
+ }
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return new HashSet<>();
+ }
+
+ public static final Creator<HuamiSettingsCustomizer> CREATOR = new Creator<HuamiSettingsCustomizer>() {
+ @Override
+ public HuamiSettingsCustomizer createFromParcel(final Parcel in) {
+ final GBDevice device = in.readParcelable(HuamiSettingsCustomizer.class.getClassLoader());
+ final List<HuamiVibrationPatternNotificationType> vibrationPatternNotificationTypes = new ArrayList<>();
+ in.readList(vibrationPatternNotificationTypes, HuamiVibrationPatternNotificationType.class.getClassLoader());
+ return new HuamiSettingsCustomizer(device, vibrationPatternNotificationTypes);
+ }
+
+ @Override
+ public HuamiSettingsCustomizer[] newArray(final int size) {
+ return new HuamiSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeParcelable(device, 0);
+ dest.writeList(vibrationPatternNotificationTypes);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSleepRespiratoryRateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSleepRespiratoryRateSampleProvider.java
new file mode 100644
index 0000000..891a665
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSleepRespiratoryRateSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSleepRespiratoryRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSleepRespiratoryRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class HuamiSleepRespiratoryRateSampleProvider extends AbstractTimeSampleProvider<HuamiSleepRespiratoryRateSample> {
+ public HuamiSleepRespiratoryRateSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<HuamiSleepRespiratoryRateSample, ?> getSampleDao() {
+ return getSession().getHuamiSleepRespiratoryRateSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HuamiSleepRespiratoryRateSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HuamiSleepRespiratoryRateSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public HuamiSleepRespiratoryRateSample createSample() {
+ return new HuamiSleepRespiratoryRateSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSpo2SampleProvider.java
new file mode 100644
index 0000000..af36d0f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSpo2SampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class HuamiSpo2SampleProvider extends AbstractTimeSampleProvider<HuamiSpo2Sample> {
+ public HuamiSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<HuamiSpo2Sample, ?> getSampleDao() {
+ return getSession().getHuamiSpo2SampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HuamiSpo2SampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HuamiSpo2SampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public HuamiSpo2Sample createSample() {
+ return new HuamiSpo2Sample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiStressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiStressSampleProvider.java
new file mode 100644
index 0000000..dae4512
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiStressSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiStressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class HuamiStressSampleProvider extends AbstractTimeSampleProvider<HuamiStressSample> {
+ public HuamiStressSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<HuamiStressSample, ?> getSampleDao() {
+ return getSession().getHuamiStressSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HuamiStressSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HuamiStressSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public HuamiStressSample createSample() {
+ return new HuamiStressSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiWeatherConditions.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiWeatherConditions.java
new file mode 100644
index 0000000..cea1963
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiWeatherConditions.java
@@ -0,0 +1,160 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa
+
+ 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.devices.huami;
+
+
+public class HuamiWeatherConditions {
+ public static final byte CLEAR_SKY = 0;
+ public static final byte SCATTERED_CLOUDS = 1;
+ public static final byte CLOUDY = 2;
+ public static final byte RAIN_WITH_SUN = 3;
+ public static final byte THUNDERSTORM = 4;
+ public static final byte HAIL = 5;
+ public static final byte RAIN_AND_SNOW = 6;
+ public static final byte LIGHT_RAIN = 7;
+ public static final byte MEDIUM_RAIN = 8;
+ public static final byte HEAVY_RAIN = 9;
+ public static final byte EXTREME_RAIN = 10;
+ public static final byte SUPER_EXTREME_RAIN = 11;
+ public static final byte TORRENTIAL_RAIN = 12;
+ public static final byte SNOW_AND_SUN = 13;
+ public static final byte LIGHT_SNOW = 14;
+ public static final byte MEDIUM_SNOW = 15;
+ public static final byte HEAVY_SNOW = 16;
+ public static final byte EXTREME_SNOW = 17;
+ public static final byte MIST = 18;
+ public static final byte DRIZZLE = 19;
+ public static final byte WIND_AND_RAIN = 20;
+ // 21- various types of rain
+
+ public static byte mapToAmazfitBipWeatherCode(int openWeatherMapCondition) {
+ // openweathermap.org conditions:
+ // http://openweathermap.org/weather-conditions
+ switch (openWeatherMapCondition) {
+//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 230: //thunderstorm with light drizzle: //11d
+ case 231: //thunderstorm with drizzle: //11d
+ case 232: //thunderstorm with heavy drizzle: //11d
+ case 212: //heavy thunderstorm: //11d
+ case 221: //ragged thunderstorm: //11d
+ return THUNDERSTORM;
+//Group 3xx: Drizzle
+ case 300: //light intensity drizzle: //09d
+ case 301: //drizzle: //09d
+ case 302: //heavy intensity drizzle: //09d
+ case 310: //light intensity drizzle rain: //09d
+ case 311: //drizzle rain: //09d
+ case 312: //heavy intensity drizzle rain: //09d
+ case 313: //shower rain and drizzle: //09d
+ case 314: //heavy shower rain and drizzle: //09d
+ case 321: //shower drizzle: //09d
+ return DRIZZLE;
+//Group 5xx: Rain
+ case 500: //light rain: //10d
+ return LIGHT_RAIN;
+ case 501: //moderate rain: //10d
+ return MEDIUM_RAIN;
+ case 502: //heavy intensity rain: //10d
+ return HEAVY_RAIN;
+ case 503: //very heavy rain: //10d
+ return EXTREME_RAIN;
+ case 504: //extreme rain: //10d
+ return TORRENTIAL_RAIN;
+ case 511: //freezing rain: //13d
+ return MEDIUM_RAIN;
+ case 520: //light intensity shower rain: //09d
+ return LIGHT_RAIN;
+ case 521: //shower rain: //09d
+ return MEDIUM_RAIN;
+ case 522: //heavy intensity shower rain: //09d
+ return HEAVY_RAIN;
+ case 531: //ragged shower rain: //09d
+ return MEDIUM_RAIN;
+//Group 6xx: Snow
+ case 600: //light snow: //[[file:13d.png]]
+ return LIGHT_SNOW;
+ case 601: //snow: //[[file:13d.png]]
+ return MEDIUM_SNOW;
+ case 602: //heavy snow: //[[file:13d.png]]
+ return HEAVY_SNOW;
+ case 611: //sleet: //[[file:13d.png]]
+ case 612: //shower sleet: //[[file:13d.png]]
+ case 615: //light rain and snow: //[[file:13d.png]]
+ case 616: //rain and snow: //[[file:13d.png]]
+ case 620: //light shower snow: //[[file:13d.png]]
+ case 621: //shower snow: //[[file:13d.png]]
+ case 622: //heavy shower snow: //[[file:13d.png]]
+ return RAIN_AND_SNOW;
+
+//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 741: //fog: //[[file:50d.png]]
+ case 751: //sand: //[[file:50d.png]]
+ case 761: //dust: //[[file:50d.png]]
+ case 762: //volcanic ash: //[[file:50d.png]]
+ case 771: //squalls: //[[file:50d.png]]
+ return MIST;
+ case 781: //tornado: //[[file:50d.png]]
+ case 900: //tornado
+ return WIND_AND_RAIN;
+//Group 800: Clear
+ case 800: //clear sky: //[[file:01d.png]] [[file:01n.png]]
+ return CLEAR_SKY;
+//Group 80x: Clouds
+ case 801: //few clouds: //[[file:02d.png]] [[file:02n.png]]
+ case 802: //scattered clouds: //[[file:03d.png]] [[file:03d.png]]
+ case 803: //broken clouds: //[[file:04d.png]] [[file:03d.png]]
+ return SCATTERED_CLOUDS;
+ case 804: //overcast clouds: //[[file:04d.png]] [[file:04d.png]]
+ return CLOUDY;
+//Group 90x: Extreme
+ case 901: //tropical storm
+ return WIND_AND_RAIN;
+ case 903: //cold
+ case 904: //hot
+ case 905: //windy
+ return 0;
+ case 906: //hail
+ return HAIL;
+//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:
+ return 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitactive/AmazfitActiveCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitactive/AmazfitActiveCoordinator.java
new file mode 100644
index 0000000..0b76f98
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitactive/AmazfitActiveCoordinator.java
@@ -0,0 +1,86 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitactive;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitActiveCoordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_ACTIVE_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 8323328, // chinese mainland version
+ 8323329
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_active;
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean mainMenuHasMoreSection() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return true;
+ }
+
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitactiveedge/AmazfitActiveEdgeCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitactiveedge/AmazfitActiveEdgeCoordinator.java
new file mode 100644
index 0000000..f82d935
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitactiveedge/AmazfitActiveEdgeCoordinator.java
@@ -0,0 +1,90 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitactiveedge;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitActiveEdgeCoordinator extends ZeppOsCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_ACTIVE_EDGE_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 8388864, // chinese mainland version
+ 8388865
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_active_edge;
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean mainMenuHasMoreSection() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return true;
+ }
+
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbalance/AmazfitBalanceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbalance/AmazfitBalanceCoordinator.java
new file mode 100644
index 0000000..676cac8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbalance/AmazfitBalanceCoordinator.java
@@ -0,0 +1,86 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo, Maxime Reyrolle
+
+ 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.devices.huami.amazfitbalance;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitBalanceCoordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_BALANCE_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 8519936, // chinese mainland version
+ 8519937,
+ 8519939
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_balance;
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean mainMenuHasMoreSection() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return true;
+ }
+
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5Coordinator.java
new file mode 100644
index 0000000..87761de
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5Coordinator.java
@@ -0,0 +1,188 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Arjan Schrijver, Daniel Dakhno,
+ Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.huami.amazfitband5;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitband5.AmazfitBand5Support;
+
+public class AmazfitBand5Coordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuamiConst.AMAZFIT_BAND5_NAME, Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitBand5FWInstallHandler handler = new AmazfitBand5FWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 20; // as enforced by Mi Fit
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ return 30; // at least
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_dateformat);
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitband5);
+ display.add(R.xml.devicesettings_nightmode);
+ display.add(R.xml.devicesettings_liftwrist_display_sensitivity);
+ display.add(R.xml.devicesettings_swipeunlock);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep_alert_activity_stress);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> workout = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.WORKOUT);
+ workout.add(R.xml.devicesettings_workout_start_on_phone);
+ workout.add(R.xml.devicesettings_workout_send_gps_to_band);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "ar_SA",
+ "cs_CZ",
+ "de_DE",
+ "el_GR",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "he_IL",
+ "id_ID",
+ "it_IT",
+ "ja_JP",
+ "ko_KO",
+ "nl_NL",
+ "pt_BR",
+ "pl_PL",
+ "ro_RO",
+ "ru_RU",
+ "th_TH",
+ "tr_TR",
+ "uk_UA",
+ "vi_VN",
+ "zh_CN",
+ "zh_TW",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitBand5Support.class;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_band5;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband2;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband2_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5FWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5FWHelper.java
new file mode 100644
index 0000000..67ca1b8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5FWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitband5;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitband5.AmazfitBand5FirmwareInfo;
+
+public class AmazfitBand5FWHelper extends HuamiFWHelper {
+
+ public AmazfitBand5FWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitBand5FirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a Amazfit Band 5 firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5FWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5FWInstallHandler.java
new file mode 100644
index 0000000..225040b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband5/AmazfitBand5FWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitband5;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitBand5FWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitBand5FWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_miband5, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitBand5FWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITBAND5;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband7/AmazfitBand7Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband7/AmazfitBand7Coordinator.java
new file mode 100644
index 0000000..599c812
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitband7/AmazfitBand7Coordinator.java
@@ -0,0 +1,68 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitband7;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitBand7Coordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_BAND7_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 252,
+ 253,
+ 254
+ ));
+ }
+
+ @Override
+ public boolean supportsAgpsUpdates() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return false;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_band7;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_default;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_default_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipCoordinator.java
new file mode 100644
index 0000000..d17c282
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipCoordinator.java
@@ -0,0 +1,142 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
+ Dakhno, Daniele Gobbetti, Jos茅 Rebelo, Nephiel, Petr Van臎k
+
+ 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.devices.huami.amazfitbip;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbip.AmazfitBipSupport;
+
+public class AmazfitBipCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit Bip Watch", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitBipFWInstallHandler handler = new AmazfitBipFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_buttonactions_with_longpress);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitbip);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitBipSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_bip;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipFWHelper.java
new file mode 100644
index 0000000..9dccb3c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipFWHelper.java
@@ -0,0 +1,43 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Daniele Gobbetti
+
+ 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.devices.huami.amazfitbip;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbip.AmazfitBipFirmwareInfo;
+
+public class AmazfitBipFWHelper extends HuamiFWHelper {
+
+ public AmazfitBipFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @NonNull
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitBipFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit Bip firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipFWInstallHandler.java
new file mode 100644
index 0000000..80b9e42
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.devices.huami.amazfitbip;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitBipFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitBipFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitbip, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitBipFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITBIP;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteCoordinator.java
new file mode 100644
index 0000000..5eae922
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteCoordinator.java
@@ -0,0 +1,98 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.huami.amazfitbip;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbip.AmazfitBipLiteSupport;
+
+public class AmazfitBipLiteCoordinator extends AmazfitBipCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit Bip Lite", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitBipLiteFWInstallHandler handler = new AmazfitBipLiteFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_buttonactions_with_longpress);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitbip);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ //calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_relax_firmware_checks);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_bip_lite;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitBipLiteSupport.class;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteFWHelper.java
new file mode 100644
index 0000000..a567b57
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteFWHelper.java
@@ -0,0 +1,43 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitbip;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbip.AmazfitBipLiteFirmwareInfo;
+
+public class AmazfitBipLiteFWHelper extends HuamiFWHelper {
+
+ public AmazfitBipLiteFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @NonNull
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitBipLiteFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit Bip Lite firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteFWInstallHandler.java
new file mode 100644
index 0000000..7dfbf9c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipLiteFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitbip;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitBipLiteFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitBipLiteFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitbip_lite, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitBipLiteFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITBIP_LITE;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipService.java
new file mode 100644
index 0000000..d59a10c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip/AmazfitBipService.java
@@ -0,0 +1,33 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.devices.huami.amazfitbip;
+
+import java.util.UUID;
+
+import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.ENDPOINT_DISPLAY;
+
+public class AmazfitBipService {
+ public static final UUID UUID_CHARACTERISTIC_WEATHER = UUID.fromString("0000000e-0000-3512-2118-0009af100700");
+
+ public static final byte[] COMMAND_SET_LANGUAGE_SIMPLIFIED_CHINESE = new byte[]{ENDPOINT_DISPLAY, 0x13, 0x00, 0x00};
+ public static final byte[] COMMAND_SET_LANGUAGE_TRADITIONAL_CHINESE = new byte[]{ENDPOINT_DISPLAY, 0x13, 0x00, 0x01};
+ public static final byte[] COMMAND_SET_LANGUAGE_ENGLISH = new byte[]{ENDPOINT_DISPLAY, 0x13, 0x00, 0x02};
+ public static final byte[] COMMAND_SET_LANGUAGE_SPANISH = new byte[]{ENDPOINT_DISPLAY, 0x13, 0x00, 0x03};
+
+ public static final byte[] COMMAND_ACK_FIND_PHONE_IN_PROGRESS = new byte[]{ENDPOINT_DISPLAY, 0x14, 0x00, 0x00};
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3Coordinator.java
new file mode 100644
index 0000000..5b97416
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3Coordinator.java
@@ -0,0 +1,191 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip3;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbip3.AmazfitBip3Support;
+
+public class AmazfitBip3Coordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Amazfit Bip 3$", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitBip3FWInstallHandler handler = new AmazfitBip3FWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 20; // as enforced by Zepp
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ return 30; // at least
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSpo2(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsPai() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_dateformat);
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitbip3pro);
+ display.add(R.xml.devicesettings_liftwrist_display_sensitivity);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep_alert_activity_stress);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "cs_CZ",
+ "de_DE",
+ "el_GR",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "id_ID",
+ "it_IT",
+ "ja_JP",
+ "ko_KO",
+ "nl_NL",
+ "pl_PL",
+ "pt_BR",
+ "ru_RU",
+ "th_TH",
+ "tr_TR",
+ "uk_UA",
+ "vi_VN",
+ "zh_CH",
+ "zh_TW",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitBip3Support.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_bip3;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3FWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3FWHelper.java
new file mode 100644
index 0000000..4a5e908
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3FWHelper.java
@@ -0,0 +1,39 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip3;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbip3.AmazfitBip3FirmwareInfo;
+
+public class AmazfitBip3FWHelper extends HuamiFWHelper {
+ public AmazfitBip3FWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(final byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitBip3FirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not an Amazfit Bip 3 firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3FWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3FWInstallHandler.java
new file mode 100644
index 0000000..20d5323
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3/AmazfitBip3FWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip3;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitBip3FWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitBip3FWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitbip3, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitBip3FWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITBIP3;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProCoordinator.java
new file mode 100644
index 0000000..882afb2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProCoordinator.java
@@ -0,0 +1,191 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip3pro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbip3pro.AmazfitBip3ProSupport;
+
+public class AmazfitBip3ProCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit Bip 3 Pro", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitBip3ProFWInstallHandler handler = new AmazfitBip3ProFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 20; // as enforced by Zepp
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ return 30; // at least
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSpo2(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsPai() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_dateformat);
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitbip3pro);
+ display.add(R.xml.devicesettings_liftwrist_display_sensitivity);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep_alert_activity_stress);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "cs_CZ",
+ "de_DE",
+ "el_GR",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "id_ID",
+ "it_IT",
+ "ja_JP",
+ "ko_KO",
+ "nl_NL",
+ "pl_PL",
+ "pt_BR",
+ "ru_RU",
+ "th_TH",
+ "tr_TR",
+ "uk_UA",
+ "vi_VN",
+ "zh_CH",
+ "zh_TW",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitBip3ProSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_bip3_pro;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProFWHelper.java
new file mode 100644
index 0000000..e3e75c9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProFWHelper.java
@@ -0,0 +1,39 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip3pro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbip3pro.AmazfitBip3ProFirmwareInfo;
+
+public class AmazfitBip3ProFWHelper extends HuamiFWHelper {
+ public AmazfitBip3ProFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(final byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitBip3ProFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not an Amazfit Bip 3 Pro firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProFWInstallHandler.java
new file mode 100644
index 0000000..176c003
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip3pro/AmazfitBip3ProFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip3pro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitBip3ProFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitBip3ProFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitbip3pro, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitBip3ProFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITBIP3PRO;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip5/AmazfitBip5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip5/AmazfitBip5Coordinator.java
new file mode 100644
index 0000000..c596258
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip5/AmazfitBip5Coordinator.java
@@ -0,0 +1,81 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip5;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitBip5Coordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_BIP5_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 8454400, // chinese mainland version
+ 8454401
+ ));
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_bip5;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip5unity/AmazfitBip5UnityCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip5unity/AmazfitBip5UnityCoordinator.java
new file mode 100644
index 0000000..796eff0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbip5unity/AmazfitBip5UnityCoordinator.java
@@ -0,0 +1,83 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip5unity;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitBip5UnityCoordinator extends ZeppOsCoordinator {
+ @Override
+ public boolean isExperimental() {
+ // untested device
+ return true;
+ }
+
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_BIP5_UNITY_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 8782081,
+ 8782088,
+ 8782089
+ ));
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_bip5_unity;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSCoordinator.java
new file mode 100644
index 0000000..3e31774
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSCoordinator.java
@@ -0,0 +1,163 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k, Zhong Jianxin
+
+ 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.devices.huami.amazfitbips;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbips.AmazfitBipSSupport;
+
+public class AmazfitBipSCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit Bip S", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitBipSFWInstallHandler handler = new AmazfitBipSFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 20; // as enforced by Mi Fit
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ return 30; // at least
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitbips);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitBipSSupport.class;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_bips;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSFWHelper.java
new file mode 100644
index 0000000..1ecf5dd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitbips;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbips.AmazfitBipSFirmwareInfo;
+
+public class AmazfitBipSFWHelper extends HuamiFWHelper {
+
+ public AmazfitBipSFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitBipSFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit Bip S firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSFWInstallHandler.java
new file mode 100644
index 0000000..1413d55
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitbips;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitBipSFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitBipSFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitbip, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitBipSFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITBIPS;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteCoordinator.java
new file mode 100644
index 0000000..b77f479
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteCoordinator.java
@@ -0,0 +1,53 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbips;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbips.AmazfitBipSLiteSupport;
+
+public class AmazfitBipSLiteCoordinator extends AmazfitBipSCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Bip S Lite", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitBipSLiteFWInstallHandler handler = new AmazfitBipSLiteFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_bips_lite;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitBipSLiteSupport.class;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteFWHelper.java
new file mode 100644
index 0000000..23114f3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitbips;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbips.AmazfitBipSLiteFirmwareInfo;
+
+public class AmazfitBipSLiteFWHelper extends HuamiFWHelper {
+
+ public AmazfitBipSLiteFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitBipSLiteFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit Bip S Lite firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteFWInstallHandler.java
new file mode 100644
index 0000000..4149f34
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbips/AmazfitBipSLiteFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitbips;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitBipSLiteFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitBipSLiteFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitbip, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitBipSLiteFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITBIPS_LITE;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUCoordinator.java
new file mode 100644
index 0000000..d9e8e30
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUCoordinator.java
@@ -0,0 +1,192 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Daniel Dakhno, Joel Beckmeyer,
+ Jos茅 Rebelo, Petr Van臎k, TinfoilSubmarine
+
+ 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.devices.huami.amazfitbipu;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbipu.AmazfitBipUSupport;
+
+public class AmazfitBipUCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit Bip U", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitBipUFWInstallHandler handler = new AmazfitBipUFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsAlarmSnoozing() {
+ // All alarms snooze by default, there doesn't seem to be a flag that disables it
+ return false;
+ }
+
+ @Override
+ public int getReminderSlotCount(final GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 20; // as enforced by Mi Fit
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ return 30; // at least
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitbipu);
+ display.add(R.xml.devicesettings_liftwrist_display_sensitivity);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep_alert_activity_stress);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> workout = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.WORKOUT);
+ workout.add(R.xml.devicesettings_workout_start_on_phone);
+ workout.add(R.xml.devicesettings_workout_send_gps_to_band);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "cs_CZ",
+ "de_DE",
+ "el_GR",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "id_ID",
+ "it_IT",
+ "ja_JP",
+ "ko_KO",
+ "nl_NL",
+ "pl_PL",
+ "pt_BR",
+ "ru_RU",
+ "th_TH",
+ "tr_TR",
+ "uk_UA",
+ "vi_VN",
+ "zh_CH",
+ "zh_TW",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitBipUSupport.class;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_bipu;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUFWHelper.java
new file mode 100644
index 0000000..313308f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitbipu;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbipu.AmazfitBipUFirmwareInfo;
+
+public class AmazfitBipUFWHelper extends HuamiFWHelper {
+
+ public AmazfitBipUFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitBipUFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit Bip U firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUFWInstallHandler.java
new file mode 100644
index 0000000..b7a9477
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipu/AmazfitBipUFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitbipu;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitBipUFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitBipUFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitbip, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitBipUFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITBIPU;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProCoordinator.java
new file mode 100644
index 0000000..5162529
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProCoordinator.java
@@ -0,0 +1,183 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, DanialHanif, Daniel Dakhno,
+ Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.huami.amazfitbipupro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbipupro.AmazfitBipUProSupport;
+
+public class AmazfitBipUProCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit Bip U Pro", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitBipUProFWInstallHandler handler = new AmazfitBipUProFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public int getReminderSlotCount(final GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 20; // as enforced by Mi Fit
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ return 30; // at least
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitbipupro);
+ display.add(R.xml.devicesettings_liftwrist_display_sensitivity);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep_alert_activity_stress);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "cs_CZ",
+ "de_DE",
+ "el_GR",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "id_ID",
+ "it_IT",
+ "ja_JP",
+ "ko_KO",
+ "nl_NL",
+ "pl_PL",
+ "pt_BR",
+ "ru_RU",
+ "th_TH",
+ "tr_TR",
+ "uk_UA",
+ "vi_VN",
+ "zh_CH",
+ "zh_TW",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitBipUProSupport.class;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_bipupro;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProFWHelper.java
new file mode 100644
index 0000000..0eea939
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2021-2024 DanialHanif
+
+ 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.devices.huami.amazfitbipupro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbipupro.AmazfitBipUProFirmwareInfo;
+
+public class AmazfitBipUProFWHelper extends HuamiFWHelper {
+
+ public AmazfitBipUProFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitBipUProFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit Bip U Pro firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProFWInstallHandler.java
new file mode 100644
index 0000000..971ccf4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitbipupro/AmazfitBipUProFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2021-2024 DanialHanif
+
+ 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.devices.huami.amazfitbipupro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitBipUProFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitBipUProFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitbip, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitBipUProFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITBIPUPRO;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahpro/AmazfitCheetahProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahpro/AmazfitCheetahProCoordinator.java
new file mode 100644
index 0000000..dc1e706
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahpro/AmazfitCheetahProCoordinator.java
@@ -0,0 +1,85 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo, Raghd Hamzeh
+
+ 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.devices.huami.amazfitcheetahpro;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitCheetahProCoordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_CHEETAH_PRO_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 8126720, // chinese mainland version
+ 8126721
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_cheetah_pro;
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean mainMenuHasMoreSection() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return true;
+ }
+
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahround/AmazfitCheetahRoundCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahround/AmazfitCheetahRoundCoordinator.java
new file mode 100644
index 0000000..34a8e86
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahround/AmazfitCheetahRoundCoordinator.java
@@ -0,0 +1,86 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitcheetahround;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitCheetahRoundCoordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_CHEETAH_ROUND_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 8192256, // chinese mainland version
+ 8192257
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_cheetah_round;
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean mainMenuHasMoreSection() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahsquare/AmazfitCheetahSquareCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahsquare/AmazfitCheetahSquareCoordinator.java
new file mode 100644
index 0000000..c5c676f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcheetahsquare/AmazfitCheetahSquareCoordinator.java
@@ -0,0 +1,95 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitcheetahsquare;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitCheetahSquareCoordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_CHEETAH_SQUARE_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Collections.singletonList(
+ 8257793
+ ));
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean mainMenuHasMoreSection() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_cheetah_square;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorCoordinator.java
new file mode 100644
index 0000000..70ca827
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorCoordinator.java
@@ -0,0 +1,152 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Daniel Dakhno, Daniele Gobbetti,
+ Jos茅 Rebelo, Matthieu Baerts, Petr Van臎k
+
+ 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.devices.huami.amazfitcor;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitcor.AmazfitCorSupport;
+
+public class AmazfitCorCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit Band|Amazfit Cor", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitCorFWInstallHandler handler = new AmazfitCorFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitcor);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitCorSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_cor;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_default;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_default_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorFWHelper.java
new file mode 100644
index 0000000..0f001ec
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitcor;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitcor.AmazfitCorFirmwareInfo;
+
+public class AmazfitCorFWHelper extends HuamiFWHelper {
+
+ public AmazfitCorFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitCorFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit Cor firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorFWInstallHandler.java
new file mode 100644
index 0000000..1607117
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor/AmazfitCorFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.devices.huami.amazfitcor;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitCorFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitCorFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitcor, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitCorFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITCOR;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2Coordinator.java
new file mode 100644
index 0000000..1e85bcc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2Coordinator.java
@@ -0,0 +1,152 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.huami.amazfitcor2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitcor2.AmazfitCor2Support;
+
+public class AmazfitCor2Coordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit Band 2|Amazfit Cor 2", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitCor2FWInstallHandler handler = new AmazfitCor2FWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitcor);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitCor2Support.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_cor2;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_default;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_default_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2FWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2FWHelper.java
new file mode 100644
index 0000000..61c3d2b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2FWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitcor2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitcor2.AmazfitCor2FirmwareInfo;
+
+public class AmazfitCor2FWHelper extends HuamiFWHelper {
+
+ public AmazfitCor2FWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitCor2FirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit Cor 2 firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2FWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2FWInstallHandler.java
new file mode 100644
index 0000000..dce08ea
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitcor2/AmazfitCor2FWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitcor2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitCor2FWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitCor2FWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitcor2, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitCor2FWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITCOR2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitfalcon/AmazfitFalconCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitfalcon/AmazfitFalconCoordinator.java
new file mode 100644
index 0000000..09f2ec5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitfalcon/AmazfitFalconCoordinator.java
@@ -0,0 +1,71 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitfalcon;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitFalconCoordinator extends ZeppOsCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_FALCON_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 414, // chinese mainland version
+ 415
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_falcon;
+ }
+
+ @Override
+ public boolean sendAgpsAsFileTransfer() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRCoordinator.java
new file mode 100644
index 0000000..0c8dcb7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRCoordinator.java
@@ -0,0 +1,143 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.huami.amazfitgtr;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgtr.AmazfitGTRSupport;
+
+public class AmazfitGTRCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit GTR", Pattern.CASE_INSENSITIVE);
+ }
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitGTRFWInstallHandler handler = new AmazfitGTRFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_buttonactions_with_longpress);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitgtsgtr);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitGTRSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gtr;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRFWHelper.java
new file mode 100644
index 0000000..45be2c9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgtr;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgtr.AmazfitGTRFirmwareInfo;
+
+public class AmazfitGTRFWHelper extends HuamiFWHelper {
+
+ public AmazfitGTRFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitGTRFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit GTR firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRFWInstallHandler.java
new file mode 100644
index 0000000..e3086aa
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgtr;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitGTRFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitGTRFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitgtr, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitGTRFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITGTR;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteCoordinator.java
new file mode 100644
index 0000000..5f698ef
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteCoordinator.java
@@ -0,0 +1,136 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.huami.amazfitgtr;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgtr.AmazfitGTRLiteSupport;
+
+public class AmazfitGTRLiteCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit GTR L", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitGTRLiteFWInstallHandler handler = new AmazfitGTRLiteFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitgtsgtr);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitGTRLiteSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gtr_lite;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteFWHelper.java
new file mode 100644
index 0000000..ecb63af
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgtr;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgtr.AmazfitGTRLiteFirmwareInfo;
+
+public class AmazfitGTRLiteFWHelper extends HuamiFWHelper {
+
+ public AmazfitGTRLiteFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitGTRLiteFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit GTR Lite Firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteFWInstallHandler.java
new file mode 100644
index 0000000..46a136c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr/AmazfitGTRLiteFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgtr;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitGTRLiteFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitGTRLiteFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitgtr, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitGTRLiteFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITGTR_LITE;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2Coordinator.java
new file mode 100644
index 0000000..00c1399
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2Coordinator.java
@@ -0,0 +1,153 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ pangwalla, Petr Van臎k
+
+ 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.devices.huami.amazfitgtr2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgtr2.AmazfitGTR2Support;
+
+public class AmazfitGTR2Coordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit GTR 2", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitGTR2FWInstallHandler handler = new AmazfitGTR2FWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsPai() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitgtsgtr2);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitGTR2Support.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gtr2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2FWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2FWHelper.java
new file mode 100644
index 0000000..1f7c255
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2FWHelper.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, pangwalla
+
+ 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.devices.huami.amazfitgtr2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgtr.AmazfitGTRFirmwareInfo;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgtr2.AmazfitGTR2FirmwareInfo;
+
+public class AmazfitGTR2FWHelper extends HuamiFWHelper {
+
+ public AmazfitGTR2FWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitGTR2FirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not an Amazfit GTR 2 firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2FWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2FWInstallHandler.java
new file mode 100644
index 0000000..09c9825
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2FWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2020-2024 pangwalla
+
+ 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.devices.huami.amazfitgtr2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitGTR2FWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitGTR2FWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitgtr, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitGTR2FWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITGTR2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eCoordinator.java
new file mode 100644
index 0000000..a573093
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eCoordinator.java
@@ -0,0 +1,148 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.huami.amazfitgtr2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgtr2.AmazfitGTR2eSupport;
+
+public class AmazfitGTR2eCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit GTR 2e", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitGTR2eFWInstallHandler handler = new AmazfitGTR2eFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitgtsgtr2);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitGTR2eSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gtr2e;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eFWHelper.java
new file mode 100644
index 0000000..92700fe
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgtr2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgtr2.AmazfitGTR2eFirmwareInfo;
+
+public class AmazfitGTR2eFWHelper extends HuamiFWHelper {
+
+ public AmazfitGTR2eFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitGTR2eFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not an Amazfit GTR 2e firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eFWInstallHandler.java
new file mode 100644
index 0000000..31e9d64
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr2/AmazfitGTR2eFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgtr2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitGTR2eFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitGTR2eFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitgtr, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitGTR2eFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITGTR2E;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr3/AmazfitGTR3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr3/AmazfitGTR3Coordinator.java
new file mode 100644
index 0000000..ae9a23c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr3/AmazfitGTR3Coordinator.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo, thermatk
+
+ 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.devices.huami.amazfitgtr3;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitGTR3Coordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_GTR3_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 226, // chinese mainland version
+ 227
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gtr3;
+ }
+
+ @Override
+ public boolean sendAgpsAsFileTransfer() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr3pro/AmazfitGTR3ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr3pro/AmazfitGTR3ProCoordinator.java
new file mode 100644
index 0000000..c49432b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr3pro/AmazfitGTR3ProCoordinator.java
@@ -0,0 +1,58 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtr3pro;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitGTR3ProCoordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_GTR3_PRO_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 229, // chinese mainland version
+ 230,
+ 242, // chinese mainland version
+ 6095106 // chinese mainland version
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gtr3_pro;
+ }
+
+ @Override
+ public boolean sendAgpsAsFileTransfer() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr4/AmazfitGTR4Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr4/AmazfitGTR4Coordinator.java
new file mode 100644
index 0000000..09a13e7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtr4/AmazfitGTR4Coordinator.java
@@ -0,0 +1,94 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtr4;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitGTR4Coordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_GTR4_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 7930112, // chinese mainland version
+ 7930113,
+ 7864577
+ ));
+ }
+
+ @Override
+ protected Map<Integer, String> getCrcMap() {
+ return new HashMap<Integer, String>() {{
+ // firmware
+ put(1699, "3.17.0.2");
+ put(20712, "3.18.1.1 (diff from 3.17.0.2)");
+ put(49685, "3.23.3.1 (diff from 3.21.0.1)");
+ }};
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gtr4;
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtrmini/AmazfitGTRMiniCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtrmini/AmazfitGTRMiniCoordinator.java
new file mode 100644
index 0000000..fc439bf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgtrmini/AmazfitGTRMiniCoordinator.java
@@ -0,0 +1,87 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtrmini;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitGTRMiniCoordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_GTR_MINI_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 250,
+ 251
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gtr_mini;
+ }
+
+ @Override
+ public boolean sendAgpsAsFileTransfer() {
+ // Even though it's a Zepp OS 2.0 device, it doesn't seem to support the AGPS service
+ return false;
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSCoordinator.java
new file mode 100644
index 0000000..165b7ff
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSCoordinator.java
@@ -0,0 +1,153 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ Manuel Ru脽, Petr Van臎k
+
+ 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.devices.huami.amazfitgts;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgts.AmazfitGTSSupport;
+
+public class AmazfitGTSCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit GTS", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitGTSFWInstallHandler handler = new AmazfitGTSFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitgtsgtr);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitGTSSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gts;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSFWHelper.java
new file mode 100644
index 0000000..ad96628
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgts;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgts.AmazfitGTSFirmwareInfo;
+
+public class AmazfitGTSFWHelper extends HuamiFWHelper {
+
+ public AmazfitGTSFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitGTSFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit GTS firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSFWInstallHandler.java
new file mode 100644
index 0000000..da7eb72
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts/AmazfitGTSFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgts;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitGTSFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitGTSFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitgts, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitGTSFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITGTS;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2Coordinator.java
new file mode 100644
index 0000000..255dec9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2Coordinator.java
@@ -0,0 +1,158 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.huami.amazfitgts2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgts2.AmazfitGTS2Support;
+
+public class AmazfitGTS2Coordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit GTS 2", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitGTS2FWInstallHandler handler = new AmazfitGTS2FWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitgtsgtr2);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitGTS2Support.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gts2;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2FWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2FWHelper.java
new file mode 100644
index 0000000..3841494
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2FWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgts2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgts2.AmazfitGTS2FirmwareInfo;
+
+public class AmazfitGTS2FWHelper extends HuamiFWHelper {
+
+ public AmazfitGTS2FWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitGTS2FirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not an Amazfit GTS 2 firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2FWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2FWInstallHandler.java
new file mode 100644
index 0000000..2256051
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2FWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgts2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitGTS2FWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitGTS2FWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitgtr, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitGTS2FWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITGTS2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniCoordinator.java
new file mode 100644
index 0000000..a7aef13
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniCoordinator.java
@@ -0,0 +1,93 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.huami.amazfitgts2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgts2.AmazfitGTS2MiniSupport;
+
+public class AmazfitGTS2MiniCoordinator extends AmazfitGTS2Coordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit GTS2 mini", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitGTS2MiniFWInstallHandler handler = new AmazfitGTS2MiniFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitgts2mini);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gts2_mini;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitGTS2MiniSupport.class;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniFWHelper.java
new file mode 100644
index 0000000..c39b39e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgts2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgts2.AmazfitGTS2MiniFirmwareInfo;
+
+public class AmazfitGTS2MiniFWHelper extends HuamiFWHelper {
+
+ public AmazfitGTS2MiniFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitGTS2MiniFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not an Amazfit GTS 2 Mini firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniFWInstallHandler.java
new file mode 100644
index 0000000..25d33dd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2MiniFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgts2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitGTS2MiniFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitGTS2MiniFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitgtr, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitGTS2MiniFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITGTS2_MINI;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eCoordinator.java
new file mode 100644
index 0000000..4016de5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eCoordinator.java
@@ -0,0 +1,158 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k, pommes
+
+ 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.devices.huami.amazfitgts2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgts2.AmazfitGTS2eSupport;
+
+public class AmazfitGTS2eCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit GTS 2e", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitGTS2eFWInstallHandler handler = new AmazfitGTS2eFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitgtsgtr2);
+ display.add(R.xml.devicesettings_liftwrist_display_sensitivity);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitGTS2eSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gts2e;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eFWHelper.java
new file mode 100644
index 0000000..685ddc0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgts2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitgts2.AmazfitGTS2eFirmwareInfo;
+
+public class AmazfitGTS2eFWHelper extends HuamiFWHelper {
+
+ public AmazfitGTS2eFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitGTS2eFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not an Amazfit GTR 2e firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eFWInstallHandler.java
new file mode 100644
index 0000000..79a8e45
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts2/AmazfitGTS2eFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitgts2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitGTS2eFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitGTS2eFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitgts, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitGTS2eFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITGTS2E;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts3/AmazfitGTS3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts3/AmazfitGTS3Coordinator.java
new file mode 100644
index 0000000..96196ea
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts3/AmazfitGTS3Coordinator.java
@@ -0,0 +1,66 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo, sedy89
+
+ 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.devices.huami.amazfitgts3;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitGTS3Coordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_GTS3_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 224, // chinese mainland version
+ 225
+ ));
+ }
+
+ @Override
+ public boolean sendAgpsAsFileTransfer() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gts3;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts4/AmazfitGTS4Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts4/AmazfitGTS4Coordinator.java
new file mode 100644
index 0000000..b7ab450
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts4/AmazfitGTS4Coordinator.java
@@ -0,0 +1,91 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgts4;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitGTS4Coordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_GTS4_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 7995648, // chinese mainland version
+ 7995649
+ ));
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gts4;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts4mini/AmazfitGTS4MiniCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts4mini/AmazfitGTS4MiniCoordinator.java
new file mode 100644
index 0000000..7a821ec
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitgts4mini/AmazfitGTS4MiniCoordinator.java
@@ -0,0 +1,66 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgts4mini;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitGTS4MiniCoordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_GTS4_MINI_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 246, // chinese mainland version
+ 247
+ ));
+ }
+
+ @Override
+ public boolean sendAgpsAsFileTransfer() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_gts4_mini;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoCoordinator.java
new file mode 100644
index 0000000..671f68c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoCoordinator.java
@@ -0,0 +1,153 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ NekoBox, Petr Van臎k, xaos
+
+ 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.devices.huami.amazfitneo;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitneo.AmazfitNeoSupport;
+
+public class AmazfitNeoCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuamiConst.AMAZFIT_NEO_NAME, Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitNeoFWInstallHandler handler = new AmazfitNeoFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsPai(){
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 20; // max in Zepp app
+ }
+
+ @Override
+ public int getReminderSlotCount(final GBDevice device) {
+ return 0; // Neo does not support reminders
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitneo_display);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep_activity);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> sound = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.SOUND);
+ sound.add(R.xml.devicesettings_amazfitneo_sound);
+ sound.add(R.xml.devicesettings_hourly_chime);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
+ return Arrays.asList(
+ HeartRateCapability.MeasurementInterval.OFF,
+ HeartRateCapability.MeasurementInterval.MINUTES_1,
+ HeartRateCapability.MeasurementInterval.MINUTES_5,
+ HeartRateCapability.MeasurementInterval.MINUTES_10,
+ HeartRateCapability.MeasurementInterval.MINUTES_30
+ );
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitNeoSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_neo;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoFWHelper.java
new file mode 100644
index 0000000..42648f8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoFWHelper.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, xaos
+
+ 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.devices.huami.amazfitneo;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitneo.AmazfitNeoFirmwareInfo;
+
+public class AmazfitNeoFWHelper extends HuamiFWHelper {
+
+ public AmazfitNeoFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitNeoFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not an Amazfit Neo firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoFWInstallHandler.java
new file mode 100644
index 0000000..26bf607
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitneo/AmazfitNeoFWInstallHandler.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, xaos
+
+ 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.devices.huami.amazfitneo;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitNeoFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitNeoFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitneo, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitNeoFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITNEO;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopCoordinator.java
new file mode 100644
index 0000000..3929b4d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopCoordinator.java
@@ -0,0 +1,64 @@
+/* Copyright (C) 2022-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitpop;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbipu.AmazfitBipUCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitpop.AmazfitPopSupport;
+
+public class AmazfitPopCoordinator extends AmazfitBipUCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit Pop", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitPopFWInstallHandler handler = new AmazfitPopFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_pop;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitPopSupport.class;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopFWHelper.java
new file mode 100644
index 0000000..75a4603
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2022-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitpop;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitpoppro.AmazfitPopProFirmwareInfo;
+
+public class AmazfitPopFWHelper extends HuamiFWHelper {
+
+ public AmazfitPopFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitPopProFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not an Amazfit Pop Pro firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopFWInstallHandler.java
new file mode 100644
index 0000000..5602867
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpop/AmazfitPopFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2022-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitpop;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitPopFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitPopFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitbip, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitPopFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITPOP;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProCoordinator.java
new file mode 100644
index 0000000..eb82116
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProCoordinator.java
@@ -0,0 +1,54 @@
+/* Copyright (C) 2022-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitpoppro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbipupro.AmazfitBipUProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitpoppro.AmazfitPopProSupport;
+
+public class AmazfitPopProCoordinator extends AmazfitBipUProCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit Pop Pro", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitPopProFWInstallHandler handler = new AmazfitPopProFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_pop_pro;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitPopProSupport.class;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProFWHelper.java
new file mode 100644
index 0000000..553de24
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2022-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitpoppro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitpoppro.AmazfitPopProFirmwareInfo;
+
+public class AmazfitPopProFWHelper extends HuamiFWHelper {
+
+ public AmazfitPopProFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitPopProFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not an Amazfit Pop Pro firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProFWInstallHandler.java
new file mode 100644
index 0000000..b552e35
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitpoppro/AmazfitPopProFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2022-2024 Andreas Shimokawa
+
+ 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.devices.huami.amazfitpoppro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitPopProFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitPopProFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitbip, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitPopProFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITPOPPRO;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexCoordinator.java
new file mode 100644
index 0000000..153d73f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexCoordinator.java
@@ -0,0 +1,143 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Daniel Dakhno, Dmytro Bielik,
+ Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.huami.amazfittrex;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfittrex.AmazfitTRexSupport;
+
+public class AmazfitTRexCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit T-Rex", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitTRexFWInstallHandler handler = new AmazfitTRexFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfittrex);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitTRexSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_trex;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexFWHelper.java
new file mode 100644
index 0000000..2581480
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2020-2024 Dmytro Bielik
+
+ 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.devices.huami.amazfittrex;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfittrex.AmazfitTRexFirmwareInfo;
+
+public class AmazfitTRexFWHelper extends HuamiFWHelper {
+
+ public AmazfitTRexFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitTRexFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit T-Rex firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexFWInstallHandler.java
new file mode 100644
index 0000000..d5ca396
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex/AmazfitTRexFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2020-2024 Dmytro Bielik
+
+ 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.devices.huami.amazfittrex;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitTRexFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitTRexFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfit_trex, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitTRexFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITTREX;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex2/AmazfitTRex2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex2/AmazfitTRex2Coordinator.java
new file mode 100644
index 0000000..d4826c7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex2/AmazfitTRex2Coordinator.java
@@ -0,0 +1,66 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfittrex2;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitTRex2Coordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_TREX_2_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 418, // chinese mainland version
+ 419
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_trex_2;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean sendAgpsAsFileTransfer() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex3/AmazfitTRex3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex3/AmazfitTRex3Coordinator.java
new file mode 100644
index 0000000..38138ec
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrex3/AmazfitTRex3Coordinator.java
@@ -0,0 +1,92 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfittrex3;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitTRex3Coordinator extends ZeppOsCoordinator {
+ @Override
+ public boolean isExperimental() {
+ // Untested device
+ return true;
+ }
+
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_TREX_3_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 8716544,
+ 8716545, // chinese mainland version
+ 8716547 // chinese mainland version
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_trex_3;
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean mainMenuHasMoreSection() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return true;
+ }
+
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProCoordinator.java
new file mode 100644
index 0000000..438e74a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProCoordinator.java
@@ -0,0 +1,143 @@
+/* Copyright (C) 2021-2024 Daniel Dakhno, GeekosaurusR3x, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.huami.amazfittrexpro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfittrexpro.AmazfitTRexProSupport;
+
+public class AmazfitTRexProCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit T-Rex Pro", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitTRexProFWInstallHandler handler = new AmazfitTRexProFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfittrexpro);
+ display.add(R.xml.devicesettings_liftwrist_display_sensitivity);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitTRexProSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_trex_pro;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProFWHelper.java
new file mode 100644
index 0000000..5a861b9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2021-2024 GeekosaurusR3x
+
+ 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.devices.huami.amazfittrexpro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfittrexpro.AmazfitTRexProFirmwareInfo;
+
+public class AmazfitTRexProFWHelper extends HuamiFWHelper {
+
+ public AmazfitTRexProFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitTRexProFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit T-Rex Pro firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProFWInstallHandler.java
new file mode 100644
index 0000000..1ec2147
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexpro/AmazfitTRexProFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2021-2024 GeekosaurusR3x
+
+ 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.devices.huami.amazfittrexpro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitTRexProFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitTRexProFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfit_trex, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitTRexProFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITTREXPRO;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexultra/AmazfitTRexUltraCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexultra/AmazfitTRexUltraCoordinator.java
new file mode 100644
index 0000000..8b61c4e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfittrexultra/AmazfitTRexUltraCoordinator.java
@@ -0,0 +1,81 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfittrexultra;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class AmazfitTRexUltraCoordinator extends ZeppOsCoordinator {
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.AMAZFIT_TREX_ULTRA;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(
+ 6553856, // chinese mainland version
+ 6553857
+ ));
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_trex_ultra;
+ }
+
+ @Override
+ public boolean supportsContinuousFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsGpxUploads() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsControlCenter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsToDoList() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFtpServer(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLCoordinator.java
new file mode 100644
index 0000000..94497c4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLCoordinator.java
@@ -0,0 +1,134 @@
+/* Copyright (C) 2020-2024 angelpup, Daniel Dakhno, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.huami.amazfitvergel;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitvergel.AmazfitVergeLSupport;
+
+public class AmazfitVergeLCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Amazfit Verge L", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final AmazfitVergeLFWInstallHandler handler = new AmazfitVergeLFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_buttonactions_with_longpress);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitvergel);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitVergeLSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_vergel;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLFWHelper.java
new file mode 100644
index 0000000..5635ae8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2020-2024 angelpup
+
+ 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.devices.huami.amazfitvergel;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitvergel.AmazfitVergeLFirmwareInfo;
+
+public class AmazfitVergeLFWHelper extends HuamiFWHelper {
+
+ public AmazfitVergeLFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitVergeLFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a an Amazfit Verge Lite firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLFWInstallHandler.java
new file mode 100644
index 0000000..e45177a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitvergel/AmazfitVergeLFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2020-2024 angelpup
+
+ 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.devices.huami.amazfitvergel;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitVergeLFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitVergeLFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitvergel, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitVergeLFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITVERGEL;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXCoordinator.java
new file mode 100644
index 0000000..7d20ffc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXCoordinator.java
@@ -0,0 +1,151 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k, Stefan Bora
+
+ 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.devices.huami.amazfitx;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitx.AmazfitXSupport;
+
+public class AmazfitXCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuamiConst.AMAZFIT_X, Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ // TOOD Need to be checked first
+ final AmazfitXFWInstallHandler handler = new AmazfitXFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+// return null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_dateformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitx);
+ display.add(R.xml.devicesettings_nightmode);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ display.add(R.xml.devicesettings_swipeunlock);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "en_US",
+ "zh_CN",
+ "zh_TW",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return AmazfitXSupport.class;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_amazfit_x;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband2;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband2_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXFWHelper.java
new file mode 100644
index 0000000..d331bc1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2021-2024 Stefan Bora
+
+ 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.devices.huami.amazfitx;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitx.AmazfitXFirmwareInfo;
+
+public class AmazfitXFWHelper extends HuamiFWHelper {
+
+ public AmazfitXFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new AmazfitXFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not an Amazfit X firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXFWInstallHandler.java
new file mode 100644
index 0000000..4b15063
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/amazfitx/AmazfitXFWInstallHandler.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2021-2024 Stefan Bora
+
+ 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.devices.huami.amazfitx;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitx.AmazfitXFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class AmazfitXFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ AmazfitXFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitx, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new AmazfitXFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.AMAZFITX;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2Coordinator.java
new file mode 100644
index 0000000..1767c9f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2Coordinator.java
@@ -0,0 +1,141 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Daniel Dakhno, Daniele Gobbetti,
+ Dmitry Markin, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.huami.miband2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband2.MiBand2Support;
+
+public class MiBand2Coordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Xiaomi";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuamiConst.MI_BAND2_NAME, Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final MiBand2FWInstallHandler handler = new MiBand2FWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public int getReminderSlotCount(final GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsDebugLogs() {
+ return false;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_buttonactions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_miband2_dateformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_miband2_display);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ display.add(R.xml.devicesettings_rotatewrist_cycleinfo);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_donotdisturb_withauto);
+ notifications.add(R.xml.devicesettings_miband_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ //calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_alarms_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ // For the vibration patterns, which are still legacy mi_ ones
+ return new MiBandSettingsCustomizer(device);
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MiBand2Support.class;
+ }
+
+ @Override
+ public EnumSet<ServiceDeviceSupport.Flags> getInitialFlags() {
+ return EnumSet.of(ServiceDeviceSupport.Flags.THROTTLING, ServiceDeviceSupport.Flags.BUSY_CHECKING);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband2;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband2;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband2_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2FWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2FWHelper.java
new file mode 100644
index 0000000..9744697
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2FWHelper.java
@@ -0,0 +1,42 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Daniele Gobbetti
+
+ 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.devices.huami.miband2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import androidx.annotation.NonNull;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband2.Mi2FirmwareInfo;
+
+public class MiBand2FWHelper extends HuamiFWHelper {
+
+ public MiBand2FWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @NonNull
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new Mi2FirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a Mi Band 2 firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2FWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2FWInstallHandler.java
new file mode 100644
index 0000000..b442c9e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2FWInstallHandler.java
@@ -0,0 +1,122 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Daniel Dakhno, Taavi Eom盲e
+
+ 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.devices.huami.miband2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareType;
+import nodomain.freeyourgadget.gadgetbridge.util.Version;
+
+public class MiBand2FWInstallHandler extends AbstractMiBandFWInstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(MiBand2FWInstallHandler.class);
+
+ MiBand2FWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ public void validateInstallation(InstallActivity installActivity, GBDevice device) {
+ super.validateInstallation(installActivity, device);
+ maybeAddFw53Hint(installActivity, device);
+ maybeAddFontHint(installActivity);
+ }
+
+ private void maybeAddFontHint(InstallActivity installActivity) {
+ HuamiFirmwareType type = getFirmwareType();
+ if (type == HuamiFirmwareType.FIRMWARE) {
+ String newInfoText = installActivity.getInfoText() + "\n\n" + "Note: you may install Mili_pro.ft or Mili_pro.ft.en to enable text notifications.";
+ installActivity.setInfoText(newInfoText);
+ }
+ }
+
+ private void maybeAddFw53Hint(InstallActivity installActivity, GBDevice device) {
+ HuamiFirmwareType type = getFirmwareType();
+ if (type != HuamiFirmwareType.FIRMWARE) {
+ return;
+ }
+
+ Version deviceVersion = getFirmwareVersionOf(device);
+ if (deviceVersion != null) {
+ Version v53 = MiBandConst.MI2_FW_VERSION_INTERMEDIATE_UPGRADE_53;
+ if (deviceVersion.compareTo(v53) < 0) {
+ String vInstall = getHelper().format(getHelper().getFirmwareVersion());
+ try {
+ if (vInstall == null || new Version(vInstall).compareTo(v53) > 0) {
+ String newInfoText = getContext().getString(R.string.mi2_fw_installhandler_fw53_hint, v53.get()) +
+ "\n\n" +
+ installActivity.getInfoText();
+ installActivity.setInfoText(newInfoText);
+ }
+ } catch (IllegalArgumentException e) {
+ String newInfoText = getContext().getString(R.string.mi2_fw_installhandler_fw53_hint, v53.get()) +
+ "\n\n" +
+ installActivity.getInfoText() +
+ "\n\n" +
+ getContext().getString(R.string.error_version_check_extreme_caution, vInstall);
+ installActivity.setInfoText(newInfoText);
+ }
+ }
+ }
+ }
+
+ private Version getFirmwareVersionOf(final GBDevice device) {
+ String version = device.getFirmwareVersion();
+ if (version == null || version.length() == 0) {
+ return null;
+ }
+ if (version.charAt(0) == 'V') {
+ version = version.substring(1);
+ }
+ try {
+ return new Version(version);
+ } catch (Exception ex) {
+ LOG.error("Unable to parse version: " + version);
+ return null;
+ }
+ }
+
+ private HuamiFirmwareType getFirmwareType() {
+ AbstractMiBandFWHelper helper = getHelper();
+ if (helper instanceof MiBand2FWHelper) {
+ return ((MiBand2FWHelper) helper).getFirmwareInfo().getFirmwareType();
+ }
+ return HuamiFirmwareType.INVALID;
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new MiBand2FWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.MIBAND2 || device.getType() == DeviceType.MIBAND2_HRX;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2HRXCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2HRXCoordinator.java
new file mode 100644
index 0000000..dad5952
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband2/MiBand2HRXCoordinator.java
@@ -0,0 +1,131 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
+ Dakhno, Daniele Gobbetti, Dmitry Markin, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.huami.miband2;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband2.MiBand2Support;
+
+public class MiBand2HRXCoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Xiaomi";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(
+ HuamiConst.MI_BAND2_NAME_HRX + "|Mi Band 2i",
+ Pattern.CASE_INSENSITIVE
+ );
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsFlashing() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsDebugLogs() {
+ return false;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_miband2_dateformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_miband2_display);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ display.add(R.xml.devicesettings_rotatewrist_cycleinfo);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_donotdisturb_withauto);
+ notifications.add(R.xml.devicesettings_miband_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ // For the vibration patterns, which are still legacy mi_ ones
+ return new MiBandSettingsCustomizer(device);
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MiBand2Support.class;
+ }
+
+ @Override
+ public EnumSet<ServiceDeviceSupport.Flags> getInitialFlags() {
+ return EnumSet.of(ServiceDeviceSupport.Flags.THROTTLING, ServiceDeviceSupport.Flags.BUSY_CHECKING);
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband2;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband2_disabled;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband2_hrx;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3Coordinator.java
new file mode 100644
index 0000000..d13b367
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3Coordinator.java
@@ -0,0 +1,175 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Daniel Dakhno, Daniele Gobbetti,
+ Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.huami.miband3;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.Date;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband3.MiBand3Support;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class MiBand3Coordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Xiaomi";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(
+ HuamiConst.MI_BAND3_NAME + "|" + HuamiConst.MI_BAND3_NAME_2,
+ Pattern.CASE_INSENSITIVE
+ );
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final MiBand3FWInstallHandler handler = new MiBand3FWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ public static String getNightMode(final String deviceAddress) {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+
+ return prefs.getString(MiBandConst.PREF_NIGHT_MODE, MiBandConst.PREF_NIGHT_MODE_OFF);
+ }
+
+ public static Date getNightModeStart(final String deviceAddress) {
+ return getTimePreference(MiBandConst.PREF_NIGHT_MODE_START, "16:00", deviceAddress);
+ }
+
+ public static Date getNightModeEnd(final String deviceAddress) {
+ return getTimePreference(MiBandConst.PREF_NIGHT_MODE_END, "07:00", deviceAddress);
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_dateformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_miband3);
+ display.add(R.xml.devicesettings_nightmode);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ display.add(R.xml.devicesettings_donotdisturb_lift_wrist);
+ display.add(R.xml.devicesettings_swipeunlock);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_donotdisturb_withauto);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "ar_SA",
+ "de_DE",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "id_ID",
+ "it_IT",
+ "ja_JP",
+ "ko_KO",
+ "pt_PT",
+ "nl_NL",
+ "pl_PL",
+ "ru_RU",
+ "th_TH",
+ "tr_TR",
+ "uk_UA",
+ "vi_VN",
+ "zh_CN",
+ "zh_TW",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MiBand3Support.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband3;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband2;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband2_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3FWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3FWHelper.java
new file mode 100644
index 0000000..7f3ff29
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3FWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa
+
+ 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.devices.huami.miband3;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband3.MiBand3FirmwareInfo;
+
+public class MiBand3FWHelper extends HuamiFWHelper {
+
+ public MiBand3FWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new MiBand3FirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a Mi Band 3 firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3FWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3FWInstallHandler.java
new file mode 100644
index 0000000..e9f52b5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3FWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa
+
+ 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.devices.huami.miband3;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class MiBand3FWInstallHandler extends AbstractMiBandFWInstallHandler {
+ MiBand3FWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_miband3, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new MiBand3FWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.MIBAND3;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3Service.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3Service.java
new file mode 100644
index 0000000..b83cec8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband3/MiBand3Service.java
@@ -0,0 +1,28 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.miband3;
+
+import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.ENDPOINT_DISPLAY;
+
+public class MiBand3Service {
+ public static final byte[] COMMAND_ENABLE_BAND_SCREEN_UNLOCK = new byte[]{ENDPOINT_DISPLAY, 0x16, 0x00, 0x01};
+ public static final byte[] COMMAND_DISABLE_BAND_SCREEN_UNLOCK = new byte[]{ENDPOINT_DISPLAY, 0x16, 0x00, 0x00};
+ public static final byte[] COMMAND_NIGHT_MODE_OFF = new byte[]{0x1a, 0x00};
+ public static final byte[] COMMAND_NIGHT_MODE_SUNSET = new byte[]{0x1a, 0x02};
+ public static final byte[] COMMAND_NIGHT_MODE_SCHEDULED = new byte[]{0x1a, 0x01, 0x10, 0x00, 0x07, 0x00};
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4Coordinator.java
new file mode 100644
index 0000000..9baeae8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4Coordinator.java
@@ -0,0 +1,185 @@
+/* Copyright (C) 2019-2024 Adam B眉chner, Andreas Shimokawa, Daniel Dakhno,
+ HardLight, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.huami.miband4;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband4.MiBand4Support;
+
+public class MiBand4Coordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Xiaomi";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuamiConst.MI_BAND4_NAME, Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final MiBand4FWInstallHandler handler = new MiBand4FWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_dateformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_miband3);
+ display.add(R.xml.devicesettings_nightmode);
+ display.add(R.xml.devicesettings_liftwrist_display_sensitivity);
+ display.add(R.xml.devicesettings_swipeunlock);
+ display.add(R.xml.devicesettings_password);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep_activity);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> workout = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.WORKOUT);
+ workout.add(R.xml.devicesettings_workout_start_on_phone);
+ workout.add(R.xml.devicesettings_workout_send_gps_to_band);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "ar_SA",
+ "de_DE",
+ "el_GR",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "id_ID",
+ "it_IT",
+ "pt_PT",
+ "pl_PL",
+ "ru_RU",
+ "th_TH",
+ "tr_TR",
+ "uk_UA",
+ "vi_VN",
+ "zh_CN",
+ "zh_TW",
+ };
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public PasswordCapabilityImpl.Mode getPasswordCapability() {
+ return PasswordCapabilityImpl.Mode.NUMBERS_4_DIGITS_1_TO_4;
+ }
+
+ @Override
+ public List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
+ return Arrays.asList(
+ HeartRateCapability.MeasurementInterval.OFF,
+ HeartRateCapability.MeasurementInterval.MINUTES_1,
+ HeartRateCapability.MeasurementInterval.MINUTES_5,
+ HeartRateCapability.MeasurementInterval.MINUTES_10,
+ HeartRateCapability.MeasurementInterval.MINUTES_30
+ );
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MiBand4Support.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband4;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband2;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband2_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4FWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4FWHelper.java
new file mode 100644
index 0000000..6717540
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4FWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.devices.huami.miband4;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband4.MiBand4FirmwareInfo;
+
+public class MiBand4FWHelper extends HuamiFWHelper {
+
+ public MiBand4FWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new MiBand4FirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a Mi Band 4 firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4FWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4FWInstallHandler.java
new file mode 100644
index 0000000..3581eea
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband4/MiBand4FWInstallHandler.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa
+
+ 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.devices.huami.miband4;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband3.MiBand3FWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class MiBand4FWInstallHandler extends AbstractMiBandFWInstallHandler {
+ MiBand4FWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_miband4, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new MiBand4FWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.MIBAND4;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5Coordinator.java
new file mode 100644
index 0000000..db7f4d6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5Coordinator.java
@@ -0,0 +1,204 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo,
+ odavo32nof, Petr Van臎k
+
+ 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.devices.huami.miband5;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband5.MiBand5Support;
+
+public class MiBand5Coordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Xiaomi";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuamiConst.MI_BAND5_NAME, Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final MiBand5FWInstallHandler handler = new MiBand5FWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public int getReminderSlotCount(final GBDevice device) {
+ return 50; // as enforced by Zepp Life
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 20; // as enforced by Mi Fit
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ return 30; // at least
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_dateformat);
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_miband5);
+ display.add(R.xml.devicesettings_nightmode);
+ display.add(R.xml.devicesettings_liftwrist_display_sensitivity);
+ display.add(R.xml.devicesettings_swipeunlock);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep_alert_activity_stress);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> workout = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.WORKOUT);
+ workout.add(R.xml.devicesettings_workout_start_on_phone);
+ workout.add(R.xml.devicesettings_workout_send_gps_to_band);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "ar_SA",
+ "cs_CZ",
+ "de_DE",
+ "el_GR",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "he_IL",
+ "id_ID",
+ "it_IT",
+ "nl_NL",
+ "pt_BR",
+ "pl_PL",
+ "ro_RO",
+ "ru_RU",
+ "th_TH",
+ "tr_TR",
+ "uk_UA",
+ "vi_VN",
+ "zh_CN",
+ "zh_TW",
+ };
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
+ return Arrays.asList(
+ HeartRateCapability.MeasurementInterval.OFF,
+ HeartRateCapability.MeasurementInterval.MINUTES_1,
+ HeartRateCapability.MeasurementInterval.MINUTES_5,
+ HeartRateCapability.MeasurementInterval.MINUTES_10,
+ HeartRateCapability.MeasurementInterval.MINUTES_30
+ );
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MiBand5Support.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband5;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband2;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband2_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5FWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5FWHelper.java
new file mode 100644
index 0000000..3dd3ed1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5FWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2020-2024 odavo32nof
+
+ 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.devices.huami.miband5;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband5.MiBand5FirmwareInfo;
+
+public class MiBand5FWHelper extends HuamiFWHelper {
+
+ public MiBand5FWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new MiBand5FirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a Mi Band 5 firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5FWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5FWInstallHandler.java
new file mode 100644
index 0000000..db96139
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband5/MiBand5FWInstallHandler.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2020-2024 odavo32nof
+
+ 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.devices.huami.miband5;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband5.MiBand5FWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class MiBand5FWInstallHandler extends AbstractMiBandFWInstallHandler {
+ MiBand5FWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_miband5, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new MiBand5FWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.MIBAND5;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6Coordinator.java
new file mode 100644
index 0000000..d158c68
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6Coordinator.java
@@ -0,0 +1,226 @@
+/* Copyright (C) 2021-2024 Aidan Crane, Andreas Shimokawa, Daniel Dakhno,
+ Daniele Gobbetti, jhey, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.huami.miband6;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband6.MiBand6Support;
+
+public class MiBand6Coordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Xiaomi";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuamiConst.MI_BAND6_NAME, Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final MiBand6FWInstallHandler handler = new MiBand6FWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsPai() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 20; // as enforced by Mi Fit
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ return 30; // at least
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_dateformat);
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_miband6);
+ display.add(R.xml.devicesettings_nightmode);
+ display.add(R.xml.devicesettings_liftwrist_display_sensitivity);
+ display.add(R.xml.devicesettings_swipeunlock);
+ display.add(R.xml.devicesettings_password);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep_alert_activity_stress);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> workout = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.WORKOUT);
+ workout.add(R.xml.devicesettings_workout_start_on_phone);
+ workout.add(R.xml.devicesettings_workout_send_gps_to_band);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_custom_emoji_font);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificAuthenticationSettings() {
+ return new int[]{
+ R.xml.devicesettings_pairingkey,
+ R.xml.devicesettings_miband6_new_protocol,
+ R.xml.devicesettings_miband6_new_auth_protocol_explanation,
+ };
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "ar_SA",
+ "cs_CZ",
+ "de_DE",
+ "el_GR",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "he_IL",
+ "id_ID",
+ "it_IT",
+ "nl_NL",
+ "pt_PT",
+ "pl_PL",
+ "ro_RO",
+ "ru_RU",
+ "th_TH",
+ "tr_TR",
+ "uk_UA",
+ "vi_VN",
+ "zh_CN",
+ "zh_TW",
+ };
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public PasswordCapabilityImpl.Mode getPasswordCapability() {
+ return PasswordCapabilityImpl.Mode.NUMBERS_6;
+ }
+
+ @Override
+ public List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
+ return Arrays.asList(
+ HeartRateCapability.MeasurementInterval.OFF,
+ HeartRateCapability.MeasurementInterval.MINUTES_1,
+ HeartRateCapability.MeasurementInterval.MINUTES_5,
+ HeartRateCapability.MeasurementInterval.MINUTES_10,
+ HeartRateCapability.MeasurementInterval.MINUTES_30
+ );
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MiBand6Support.class;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband6;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband6;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband6_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6FWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6FWHelper.java
new file mode 100644
index 0000000..8764614
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6FWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, jhey
+
+ 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.devices.huami.miband6;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband6.MiBand6FirmwareInfo;
+
+public class MiBand6FWHelper extends HuamiFWHelper {
+
+ public MiBand6FWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new MiBand6FirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a Mi Band 6 firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6FWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6FWInstallHandler.java
new file mode 100644
index 0000000..6bf497a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband6/MiBand6FWInstallHandler.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2021-2024 jhey
+
+ 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.devices.huami.miband6;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband5.MiBand5FWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class MiBand6FWInstallHandler extends AbstractMiBandFWInstallHandler {
+ MiBand6FWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_miband6, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new MiBand6FWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.MIBAND6;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7Coordinator.java
new file mode 100644
index 0000000..3dc1446
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7Coordinator.java
@@ -0,0 +1,87 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.miband7;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class MiBand7Coordinator extends ZeppOsCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Xiaomi";
+ }
+
+ @Override
+ public String getDeviceBluetoothName() {
+ return HuamiConst.XIAOMI_SMART_BAND7_NAME;
+ }
+
+ @Override
+ public Set<Integer> getDeviceSources() {
+ return new HashSet<>(Arrays.asList(260, 262, 263, 264, 265));
+ }
+
+ @Override
+ protected Map<Integer, String> getCrcMap() {
+ return new HashMap<Integer, String>() {{
+ // firmware
+ put(26036, "1.20.3.1");
+ put(55449, "1.27.0.4");
+ put(14502, "2.0.0.2");
+ put(25658, "2.1.0.1");
+ }};
+ }
+
+ @Override
+ public boolean supportsAgpsUpdates() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband7;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband6;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband6_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppECoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppECoordinator.java
new file mode 100644
index 0000000..4a2f6a3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppECoordinator.java
@@ -0,0 +1,143 @@
+/* Copyright (C) 2021-2024 Andrew Watkins, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.huami.zeppe;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppe.ZeppESupport;
+
+public class ZeppECoordinator extends HuamiCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Zepp E", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final ZeppEFWInstallHandler handler = new ZeppEFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+ generic.add(R.xml.devicesettings_device_actions);
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_amazfitgtsgtr);
+ display.add(R.xml.devicesettings_liftwrist_display);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_heartrate_sleep);
+ health.add(R.xml.devicesettings_inactivity_dnd);
+ health.add(R.xml.devicesettings_goal_notification);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_phone_silent_mode);
+ notifications.add(R.xml.devicesettings_transliteration);
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ //calendar.add(R.xml.devicesettings_reserve_reminders_calendar);
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_disconnectnotification);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+ connection.add(R.xml.devicesettings_overwrite_settings_on_connection);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_BR",
+ "tr_TR",
+ "cs_CZ",
+ "ru_RU",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return ZeppESupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_zepp_e;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppEFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppEFWHelper.java
new file mode 100644
index 0000000..2c36c14
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppEFWHelper.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, Andrew Watkins
+
+ 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.devices.huami.zeppe;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppe.ZeppEFirmwareInfo;
+
+public class ZeppEFWHelper extends HuamiFWHelper {
+
+ public ZeppEFWHelper(final Uri uri, final Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = new ZeppEFirmwareInfo(wholeFirmwareBytes);
+ if (!firmwareInfo.isHeaderValid()) {
+ throw new IllegalArgumentException("Not a Zepp E firmware");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppEFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppEFWInstallHandler.java
new file mode 100644
index 0000000..78a1d5c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppe/ZeppEFWInstallHandler.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2021-2024 Andrew Watkins
+
+ 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.devices.huami.zeppe;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+class ZeppEFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ ZeppEFWInstallHandler(final Uri uri, final Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice_amazfitgtr, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(final Uri uri, final Context context) throws IOException {
+ return new ZeppEFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(final GBDevice device) {
+ return device.getType() == DeviceType.ZEPP_E;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsActivitySummaryParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsActivitySummaryParser.java
new file mode 100644
index 0000000..fc6244e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsActivitySummaryParser.java
@@ -0,0 +1,212 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo, Reiner Herrmann
+
+ 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.devices.huami.zeppos;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.*;
+
+import android.content.Context;
+
+import com.google.protobuf.InvalidProtocolBufferException;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryProgressEntry;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.proto.HuamiProtos;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiActivityDetailsParser;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsActivityDetailsParser;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsActivityType;
+
+public class ZeppOsActivitySummaryParser extends HuamiActivitySummaryParser {
+ private static final Logger LOG = LoggerFactory.getLogger(ZeppOsActivitySummaryParser.class);
+ private final Context context;
+
+ @Override
+ public AbstractHuamiActivityDetailsParser getDetailsParser(final BaseActivitySummary summary) {
+ return new ZeppOsActivityDetailsParser(summary);
+ }
+
+ public ZeppOsActivitySummaryParser(final Context context) {
+ this.context = context;
+ }
+
+ @Override
+ protected void parseBinaryData(final BaseActivitySummary summary, final Date startTime) {
+ final byte[] rawData = summary.getRawSummaryData();
+ if (rawData == null) {
+ return;
+ }
+
+ final int version = (rawData[0] & 0xff) | ((rawData[1] & 0xff) << 8);
+ if (version != 0x8000) {
+ LOG.warn("Unexpected binary data version {}, parsing might fail", version);
+ }
+
+ final byte[] protobufData = ArrayUtils.subarray(rawData, 2, rawData.length);
+ final HuamiProtos.WorkoutSummary summaryProto;
+ try {
+ summaryProto = HuamiProtos.WorkoutSummary.parseFrom(protobufData);
+ } catch (final InvalidProtocolBufferException e) {
+ LOG.error("Failed to parse summary protobuf data", e);
+ return;
+ }
+
+ if (summaryProto.hasType()) {
+ final ZeppOsActivityType activityType = ZeppOsActivityType
+ .fromCode((byte) summaryProto.getType().getType());
+
+ final ActivityKind activityKind;
+ if (activityType != null) {
+ activityKind = activityType.toActivityKind();
+ } else {
+ LOG.warn("Unknown workout activity type code {}", String.format("0x%X", summaryProto.getType().getType()));
+ activityKind = ActivityKind.UNKNOWN;
+ }
+ summary.setActivityKind(activityKind.getCode());
+ }
+
+ if (summaryProto.hasTime()) {
+ int totalDuration = summaryProto.getTime().getTotalDuration();
+ summary.setEndTime(new Date(startTime.getTime() + totalDuration * 1000L));
+ summaryData.add(ACTIVE_SECONDS, summaryProto.getTime().getWorkoutDuration(), UNIT_SECONDS);
+ // TODO pause durations
+ }
+
+ if (summaryProto.hasLocation()) {
+ summary.setBaseLongitude(summaryProto.getLocation().getBaseLongitude());
+ summary.setBaseLatitude(summaryProto.getLocation().getBaseLatitude());
+ summary.setBaseAltitude(summaryProto.getLocation().getBaseAltitude() / 2);
+ // TODO: Min/Max Latitude/Longitude
+ summaryData.add(ALTITUDE_BASE, summaryProto.getLocation().getBaseAltitude() / 2f, UNIT_METERS);
+ }
+
+ if (summaryProto.hasHeartRate()) {
+ summaryData.add(HR_AVG, summaryProto.getHeartRate().getAvg(), UNIT_BPM);
+ summaryData.add(HR_MAX, summaryProto.getHeartRate().getMax(), UNIT_BPM);
+ summaryData.add(HR_MIN, summaryProto.getHeartRate().getMin(), UNIT_BPM);
+ }
+
+ if (summaryProto.hasSteps()) {
+ summaryData.add(CADENCE_MAX, summaryProto.getSteps().getMaxCadence() * 60, UNIT_SPM);
+ summaryData.add(CADENCE_AVG, summaryProto.getSteps().getAvgCadence() * 60, UNIT_SPM);
+ summaryData.add(STRIDE_AVG, summaryProto.getSteps().getAvgStride(), UNIT_CM);
+ summaryData.add(STEPS, summaryProto.getSteps().getSteps(), UNIT_STEPS);
+ }
+
+ if (summaryProto.hasDistance()) {
+ summaryData.add(DISTANCE_METERS, summaryProto.getDistance().getDistance(), UNIT_METERS);
+ }
+
+ if (summaryProto.hasPace()) {
+ summaryData.add(PACE_MAX, summaryProto.getPace().getBest(), UNIT_SECONDS_PER_M);
+ summaryData.add(PACE_AVG_SECONDS_KM, summaryProto.getPace().getAvg() * 1000, UNIT_SECONDS_PER_KM);
+ }
+
+ if (summaryProto.hasCalories()) {
+ summaryData.add(CALORIES_BURNT, summaryProto.getCalories().getCalories(), UNIT_KCAL);
+ }
+
+ if (summaryProto.hasHeartRateZones()) {
+ // TODO hr zones bpm?
+ if (summaryProto.getHeartRateZones().getZoneTimeCount() == 6) {
+ final double totalTime = summaryProto.getHeartRateZones().getZoneTimeList()
+ .stream()
+ .mapToInt(v -> v)
+ .sum();
+
+ final List<String> zoneOrder = Arrays.asList(HR_ZONE_NA, HR_ZONE_WARM_UP, HR_ZONE_FAT_BURN, HR_ZONE_AEROBIC, HR_ZONE_ANAEROBIC, HR_ZONE_EXTREME);
+ final int[] zoneColors = new int[]{
+ 0,
+ context.getResources().getColor(R.color.hr_zone_warm_up_color),
+ context.getResources().getColor(R.color.hr_zone_easy_color),
+ context.getResources().getColor(R.color.hr_zone_aerobic_color),
+ context.getResources().getColor(R.color.hr_zone_threshold_color),
+ context.getResources().getColor(R.color.hr_zone_maximum_color),
+ };
+ for (int i = 0; i < zoneOrder.size(); i++) {
+ summaryData.add(
+ zoneOrder.get(i),
+ new ActivitySummaryProgressEntry(
+ summaryProto.getHeartRateZones().getZoneTime(i),
+ UNIT_SECONDS,
+ (int) ((100 * summaryProto.getHeartRateZones().getZoneTime(i)) / totalTime),
+ zoneColors[i]
+ )
+ );
+ }
+ } else {
+ LOG.warn("Unexpected number of HR zones {}", summaryProto.getHeartRateZones().getZoneTimeCount());
+ }
+ }
+
+ if (summaryProto.hasTrainingEffect()) {
+ summaryData.add(TRAINING_EFFECT_AEROBIC, summaryProto.getTrainingEffect().getAerobicTrainingEffect(), UNIT_NONE);
+ summaryData.add(TRAINING_EFFECT_ANAEROBIC, summaryProto.getTrainingEffect().getAnaerobicTrainingEffect(), UNIT_NONE);
+ summaryData.add(WORKOUT_LOAD, summaryProto.getTrainingEffect().getCurrentWorkoutLoad(), UNIT_NONE);
+ summaryData.add(MAXIMUM_OXYGEN_UPTAKE, summaryProto.getTrainingEffect().getMaximumOxygenUptake(), UNIT_ML_KG_MIN);
+ }
+
+ if (summaryProto.hasAltitude()) {
+ summaryData.add(ALTITUDE_MAX, summaryProto.getAltitude().getMaxAltitude() / 200f, UNIT_METERS);
+ summaryData.add(ALTITUDE_MIN, summaryProto.getAltitude().getMinAltitude() / 200f, UNIT_METERS);
+ summaryData.add(ALTITUDE_AVG, summaryProto.getAltitude().getAvgAltitude() / 200f, UNIT_METERS);
+ // TODO totalClimbing
+ summaryData.add(ELEVATION_GAIN, summaryProto.getAltitude().getElevationGain() / 100f, UNIT_METERS);
+ summaryData.add(ELEVATION_LOSS, summaryProto.getAltitude().getElevationLoss() / 100f, UNIT_METERS);
+ }
+
+ if (summaryProto.hasElevation()) {
+ summaryData.add(ASCENT_SECONDS, summaryProto.getElevation().getUphillTime(), UNIT_SECONDS);
+ summaryData.add(DESCENT_SECONDS, summaryProto.getElevation().getDownhillTime(), UNIT_SECONDS);
+ }
+
+ if (summaryProto.hasSwimmingData()) {
+ summaryData.add(LAPS, summaryProto.getSwimmingData().getLaps(), UNIT_LAPS);
+ switch (summaryProto.getSwimmingData().getLaneLengthUnit()) {
+ case 0:
+ summaryData.add(LANE_LENGTH, summaryProto.getSwimmingData().getLaneLength(), UNIT_METERS);
+ break;
+ case 1:
+ summaryData.add(LANE_LENGTH, summaryProto.getSwimmingData().getLaneLength(), UNIT_YARD);
+ break;
+ }
+ switch (summaryProto.getSwimmingData().getStyle()) {
+ // TODO i18n these
+ case 1:
+ summaryData.add(SWIM_STYLE, "breaststroke");
+ break;
+ case 2:
+ summaryData.add(SWIM_STYLE, "freestyle");
+ break;
+ }
+ summaryData.add(STROKES, summaryProto.getSwimmingData().getStrokes(), UNIT_STROKES);
+ summaryData.add(STROKE_RATE_AVG, summaryProto.getSwimmingData().getAvgStrokeRate(), UNIT_STROKES_PER_MINUTE);
+ summaryData.add(STROKE_RATE_MAX, summaryProto.getSwimmingData().getMaxStrokeRate(), UNIT_STROKES_PER_MINUTE);
+ summaryData.add(STROKE_DISTANCE_AVG, summaryProto.getSwimmingData().getAvgDps(), UNIT_CM);
+ summaryData.add(SWOLF_INDEX, summaryProto.getSwimmingData().getSwolf(), UNIT_NONE);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsAgpsInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsAgpsInstallHandler.java
new file mode 100644
index 0000000..6349f6c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsAgpsInstallHandler.java
@@ -0,0 +1,138 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operations.ZeppOsAgpsFile;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+
+public class ZeppOsAgpsInstallHandler implements InstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(ZeppOsAgpsInstallHandler.class);
+
+ protected final Context mContext;
+ private ZeppOsAgpsFile file;
+
+ public ZeppOsAgpsInstallHandler(final Uri uri, final Context context) {
+ this.mContext = context;
+
+ final UriHelper uriHelper;
+ try {
+ uriHelper = UriHelper.get(uri, context);
+ } catch (final IOException e) {
+ LOG.error("Failed to get uri", e);
+ return;
+ }
+
+ try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ final byte[] rawBytes = FileUtils.readAll(in, 1024 * 1024); // 1MB, they're usually ~128KB
+ final ZeppOsAgpsFile agpsFile = new ZeppOsAgpsFile(rawBytes);
+ if (agpsFile.isValid()) {
+ this.file = agpsFile;
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to read file", e);
+ }
+ }
+
+ @Override
+ public boolean isValid() {
+ return file != null;
+ }
+
+ @Override
+ public void validateInstallation(final InstallActivity installActivity, final GBDevice device) {
+ if (device.isBusy()) {
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ if (!(coordinator instanceof ZeppOsCoordinator)) {
+ LOG.warn("Coordinator is not a ZeppOsCoordinator: {}", coordinator.getClass());
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ final ZeppOsCoordinator zeppOsCoordinator = (ZeppOsCoordinator) coordinator;
+ if (!zeppOsCoordinator.supportsAgpsUpdates()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!device.isInitialized()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final GenericItem fwItem = createInstallItem(device);
+ fwItem.setIcon(coordinator.getDefaultIconResource());
+
+ if (file == null) {
+ fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_incompatible_version));
+ installActivity.setInfoText(mContext.getString(R.string.fwinstaller_firmware_not_compatible_to_device));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final StringBuilder builder = new StringBuilder();
+ final String agpsBundle = mContext.getString(R.string.kind_agps_bundle);
+ builder.append(mContext.getString(R.string.fw_upgrade_notice, agpsBundle));
+ builder.append("\n\n").append(mContext.getString(R.string.miband_firmware_unknown_warning));
+ fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_untested_version));
+ installActivity.setInfoText(builder.toString());
+ installActivity.setInstallItem(fwItem);
+ installActivity.setInstallEnabled(true);
+ }
+
+ @Override
+ public void onStartInstall(final GBDevice device) {
+ }
+
+ public ZeppOsAgpsFile getFile() {
+ return file;
+ }
+
+ private GenericItem createInstallItem(final GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final String firmwareName = mContext.getString(
+ R.string.installhandler_firmware_name,
+ mContext.getString(coordinator.getDeviceNameResource()),
+ mContext.getString(R.string.kind_agps_bundle),
+ ""
+ );
+ return new GenericItem(firmwareName);
+ }
+}
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
new file mode 100644
index 0000000..5fa92ea
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsCoordinator.java
@@ -0,0 +1,629 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.Vo2MaxSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.WorkoutVo2MaxSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiExtendedSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiExtendedActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiHeartRateManualSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiHeartRateMaxSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiHeartRateRestingSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiPaiSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSleepRespiratoryRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuamiStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsFwInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAlexaService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsContactsService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsLogsService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsLoyaltyCardService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsRemindersService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsShortcutCardsService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiLanguageType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiVibrationPatternNotificationType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsPhoneService;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.devices.SleepAsAndroidFeature;
+
+public abstract class ZeppOsCoordinator extends HuamiCoordinator {
+ public abstract String getDeviceBluetoothName();
+
+ public abstract Set<Integer> getDeviceSources();
+
+ protected Map<Integer, String> getCrcMap() {
+ // A map from CRC16 to human-readable version for flashable files
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Amazfit";
+ }
+
+ @Override
+ protected final Pattern getSupportedDeviceName() {
+ // Most devices use the exact bluetooth name
+ // Some devices have a " XXXX" suffix with the last 4 digits of mac address (eg. Mi Band 7)
+ // *However*, some devices broadcast a 2nd bluetooth device with "-XXXX" suffix, which I believe
+ // is only used for calls, and Gadgetbridge can't use for pairing, but I was not yet able to
+ // fully confirm this, so we still recognize them.
+ return Pattern.compile("^" + getDeviceBluetoothName() + "([- ][A-Z0-9]{4})?$");
+ }
+
+ @NonNull
+ @Override
+ public final Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return ZeppOsSupport.class;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ if (supportsAgpsUpdates()) {
+ final ZeppOsAgpsInstallHandler agpsInstallHandler = new ZeppOsAgpsInstallHandler(uri, context);
+ if (agpsInstallHandler.isValid()) {
+ return agpsInstallHandler;
+ }
+ }
+
+ if (supportsGpxUploads()) {
+ final ZeppOsGpxRouteInstallHandler gpxRouteInstallHandler = new ZeppOsGpxRouteInstallHandler(uri, context);
+ if (gpxRouteInstallHandler.isValid()) {
+ return gpxRouteInstallHandler;
+ }
+ }
+
+ final ZeppOsFwInstallHandler handler = new ZeppOsFwInstallHandler(
+ uri,
+ context,
+ getDeviceBluetoothName(),
+ getDeviceSources()
+ );
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsManualHeartRateMeasurement(final GBDevice device) {
+ // TODO: It should be supported, but not yet properly implemented
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsRemSleep() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSpo2(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsVO2Max() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsVO2MaxRunning() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateStats() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsPai() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSleepRespiratoryRate() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ 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
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ return 30; // at least
+ }
+
+ @Override
+ public boolean supportsDisabledWorldClocks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return experimentalFeatures(device);
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return AppManagerActivity.class;
+ }
+
+ @Override
+ public File getAppCacheDir() throws IOException {
+ return new File(FileUtils.getExternalFilesDir(), "zepp-os-app-cache");
+ }
+
+ @Override
+ public String getAppCacheSortFilename() {
+ return "zepp-os-app-cache-order.txt";
+ }
+
+ @Override
+ public String getAppFileExtension() {
+ return ".zip";
+ }
+
+ @Override
+ public boolean supportsAppListFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return true;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull final GBDevice gbDevice,
+ @NonNull final Device device,
+ @NonNull final DaoSession session) throws GBException {
+ final Long deviceId = device.getId();
+
+ session.getHuamiExtendedActivitySampleDao().queryBuilder()
+ .where(HuamiExtendedActivitySampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getHuamiStressSampleDao().queryBuilder()
+ .where(HuamiStressSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getHuamiSpo2SampleDao().queryBuilder()
+ .where(HuamiSpo2SampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getHuamiHeartRateManualSampleDao().queryBuilder()
+ .where(HuamiHeartRateManualSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getHuamiHeartRateMaxSampleDao().queryBuilder()
+ .where(HuamiHeartRateMaxSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getHuamiHeartRateRestingSampleDao().queryBuilder()
+ .where(HuamiHeartRateRestingSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getHuamiPaiSampleDao().queryBuilder()
+ .where(HuamiPaiSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getHuamiSleepRespiratoryRateSampleDao().queryBuilder()
+ .where(HuamiSleepRespiratoryRateSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public SampleProvider<? extends AbstractActivitySample> getSampleProvider(final GBDevice device, final DaoSession session) {
+ return new HuamiExtendedSampleProvider(device, session);
+ }
+
+ @Override
+ public Vo2MaxSampleProvider<? extends Vo2MaxSample> getVo2MaxSampleProvider(final GBDevice device, final DaoSession session) {
+ return new WorkoutVo2MaxSampleProvider(device, session);
+ }
+
+ @Override
+ public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
+ return new ZeppOsActivitySummaryParser(context);
+ }
+
+ @Override
+ public boolean supportsAlarmSnoozing() {
+ // All alarms snooze by default, there doesn't seem to be a flag that disables it
+ return false;
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(final GBDevice device, int position) {
+ return true;
+ }
+
+ @Override
+ public int getReminderSlotCount(final GBDevice device) {
+ return ZeppOsRemindersService.getSlotCount(getPrefs(device));
+ }
+
+ @Override
+ public int getCannedRepliesSlotCount(final GBDevice device) {
+ return 16;
+ }
+
+ @Override
+ public int getContactsSlotCount(final GBDevice device) {
+ return getPrefs(device).getInt(ZeppOsContactsService.PREF_CONTACTS_SLOT_COUNT, 0);
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ // Return all known languages by default. Unsupported languages will be removed by Huami2021SettingsCustomizer
+ final List<String> allLanguages = new ArrayList<>(HuamiLanguageType.idLookup.keySet());
+ allLanguages.add(0, "auto");
+ return allLanguages.toArray(new String[0]);
+ }
+
+ @Override
+ public PasswordCapabilityImpl.Mode getPasswordCapability() {
+ return PasswordCapabilityImpl.Mode.NUMBERS_6;
+ }
+
+ @Override
+ public List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
+ // Return all known by default. Unsupported will be removed by Huami2021SettingsCustomizer
+ return Arrays.asList(HeartRateCapability.MeasurementInterval.values());
+ }
+
+ /**
+ * Returns a superset of all settings supported by Zepp OS Devices. Unsupported settings are removed
+ * by {@link ZeppOsSettingsCustomizer}.
+ */
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ //
+ // Apps
+ // TODO: These should go somewhere else
+ //
+ if (ZeppOsLoyaltyCardService.isSupported(getPrefs(device))) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_loyalty_cards);
+ }
+
+ //
+ // Time
+ //
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ //dateTime.add(R.xml.devicesettings_timeformat);
+ dateTime.add(R.xml.devicesettings_dateformat_2);
+ if (getWorldClocksSlotCount() > 0) {
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ }
+
+ //
+ // Display
+ //
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_huami2021_displayitems);
+ display.add(R.xml.devicesettings_huami2021_shortcuts);
+ if (supportsControlCenter()) {
+ display.add(R.xml.devicesettings_huami2021_control_center);
+ }
+ if (supportsShortcutCards(device)) {
+ display.add(R.xml.devicesettings_huami2021_shortcut_cards);
+ }
+ display.add(R.xml.devicesettings_nightmode);
+ display.add(R.xml.devicesettings_sleep_mode);
+ display.add(R.xml.devicesettings_liftwrist_display_sensitivity_with_smart);
+ display.add(R.xml.devicesettings_password);
+ display.add(R.xml.devicesettings_huami2021_watchface);
+ display.add(R.xml.devicesettings_always_on_display);
+ display.add(R.xml.devicesettings_screen_timeout);
+ if (supportsAutoBrightness(device)) {
+ display.add(R.xml.devicesettings_screen_brightness_withauto);
+ } else {
+ display.add(R.xml.devicesettings_screen_brightness);
+ }
+
+ //
+ // Health
+ //
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_heartrate_sleep_alert_activity_stress_spo2);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_inactivity_dnd_no_threshold);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_goal_notification);
+
+ //
+ // Workout
+ //
+ final List<Integer> workout = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.WORKOUT);
+ if (hasGps(device)) {
+ workout.add(R.xml.devicesettings_gps_agps);
+ } else {
+ // If the device has GPS, it doesn't report workout start/end to the phone
+ workout.add(R.xml.devicesettings_workout_start_on_phone);
+ workout.add(R.xml.devicesettings_workout_send_gps_to_band);
+ }
+ workout.add(R.xml.devicesettings_workout_keep_screen_on);
+ workout.add(R.xml.devicesettings_workout_detection);
+
+ //
+ // Notifications
+ //
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
+ if (supportsBluetoothPhoneCalls(device)) {
+ notifications.add(R.xml.devicesettings_phone_calls_watch_pair);
+ } else {
+ notifications.add(R.xml.devicesettings_display_caller);
+ }
+ notifications.add(R.xml.devicesettings_sound_and_vibration);
+ notifications.add(R.xml.devicesettings_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_donotdisturb_withauto_and_always);
+ notifications.add(R.xml.devicesettings_send_app_notifications);
+ notifications.add(R.xml.devicesettings_screen_on_on_notifications);
+ notifications.add(R.xml.devicesettings_autoremove_notifications);
+ notifications.add(R.xml.devicesettings_canned_reply_16);
+ notifications.add(R.xml.devicesettings_transliteration);
+
+ //
+ // Calendar
+ //
+ deviceSpecificSettings.addRootScreen(
+ DeviceSpecificSettingsScreen.CALENDAR,
+ R.xml.devicesettings_sync_calendar
+ );
+
+ //
+ // Other
+ //
+ if (getContactsSlotCount(device) > 0) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_contacts);
+ }
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_offline_voice);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_device_actions_without_not_wear);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_phone_silent_mode);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_buttonactions_upper_long);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_buttonactions_lower_short);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_weardirection);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_camera_remote);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_morning_updates);
+
+ //
+ // Connection
+ //
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_expose_hr_thirdparty);
+ connection.add(R.xml.devicesettings_bt_connected_advertisement);
+ connection.add(R.xml.devicesettings_high_mtu);
+
+ //
+ // Developer
+ //
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ if (ZeppOsLogsService.isSupported(getPrefs(device))) {
+ developer.add(R.xml.devicesettings_app_logs_start_stop);
+ }
+ if (supportsAlexa(device)) {
+ developer.add(R.xml.devicesettings_huami2021_alexa);
+ }
+ if (supportsWifiHotspot(device)) {
+ developer.add(R.xml.devicesettings_wifi_hotspot);
+ }
+ if (supportsFtpServer(device)) {
+ developer.add(R.xml.devicesettings_ftp_server);
+ }
+ developer.add(R.xml.devicesettings_keep_activity_data_on_device);
+ developer.add(R.xml.devicesettings_huami2021_fetch_operation_time_unit);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public List<HuamiVibrationPatternNotificationType> getVibrationPatternNotificationTypes(final GBDevice device) {
+ final List<HuamiVibrationPatternNotificationType> notificationTypes = new ArrayList<>(Arrays.asList(
+ HuamiVibrationPatternNotificationType.APP_ALERTS,
+ HuamiVibrationPatternNotificationType.INCOMING_CALL,
+ HuamiVibrationPatternNotificationType.INCOMING_SMS,
+ HuamiVibrationPatternNotificationType.GOAL_NOTIFICATION,
+ HuamiVibrationPatternNotificationType.ALARM,
+ HuamiVibrationPatternNotificationType.IDLE_ALERTS
+ ));
+
+ if (getReminderSlotCount(device) > 0) {
+ notificationTypes.add(HuamiVibrationPatternNotificationType.EVENT_REMINDER);
+ }
+
+ if (!supportsContinuousFindDevice()) {
+ notificationTypes.add(HuamiVibrationPatternNotificationType.FIND_BAND);
+ }
+
+ if (supportsToDoList()) {
+ notificationTypes.add(HuamiVibrationPatternNotificationType.SCHEDULE);
+ notificationTypes.add(HuamiVibrationPatternNotificationType.TODO_LIST);
+ }
+
+ return notificationTypes;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new ZeppOsSettingsCustomizer(device, getVibrationPatternNotificationTypes(device));
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ public boolean supportsContinuousFindDevice() {
+ // TODO: Auto-detect continuous find device?
+ return false;
+ }
+
+ public boolean supportsAgpsUpdates() {
+ return true;
+ }
+
+ /**
+ * true for Zepp OS 2.0+, false for Zepp OS 1
+ */
+ public boolean sendAgpsAsFileTransfer() {
+ return true;
+ }
+
+ public boolean supportsGpxUploads() {
+ return false;
+ }
+
+ public boolean supportsControlCenter() {
+ // TODO: Auto-detect control center?
+ return false;
+ }
+
+ public boolean supportsToDoList() {
+ // TODO: Not yet implemented
+ // TODO: When implemented, query the capability like reminders
+ return false;
+ }
+
+ public boolean mainMenuHasMoreSection() {
+ // Devices that have a control center don't seem to have a "more" section in the main menu
+ return !supportsControlCenter();
+ }
+
+ public boolean supportsWifiHotspot(final GBDevice device) {
+ return false;
+ }
+
+ public boolean supportsFtpServer(final GBDevice device) {
+ return false;
+ }
+
+ public boolean hasGps(final GBDevice device) {
+ return supportsConfig(device, ZeppOsConfigService.ConfigArg.WORKOUT_GPS_PRESET);
+ }
+
+ public boolean supportsAutoBrightness(final GBDevice device) {
+ return supportsConfig(device, ZeppOsConfigService.ConfigArg.SCREEN_AUTO_BRIGHTNESS);
+ }
+
+ public boolean supportsBluetoothPhoneCalls(final GBDevice device) {
+ return ZeppOsPhoneService.isSupported(getPrefs(device));
+ }
+
+ public boolean supportsShortcutCards(final GBDevice device) {
+ return ZeppOsShortcutCardsService.isSupported(getPrefs(device));
+ }
+
+ public boolean supportsAlexa(final GBDevice device) {
+ return experimentalFeatures(device) && ZeppOsAlexaService.isSupported(getPrefs(device));
+ }
+
+ private boolean supportsConfig(final GBDevice device, final ZeppOsConfigService.ConfigArg config) {
+ return ZeppOsConfigService.deviceHasConfig(getPrefs(device), config);
+ }
+
+ public static boolean experimentalFeatures(final GBDevice device) {
+ return getPrefs(device).getBoolean("zepp_os_experimental_features", false);
+ }
+
+ @Override
+ public boolean validateAuthKey(final String authKey) {
+ final byte[] authKeyBytes = authKey.trim().getBytes();
+ return authKeyBytes.length == 32 || (authKey.trim().startsWith("0x") && authKeyBytes.length == 34);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsFwHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsFwHelper.java
new file mode 100644
index 0000000..803d6a0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsFwHelper.java
@@ -0,0 +1,538 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+
+import androidx.annotation.Nullable;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+import java.util.zip.CRC32;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipException;
+import java.util.zip.ZipFile;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.UIHHContainer;
+import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil;
+import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GBZipFile;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.ZipFileException;
+
+public class ZeppOsFwHelper {
+ private static final Logger LOG = LoggerFactory.getLogger(ZeppOsFwHelper.class);
+
+ private final Uri uri;
+ private final Context context;
+ private final String deviceName;
+ private final Set<Integer> deviceSources;
+
+ private HuamiFirmwareType firmwareType = HuamiFirmwareType.INVALID;
+ private File file = null;
+ private int crc32;
+ private String version = "Unknown";
+ private GBDeviceApp gbDeviceApp = null;
+
+ public ZeppOsFwHelper(final Uri uri, final Context context, final String deviceName, final Set<Integer> deviceSources) {
+ this.uri = uri;
+ this.context = context;
+ this.deviceName = deviceName;
+ this.deviceSources = deviceSources;
+
+ processUri();
+ }
+
+ public HuamiFirmwareType getFirmwareType() {
+ return firmwareType;
+ }
+
+ public String getFirmwareVersion() {
+ return version;
+ }
+
+ public File getFile() {
+ if (file == null) {
+ throw new IllegalStateException("file is null");
+ }
+
+ return file;
+ }
+
+ public int getSize() {
+ if (file == null) {
+ throw new IllegalStateException("file is null");
+ }
+
+ return (int) file.length();
+ }
+
+ public int getCrc32() {
+ return crc32;
+ }
+
+ private void processUri() {
+ // Copy file to cache first
+ final File cacheDir = context.getCacheDir();
+ final File zpkCacheDir = new File(cacheDir, "zeppos");
+ zpkCacheDir.mkdir();
+
+ try {
+ file = File.createTempFile("fwhelper", "bin", context.getCacheDir());
+ file.deleteOnExit();
+ } catch (final IOException e) {
+ LOG.error("Failed to create temp file for zpk", e);
+ return;
+ }
+
+ final UriHelper uriHelper;
+ try {
+ uriHelper = UriHelper.get(uri, context);
+ } catch (final IOException e) {
+ LOG.error("Failed to get uri helper", e);
+ return;
+ }
+
+ final CRC32 crc = new CRC32();
+ try (FileOutputStream outputStream = new FileOutputStream(file);
+ InputStream inputStream = uriHelper.openInputStream()) {
+ final byte[] buffer = new byte[64 * 1024];
+ int len;
+ while ((len = inputStream.read(buffer)) != -1) {
+ outputStream.write(buffer, 0, len);
+ crc.update(buffer, 0, len);
+ }
+ crc32 = (int) crc.getValue();
+ } catch (final IOException e) {
+ LOG.error("Failed to write bytes to temporary file", e);
+ return;
+ }
+
+ final byte[] header = getHeader(file, 4);
+ if (header == null) {
+ return;
+ }
+
+ if (Arrays.equals(header, GBZipFile.ZIP_HEADER)) {
+ try (ZipFile zipFile = new ZipFile(file, java.util.zip.ZipFile.OPEN_READ)) {
+ processZipFile(zipFile);
+ } catch (final ZipException e) {
+ LOG.warn("{} is not a valid zip file", uri, e);
+ } catch (final IOException e) {
+ LOG.warn("Error while processing {}", uri, e);
+ }
+ } else if (Arrays.equals(header, UIHHContainer.UIHH_HEADER)) {
+ // FIXME: This should be refactored to avoid pulling the entire file to memory
+ // However, it's currently only used for agps updates, which are usually just ~140KB
+ try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ final byte[] fullFile = FileUtils.readAll(in, 32 * 1024 * 1024); // 32MB
+ processAsUihh(fullFile);
+ } catch (final IOException e) {
+ LOG.error("Failed to read full uihh from file", e);
+ }
+ }
+ }
+
+ private void processAsUihh(byte[] bytes) {
+ final UIHHContainer uihh = UIHHContainer.fromRawBytes(bytes);
+ if (uihh == null) {
+ LOG.warn("Invalid UIHH file");
+ return;
+ }
+
+ final Set<UIHHContainer.FileType> agpsEpoTypes = new HashSet<>();
+ UIHHContainer.FileEntry uihhFirmwareZipFile = null;
+ boolean hasChangelog = false;
+ for (final UIHHContainer.FileEntry file : uihh.getFiles()) {
+ switch (file.getType()) {
+ case FIRMWARE_ZIP:
+ uihhFirmwareZipFile = file;
+ continue;
+ case FIRMWARE_CHANGELOG:
+ hasChangelog = true;
+ continue;
+ case AGPS_EPO_GR_3:
+ case AGPS_EPO_GAL_7:
+ case AGPS_EPO_BDS_3:
+ agpsEpoTypes.add(file.getType());
+ continue;
+ default:
+ LOG.warn("Unexpected file for {}", file.getType());
+ }
+ }
+
+ if (uihhFirmwareZipFile != null && hasChangelog) {
+ // UIHH firmware update
+ final GBZipFile zipFile = new GBZipFile(uihhFirmwareZipFile.getContent());
+ final byte[] firmwareBin;
+ try {
+ firmwareBin = zipFile.getFileFromZip("META/firmware.bin");
+ } catch (final ZipFileException e) {
+ LOG.error("Failed to read zip from UIHH", e);
+ return;
+ }
+
+ if (isCompatibleFirmwareBin(firmwareBin)) {
+ firmwareType = HuamiFirmwareType.FIRMWARE_UIHH_2021_ZIP_WITH_CHANGELOG;
+ }
+ } else if (agpsEpoTypes.size() == 3) {
+ // AGPS EPO update
+ firmwareType = HuamiFirmwareType.AGPS_UIHH;
+ }
+ }
+
+ private void processZipFile(final ZipFile zipFile) {
+ // Attempt to handle as a firmware
+ final byte[] firmwareBin = getFileFromZip(zipFile, "META/firmware.bin");
+ if (firmwareBin != null) {
+ if (isCompatibleFirmwareBin(firmwareBin)) {
+ firmwareType = HuamiFirmwareType.FIRMWARE;
+ final JSONObject fwInfoRoot = getJson(zipFile, "META/fw_info");
+ if (fwInfoRoot != null) {
+ final JSONArray fwInfoArr = fwInfoRoot.optJSONArray("fw_info");
+ if (fwInfoArr != null) {
+ for (int i = 0; i < fwInfoArr.length(); i++) {
+ final JSONObject fwInfo = fwInfoArr.optJSONObject(i);
+ if (fwInfo == null) {
+ continue;
+ }
+
+ if ("firmware".equals(fwInfo.optString("name"))) {
+ version = fwInfo.optString("version");
+ break;
+ }
+ }
+ }
+ } else {
+ version = getFirmwareVersion(firmwareBin);
+ }
+ } else {
+ firmwareType = HuamiFirmwareType.INVALID;
+ }
+
+ return;
+ }
+
+ // Attempt to handle as an app / watchface
+ final JSONObject appJson = getJson(zipFile, "app.json");
+ if (appJson != null) {
+ final int appId;
+ final String appName;
+ final String appVersion;
+ final String appType;
+ final String appCreator;
+ final String appIconPath;
+ final JSONObject appJsonApp;
+ try {
+ appJsonApp = appJson.getJSONObject("app");
+ appId = appJsonApp.getInt("appId");
+ appName = appJsonApp.getString("appName");
+ appVersion = appJsonApp.getJSONObject("version").getString("name");
+ appType = appJsonApp.getString("appType");
+ appCreator = appJsonApp.getString("vender");
+ appIconPath = appJsonApp.getString("icon");
+ } catch (final Exception e) {
+ LOG.error("Failed to get appType from app.json", e);
+ firmwareType = HuamiFirmwareType.INVALID;
+ return;
+ }
+
+ final GBDeviceApp.Type gbDeviceAppType;
+ switch (appType) {
+ case "watchface":
+ firmwareType = HuamiFirmwareType.WATCHFACE;
+ gbDeviceAppType = GBDeviceApp.Type.WATCHFACE;
+ version = String.format("%s (watchface)", appName);
+ break;
+ case "app":
+ firmwareType = HuamiFirmwareType.APP;
+ gbDeviceAppType = GBDeviceApp.Type.APP_GENERIC;
+ version = String.format("%s (app)", appName);
+ break;
+ default:
+ LOG.warn("Unknown app type {}", appType);
+ firmwareType = HuamiFirmwareType.INVALID;
+ return;
+ }
+
+ Bitmap icon = null;
+ final byte[] iconBytes = getFileFromZip(zipFile, "assets/" + appIconPath);
+ if (iconBytes != null) {
+ if (BitmapUtil.isPng(iconBytes)) {
+ icon = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.length);
+ } else {
+ icon = BitmapUtil.decodeTga(iconBytes);
+ }
+ }
+
+ gbDeviceApp = new GBDeviceApp(
+ UUID.fromString(String.format("%08x-0000-0000-0000-000000000000", appId)),
+ appName,
+ appCreator,
+ appVersion,
+ gbDeviceAppType,
+ icon
+ );
+
+ return;
+ }
+
+ // Attempt to handle as a zab file
+ final byte[] zpkBytes = handleZabPackage(zipFile);
+ if (zpkBytes != null) {
+ final File cacheDir = context.getCacheDir();
+ final File zpkCacheDir = new File(cacheDir, "zpk");
+ zpkCacheDir.mkdir();
+
+ final File zpkFile;
+ try {
+ zpkFile = File.createTempFile("zpk", "zip", context.getCacheDir());
+ zpkFile.deleteOnExit();
+ } catch (final IOException e) {
+ LOG.error("Failed to create temp file for zpk", e);
+ return;
+ }
+
+ try (FileOutputStream outputStream = new FileOutputStream(zpkFile)) {
+ outputStream.write(zpkBytes);
+ } catch (final IOException e) {
+ LOG.error("Failed to write zpk bytes to temporary file", e);
+ return;
+ }
+
+ try (ZipFile zpkZpkFile = new ZipFile(zpkFile, java.util.zip.ZipFile.OPEN_READ)) {
+ processZipFile(zpkZpkFile);
+ } catch (final ZipException e) {
+ LOG.warn("{} is not a valid zip file", uri, e);
+ } catch (final IOException e) {
+ LOG.warn("Error while processing {}", uri, e);
+ }
+
+ if (firmwareType != HuamiFirmwareType.INVALID) {
+ file = zpkFile;
+ crc32 = CheckSums.getCRC32(zpkBytes);
+ }
+ }
+ }
+
+ /**
+ * A zab package is a zip file with:
+ * - manifest.json
+ * - .sc (source code)
+ * - One or more zpk files
+ * <p>
+ * Right now, we only handle the first compatible zpk file that is supported by the connected device.
+ */
+ private byte[] handleZabPackage(final ZipFile zipFile) {
+ final JSONObject manifest = getJson(zipFile, "manifest.json");
+ if (manifest == null) {
+ return null;
+ }
+
+ final JSONArray zpks;
+ try {
+ zpks = manifest.getJSONArray("zpks");
+ } catch (final Exception e) {
+ LOG.error("Failed to get zpks from manifest.json", e);
+ return null;
+ }
+
+ // Iterate all zpks until a compatible one is found
+ for (int i = 0; i < zpks.length(); i++) {
+ try {
+ final JSONObject zpkEntry = zpks.getJSONObject(i);
+ final JSONArray platforms = zpkEntry.getJSONArray("platforms");
+
+ // Check if this zpk is compatible with the current device
+ for (int j = 0; j < platforms.length(); j++) {
+ final JSONObject platform = platforms.getJSONObject(j);
+
+ if (deviceSources.contains(platform.getInt("deviceSource"))) {
+ // It's compatible with the device, fetch device.zip
+ final String name = zpkEntry.getString("name");
+ final byte[] zpkBytes = getFileFromZip(zipFile, name);
+ if (!GBZipFile.isZipFile(zpkBytes)) {
+ LOG.warn("bytes for {} not a zip file", name);
+ continue;
+ }
+ final GBZipFile zpkFile = new GBZipFile(zpkBytes);
+ final byte[] deviceZip = zpkFile.getFileFromZip("device.zip");
+ if (!GBZipFile.isZipFile(zpkBytes)) {
+ LOG.warn("bytes for device.zip of zpk {} not a zip file", name);
+ continue;
+ }
+
+ return deviceZip;
+ }
+ }
+ } catch (final Exception e) {
+ LOG.warn("Failed to parse zpk", e);
+ }
+ }
+
+ LOG.warn("No compatible zpk found in zab file");
+
+ return null;
+ }
+
+ @Nullable
+ public GBDeviceApp getAppInfo() {
+ return gbDeviceApp;
+ }
+
+ public boolean isValid() {
+ return firmwareType != HuamiFirmwareType.INVALID;
+ }
+
+ @Nullable
+ public Bitmap getPreview() {
+ if (gbDeviceApp != null) {
+ return gbDeviceApp.getPreviewImage();
+ }
+
+ return null;
+ }
+
+ private boolean isCompatibleFirmwareBin(final byte[] firmwareBin) {
+ if (firmwareBin == null) {
+ LOG.error("firmware bin is null");
+ return false;
+ }
+
+ if (!searchString(firmwareBin, deviceName)) {
+ LOG.warn("Failed to find {} in fwBytes", deviceName);
+ return false;
+ }
+
+ return true;
+ }
+
+ public static String getFirmwareVersion(final byte[] firmwareBin) {
+ int startIdx = 10;
+ int endIdx = -1;
+
+ for (int i = startIdx; i < startIdx + 20; i++) {
+ byte c = firmwareBin[i];
+
+ if (c == 0) {
+ endIdx = i;
+ break;
+ }
+
+ if (c != '.' && (c < '0' || c > '9')) {
+ // not a valid version character
+ break;
+ }
+ }
+
+ if (endIdx == -1) {
+ LOG.warn("Failed to find firmware version in expected offset");
+ return null;
+ }
+
+ return new String(Arrays.copyOfRange(firmwareBin, startIdx, endIdx));
+ }
+
+ @Nullable
+ private static JSONObject getJson(final ZipFile zipFile, final String path) {
+ final byte[] appJsonBin = getFileFromZip(zipFile, path);
+ if (appJsonBin == null) {
+ return null;
+ }
+
+ try {
+ final String appJsonString = new String(appJsonBin, StandardCharsets.UTF_8)
+ // Remove UTF-8 BOM if present
+ .replace("\uFEFF", "");
+ return new JSONObject(appJsonString);
+ } catch (final Exception e) {
+ LOG.error("Failed to parse " + path, e);
+ }
+
+ return null;
+ }
+
+ @Nullable
+ private static byte[] getFileFromZip(final ZipFile zipFile, final String path) {
+ try {
+ final ZipEntry entry = zipFile.getEntry(path);
+ if (entry == null) {
+ return null;
+ }
+ return GBZipFile.readAllBytes(zipFile.getInputStream(entry));
+ } catch (final IOException e) {
+ LOG.error("Failed to read " + path, e);
+ return null;
+ }
+ }
+
+ @Nullable
+ public static byte[] getHeader(final File file, final int bytes) {
+ final byte[] header = new byte[bytes];
+
+ try (InputStream is = new FileInputStream(file)) {
+ if (is.read(header) != header.length) {
+ LOG.warn("Read unexpected number of header bytes");
+ return null;
+ }
+ } catch (final IOException e) {
+ LOG.error("Error while reading header bytes", e);
+ return null;
+ }
+
+ return header;
+ }
+
+ public static boolean searchString(final byte[] fwBytes, final String str) {
+ final byte[] strBytes = (str + "\0").getBytes(StandardCharsets.UTF_8);
+
+ for (int i = 0; i < fwBytes.length - strBytes.length + 1; i++) {
+ boolean found = true;
+ for (int j = 0; j < strBytes.length; j++) {
+ if (fwBytes[i + j] != strBytes[j]) {
+ found = false;
+ break;
+ }
+ }
+ if (found) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsGpxRouteInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsGpxRouteInstallHandler.java
new file mode 100644
index 0000000..a172156
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsGpxRouteInstallHandler.java
@@ -0,0 +1,135 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operations.ZeppOsGpxRouteFile;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+
+public class ZeppOsGpxRouteInstallHandler implements InstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(ZeppOsGpxRouteInstallHandler.class);
+
+ protected final Context mContext;
+ private ZeppOsGpxRouteFile file;
+
+ public ZeppOsGpxRouteInstallHandler(final Uri uri, final Context context) {
+ this.mContext = context;
+
+ final UriHelper uriHelper;
+ try {
+ uriHelper = UriHelper.get(uri, context);
+ } catch (final IOException e) {
+ LOG.error("Failed to get uri", e);
+ return;
+ }
+ try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ final byte[] rawBytes = FileUtils.readAll(in, 1024 * 1024); // 1MB
+ final ZeppOsGpxRouteFile gpxFile = new ZeppOsGpxRouteFile(rawBytes);
+ if (gpxFile.isValid()) {
+ this.file = gpxFile;
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to read file", e);
+ }
+ }
+
+ @Override
+ public boolean isValid() {
+ return file != null;
+ }
+
+ @Override
+ public void validateInstallation(final InstallActivity installActivity, final GBDevice device) {
+ if (device.isBusy()) {
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ if (!(coordinator instanceof ZeppOsCoordinator)) {
+ LOG.warn("Coordinator is not a ZeppOsCoordinator: {}", coordinator.getClass());
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ final ZeppOsCoordinator zeppOsCoordinator = (ZeppOsCoordinator) coordinator;
+ if (!zeppOsCoordinator.supportsGpxUploads()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!device.isInitialized()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final GenericItem fwItem = createInstallItem(device);
+ fwItem.setIcon(coordinator.getDefaultIconResource());
+
+ if (file == null) {
+ fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_incompatible_version));
+ installActivity.setInfoText(mContext.getString(R.string.fwinstaller_firmware_not_compatible_to_device));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final StringBuilder builder = new StringBuilder();
+ final String gpxRoute = mContext.getString(R.string.kind_gpx_route);
+ builder.append(mContext.getString(R.string.fw_upgrade_notice, gpxRoute));
+ installActivity.setInfoText(builder.toString());
+ installActivity.setInstallItem(fwItem);
+ installActivity.setInstallEnabled(true);
+ }
+
+ @Override
+ public void onStartInstall(final GBDevice device) {
+ }
+
+ public ZeppOsGpxRouteFile getFile() {
+ return file;
+ }
+
+ private GenericItem createInstallItem(final GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ final String firmwareName = mContext.getString(
+ R.string.installhandler_firmware_name,
+ mContext.getString(coordinator.getDeviceNameResource()),
+ mContext.getString(R.string.kind_gpx_route),
+ file.getName()
+ );
+ return new GenericItem(firmwareName);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsSettingsCustomizer.java
new file mode 100644
index 0000000..df62ab7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsSettingsCustomizer.java
@@ -0,0 +1,457 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsUtils.hidePrefIfNoneVisible;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsUtils.populateOrHideListPreference;
+
+import android.os.Parcel;
+
+import androidx.preference.EditTextPreference;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsUtils;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.activities.loyaltycards.LoyaltyCardsSettingsConst;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.GpsCapability;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiVibrationPatternNotificationType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class ZeppOsSettingsCustomizer extends HuamiSettingsCustomizer {
+ private static final Logger LOG = LoggerFactory.getLogger(ZeppOsSettingsCustomizer.class);
+
+ public ZeppOsSettingsCustomizer(final GBDevice device, final List<HuamiVibrationPatternNotificationType> vibrationPatternNotificationTypes) {
+ super(device, vibrationPatternNotificationTypes);
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ super.customizeSettings(handler, prefs, rootKey);
+
+ // These are not reported by the normal configs
+ populateOrHideListPreference(HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE, handler, prefs);
+ populateOrHideListPreference(HuamiConst.PREF_SHORTCUTS_SORTABLE, handler, prefs);
+ populateOrHideListPreference(HuamiConst.PREF_CONTROL_CENTER_SORTABLE, handler, prefs);
+ populateOrHideListPreference(DeviceSettingsPreferenceConst.SHORTCUT_CARDS_SORTABLE, handler, prefs);
+ populateOrHideListPreference(DeviceSettingsPreferenceConst.PREF_WATCHFACE, handler, prefs);
+ populateOrHideListPreference(DeviceSettingsPreferenceConst.MORNING_UPDATES_CATEGORIES_SORTABLE, handler, prefs);
+ populateOrHideListPreference(DeviceSettingsPreferenceConst.PREF_VOICE_SERVICE_LANGUAGE, handler, prefs);
+
+ for (final ZeppOsConfigService.ConfigArg config : ZeppOsConfigService.ConfigArg.values()) {
+ if (config.getPrefKey() == null) {
+ continue;
+ }
+ final ZeppOsConfigService.ConfigType configType = config.getConfigType(null);
+ if (configType == null) {
+ // Should never happen
+ LOG.error("configType is null - this should never happen");
+ return;
+ }
+ switch (configType) {
+ case BYTE:
+ case BYTE_LIST:
+ case STRING_LIST:
+ // For list preferences, remove the unsupported items
+ populateOrHideListPreference(config.getPrefKey(), handler, prefs);
+ break;
+ case SHORT:
+ case INT:
+ hidePrefIfNoConfigSupported(handler, prefs, config.getPrefKey(), config.name());
+ enforceMinMax(handler, prefs, config);
+ break;
+ case BOOL:
+ case DATETIME_HH_MM:
+ case TIMESTAMP_MILLIS:
+ default:
+ // For other preferences, just hide them if they were not reported as supported by the device
+ hidePrefIfNoConfigSupported(handler, prefs, config.getPrefKey(), config.name());
+ break;
+ }
+ }
+
+ // Hide all config groups that may not be mapped directly to a preference
+ final Map<String, List<String>> configScreens = new HashMap<String, List<String>>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_NIGHT_MODE, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.NIGHT_MODE_MODE.name(),
+ ZeppOsConfigService.ConfigArg.NIGHT_MODE_SCHEDULED_START.name(),
+ ZeppOsConfigService.ConfigArg.NIGHT_MODE_SCHEDULED_END.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_SLEEP_MODE, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.SLEEP_MODE_SLEEP_SCREEN.name(),
+ ZeppOsConfigService.ConfigArg.SLEEP_MODE_SMART_ENABLE.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_LIFT_WRIST, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.LIFT_WRIST_MODE.name(),
+ ZeppOsConfigService.ConfigArg.LIFT_WRIST_SCHEDULED_START.name(),
+ ZeppOsConfigService.ConfigArg.LIFT_WRIST_SCHEDULED_END.name(),
+ ZeppOsConfigService.ConfigArg.LIFT_WRIST_RESPONSE_SENSITIVITY.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_PASSWORD, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.PASSWORD_ENABLED.name(),
+ ZeppOsConfigService.ConfigArg.PASSWORD_TEXT.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_ALWAYS_ON_DISPLAY, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.ALWAYS_ON_DISPLAY_MODE.name(),
+ ZeppOsConfigService.ConfigArg.ALWAYS_ON_DISPLAY_SCHEDULED_START.name(),
+ ZeppOsConfigService.ConfigArg.ALWAYS_ON_DISPLAY_SCHEDULED_END.name(),
+ ZeppOsConfigService.ConfigArg.ALWAYS_ON_DISPLAY_FOLLOW_WATCHFACE.name(),
+ ZeppOsConfigService.ConfigArg.ALWAYS_ON_DISPLAY_STYLE.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_AUTO_BRIGHTNESS, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.SCREEN_AUTO_BRIGHTNESS.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_HEARTRATE_MONITORING, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.HEART_RATE_ALL_DAY_MONITORING.name(),
+ ZeppOsConfigService.ConfigArg.HEART_RATE_HIGH_ALERTS.name(),
+ ZeppOsConfigService.ConfigArg.HEART_RATE_LOW_ALERTS.name(),
+ ZeppOsConfigService.ConfigArg.HEART_RATE_ACTIVITY_MONITORING.name(),
+ ZeppOsConfigService.ConfigArg.SLEEP_HIGH_ACCURACY_MONITORING.name(),
+ ZeppOsConfigService.ConfigArg.SLEEP_BREATHING_QUALITY_MONITORING.name(),
+ ZeppOsConfigService.ConfigArg.STRESS_MONITORING.name(),
+ ZeppOsConfigService.ConfigArg.STRESS_RELAXATION_REMINDER.name(),
+ ZeppOsConfigService.ConfigArg.SPO2_ALL_DAY_MONITORING.name(),
+ ZeppOsConfigService.ConfigArg.SPO2_LOW_ALERT.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_INACTIVITY_EXTENDED, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.INACTIVITY_WARNINGS_ENABLED.name(),
+ ZeppOsConfigService.ConfigArg.INACTIVITY_WARNINGS_SCHEDULED_START.name(),
+ ZeppOsConfigService.ConfigArg.INACTIVITY_WARNINGS_SCHEDULED_END.name(),
+ ZeppOsConfigService.ConfigArg.INACTIVITY_WARNINGS_DND_ENABLED.name(),
+ ZeppOsConfigService.ConfigArg.INACTIVITY_WARNINGS_DND_SCHEDULED_START.name(),
+ ZeppOsConfigService.ConfigArg.INACTIVITY_WARNINGS_DND_SCHEDULED_END.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_HEADER_GPS, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.WORKOUT_GPS_PRESET.name(),
+ ZeppOsConfigService.ConfigArg.WORKOUT_GPS_BAND.name(),
+ ZeppOsConfigService.ConfigArg.WORKOUT_GPS_COMBINATION.name(),
+ ZeppOsConfigService.ConfigArg.WORKOUT_GPS_SATELLITE_SEARCH.name(),
+ ZeppOsConfigService.ConfigArg.WORKOUT_AGPS_EXPIRY_REMINDER_ENABLED.name(),
+ ZeppOsConfigService.ConfigArg.WORKOUT_AGPS_EXPIRY_REMINDER_TIME.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_SOUND_AND_VIBRATION, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.VOLUME.name(),
+ ZeppOsConfigService.ConfigArg.CROWN_VIBRATION.name(),
+ ZeppOsConfigService.ConfigArg.ALERT_TONE.name(),
+ ZeppOsConfigService.ConfigArg.COVER_TO_MUTE.name(),
+ ZeppOsConfigService.ConfigArg.VIBRATE_FOR_ALERT.name(),
+ ZeppOsConfigService.ConfigArg.TEXT_TO_SPEECH.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_DO_NOT_DISTURB, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.DND_MODE.name(),
+ ZeppOsConfigService.ConfigArg.DND_SCHEDULED_START.name(),
+ ZeppOsConfigService.ConfigArg.DND_SCHEDULED_END.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_HEADER_WORKOUT_DETECTION, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.WORKOUT_DETECTION_CATEGORY.name(),
+ ZeppOsConfigService.ConfigArg.WORKOUT_DETECTION_ALERT.name(),
+ ZeppOsConfigService.ConfigArg.WORKOUT_DETECTION_SENSITIVITY.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_OFFLINE_VOICE, Arrays.asList(
+ ZeppOsConfigService.ConfigArg.OFFLINE_VOICE_RESPOND_TURN_WRIST.name(),
+ ZeppOsConfigService.ConfigArg.OFFLINE_VOICE_RESPOND_SCREEN_ON.name(),
+ ZeppOsConfigService.ConfigArg.OFFLINE_VOICE_RESPONSE_DURING_SCREEN_LIGHTING.name(),
+ ZeppOsConfigService.ConfigArg.OFFLINE_VOICE_LANGUAGE.name()
+ ));
+ put(DeviceSettingsPreferenceConst.PREF_SCREEN_MORNING_UPDATES, Arrays.asList(
+ DeviceSettingsPreferenceConst.MORNING_UPDATES_ENABLED,
+ DeviceSettingsPreferenceConst.MORNING_UPDATES_CATEGORIES_SORTABLE
+ ));
+ }};
+
+ for (final Map.Entry<String, List<String>> configScreen : configScreens.entrySet()) {
+ hidePrefIfNoConfigSupported(
+ handler,
+ prefs,
+ configScreen.getKey(),
+ configScreen.getValue().toArray(new String[0])
+ );
+ }
+
+ // Hides the headers if none of the preferences under them are available
+ hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_APPS, Arrays.asList(
+ LoyaltyCardsSettingsConst.PREF_KEY_LOYALTY_CARDS
+ ));
+ hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_TIME, Arrays.asList(
+ DeviceSettingsPreferenceConst.PREF_TIMEFORMAT,
+ DeviceSettingsPreferenceConst.PREF_DATEFORMAT,
+ DeviceSettingsPreferenceConst.PREF_WORLD_CLOCKS
+ ));
+ hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_DISPLAY, Arrays.asList(
+ HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE,
+ HuamiConst.PREF_SHORTCUTS_SORTABLE,
+ HuamiConst.PREF_CONTROL_CENTER_SORTABLE,
+ DeviceSettingsPreferenceConst.PREF_SCREEN_NIGHT_MODE,
+ DeviceSettingsPreferenceConst.PREF_SCREEN_SLEEP_MODE,
+ DeviceSettingsPreferenceConst.PREF_SCREEN_LIFT_WRIST,
+ DeviceSettingsPreferenceConst.PREF_SCREEN_PASSWORD,
+ DeviceSettingsPreferenceConst.PREF_SCREEN_ALWAYS_ON_DISPLAY,
+ DeviceSettingsPreferenceConst.PREF_SCREEN_TIMEOUT,
+ DeviceSettingsPreferenceConst.PREF_SCREEN_AUTO_BRIGHTNESS,
+ DeviceSettingsPreferenceConst.PREF_SCREEN_BRIGHTNESS
+ ));
+ hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_HEALTH, Arrays.asList(
+ DeviceSettingsPreferenceConst.PREF_SCREEN_HEARTRATE_MONITORING,
+ DeviceSettingsPreferenceConst.PREF_SCREEN_INACTIVITY_EXTENDED,
+ DeviceSettingsPreferenceConst.PREF_USER_FITNESS_GOAL_NOTIFICATION
+ ));
+ hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_WORKOUT, Arrays.asList(
+ DeviceSettingsPreferenceConst.PREF_HEADER_GPS,
+ DeviceSettingsPreferenceConst.PREF_WORKOUT_START_ON_PHONE,
+ DeviceSettingsPreferenceConst.PREF_WORKOUT_SEND_GPS_TO_BAND,
+ DeviceSettingsPreferenceConst.PREF_HEADER_WORKOUT_DETECTION,
+ DeviceSettingsPreferenceConst.PREF_WORKOUT_KEEP_SCREEN_ON
+ ));
+ hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_AGPS, Arrays.asList(
+ DeviceSettingsPreferenceConst.PREF_AGPS_EXPIRY_REMINDER_ENABLED,
+ DeviceSettingsPreferenceConst.PREF_AGPS_EXPIRY_REMINDER_TIME,
+ DeviceSettingsPreferenceConst.PREF_AGPS_UPDATE_TIME,
+ DeviceSettingsPreferenceConst.PREF_AGPS_EXPIRE_TIME
+ ));
+
+ setupGpsPreference(handler, prefs);
+ setupButtonClickPreferences(handler);
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ final Set<String> preferenceKeysWithSummary = super.getPreferenceKeysWithSummary();
+
+ preferenceKeysWithSummary.add(DeviceSettingsPreferenceConst.WIFI_HOTSPOT_SSID);
+ preferenceKeysWithSummary.add(DeviceSettingsPreferenceConst.WIFI_HOTSPOT_PASSWORD);
+ preferenceKeysWithSummary.add(DeviceSettingsPreferenceConst.WIFI_HOTSPOT_STATUS);
+
+ preferenceKeysWithSummary.add(DeviceSettingsPreferenceConst.FTP_SERVER_ROOT_DIR);
+ preferenceKeysWithSummary.add(DeviceSettingsPreferenceConst.FTP_SERVER_ADDRESS);
+ preferenceKeysWithSummary.add(DeviceSettingsPreferenceConst.FTP_SERVER_USERNAME);
+ preferenceKeysWithSummary.add(DeviceSettingsPreferenceConst.FTP_SERVER_STATUS);
+
+ return preferenceKeysWithSummary;
+ }
+
+ private void setupGpsPreference(final DeviceSpecificSettingsHandler handler, final Prefs prefs) {
+ final ListPreference prefGpsPreset = handler.findPreference(DeviceSettingsPreferenceConst.PREF_GPS_MODE_PRESET);
+ final ListPreference prefGpsBand = handler.findPreference(DeviceSettingsPreferenceConst.PREF_GPS_BAND);
+ final ListPreference prefGpsCombination = handler.findPreference(DeviceSettingsPreferenceConst.PREF_GPS_COMBINATION);
+ final ListPreference prefGpsSatelliteSearch = handler.findPreference(DeviceSettingsPreferenceConst.PREF_GPS_SATELLITE_SEARCH);
+
+ if (prefGpsPreset != null) {
+ // When the preset preference is changed, update the band, combination and satellite search to the corresponding values
+ final Preference.OnPreferenceChangeListener onGpsPresetUpdated = (preference, newVal) -> {
+ final boolean isCustomPreset = GpsCapability.Preset.CUSTOM.name().toLowerCase(Locale.ROOT).equals(newVal);
+ final GpsCapability.Preset preset = GpsCapability.Preset.valueOf(newVal.toString().toUpperCase(Locale.ROOT));
+ final GpsCapability.Band presetBand;
+ final GpsCapability.Combination presetCombination;
+ final GpsCapability.SatelliteSearch presetSatelliteSearch;
+ switch (preset) {
+ case ACCURACY:
+ presetBand = GpsCapability.Band.DUAL_BAND;
+ presetCombination = GpsCapability.Combination.ALL_SATELLITES;
+ presetSatelliteSearch = GpsCapability.SatelliteSearch.ACCURACY_FIRST;
+ break;
+ case BALANCED:
+ presetBand = GpsCapability.Band.SINGLE_BAND;
+ presetCombination = GpsCapability.Combination.GPS_BDS;
+ presetSatelliteSearch = GpsCapability.SatelliteSearch.ACCURACY_FIRST;
+ break;
+ case POWER_SAVING:
+ presetBand = GpsCapability.Band.SINGLE_BAND;
+ presetCombination = GpsCapability.Combination.LOW_POWER_GPS;
+ presetSatelliteSearch = GpsCapability.SatelliteSearch.SPEED_FIRST;
+ break;
+ default:
+ presetBand = null;
+ presetCombination = null;
+ presetSatelliteSearch = null;
+ break;
+ }
+
+ if (prefGpsBand != null) {
+ prefGpsBand.setEnabled(isCustomPreset);
+ if (!isCustomPreset && presetBand != null) {
+ prefGpsBand.setValue(presetBand.name().toLowerCase(Locale.ROOT));
+ }
+ }
+ if (prefGpsCombination != null) {
+ prefGpsCombination.setEnabled(isCustomPreset);
+ if (!isCustomPreset && presetBand != null) {
+ prefGpsCombination.setValue(presetCombination.name().toLowerCase(Locale.ROOT));
+ }
+ }
+ if (prefGpsSatelliteSearch != null) {
+ prefGpsSatelliteSearch.setEnabled(isCustomPreset);
+ if (!isCustomPreset && presetBand != null) {
+ prefGpsSatelliteSearch.setValue(presetSatelliteSearch.name().toLowerCase(Locale.ROOT));
+ }
+ }
+
+ return true;
+ };
+
+ handler.addPreferenceHandlerFor(DeviceSettingsPreferenceConst.PREF_GPS_MODE_PRESET, onGpsPresetUpdated);
+ onGpsPresetUpdated.onPreferenceChange(prefGpsPreset, prefGpsPreset.getValue());
+ }
+
+ // The gps combination can only be chosen if the gps band is single band
+ if (prefGpsBand != null && prefGpsCombination != null) {
+ final Preference.OnPreferenceChangeListener onGpsBandUpdate = (preference, newVal) -> {
+ final boolean isSingleBand = GpsCapability.Band.SINGLE_BAND.name().toLowerCase(Locale.ROOT).equals(newVal);
+ prefGpsCombination.setEnabled(isSingleBand);
+ return true;
+ };
+
+ handler.addPreferenceHandlerFor(DeviceSettingsPreferenceConst.PREF_GPS_BAND, onGpsBandUpdate);
+ final boolean isCustomPreset = prefGpsPreset != null &&
+ GpsCapability.Preset.CUSTOM.name().toLowerCase(Locale.ROOT).equals(prefGpsPreset.getValue());
+ if (isCustomPreset) {
+ onGpsBandUpdate.onPreferenceChange(prefGpsPreset, prefGpsBand.getValue());
+ }
+ }
+
+ final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
+
+ final Preference prefAgpsUpdateTime = handler.findPreference(DeviceSettingsPreferenceConst.PREF_AGPS_UPDATE_TIME);
+ if (prefAgpsUpdateTime != null) {
+ final long ts = prefs.getLong(DeviceSettingsPreferenceConst.PREF_AGPS_UPDATE_TIME, 0L);
+ if (ts > 0) {
+ prefAgpsUpdateTime.setSummary(sdf.format(new Date(ts)));
+ } else {
+ prefAgpsUpdateTime.setSummary(handler.getContext().getString(R.string.unknown));
+ }
+ }
+
+ final Preference prefAgpsExpireTime = handler.findPreference(DeviceSettingsPreferenceConst.PREF_AGPS_EXPIRE_TIME);
+ if (prefAgpsExpireTime != null) {
+ final long ts = prefs.getLong(DeviceSettingsPreferenceConst.PREF_AGPS_EXPIRE_TIME, 0L);
+ if (ts > 0) {
+ prefAgpsExpireTime.setSummary(sdf.format(new Date(ts)));
+ } else {
+ prefAgpsExpireTime.setSummary(handler.getContext().getString(R.string.unknown));
+ }
+ }
+ }
+
+ private void setupButtonClickPreferences(final DeviceSpecificSettingsHandler handler) {
+ // Notify preference changed on button click, so we can react to them
+ final List<Preference> wifiFtpButtons = Arrays.asList(
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_BLUETOOTH_CALLS_PAIR),
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_APP_LOGS_START),
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_APP_LOGS_STOP),
+ handler.findPreference(DeviceSettingsPreferenceConst.WIFI_HOTSPOT_START),
+ handler.findPreference(DeviceSettingsPreferenceConst.WIFI_HOTSPOT_STOP),
+ handler.findPreference(DeviceSettingsPreferenceConst.FTP_SERVER_START),
+ handler.findPreference(DeviceSettingsPreferenceConst.FTP_SERVER_STOP),
+ // TODO: These are temporary for debugging and will be removed
+ handler.findPreference("zepp_os_alexa_btn_trigger"),
+ handler.findPreference("zepp_os_alexa_btn_send_simple"),
+ handler.findPreference("zepp_os_alexa_btn_send_complex")
+ );
+
+ for (final Preference btn : wifiFtpButtons) {
+ if (btn != null) {
+ btn.setOnPreferenceClickListener(preference -> {
+ handler.notifyPreferenceChanged(btn.getKey());
+ return true;
+ });
+ }
+ }
+ }
+
+ /**
+ * Hides prefToHide if no configuration from the list has been reported by the band.
+ */
+ private void hidePrefIfNoConfigSupported(final DeviceSpecificSettingsHandler handler,
+ final Prefs prefs,
+ final String prefToHide,
+ final String... supportedPref) {
+ final Preference pref = handler.findPreference(prefToHide);
+ if (pref == null) {
+ return;
+ }
+
+ for (final String prefKey : supportedPref) {
+ final boolean deviceHasConfig = prefs.getBoolean(DeviceSettingsUtils.getPrefKnownConfig(prefKey), false);
+ if (deviceHasConfig) {
+ // This preference is supported, don't hide
+ return;
+ }
+ }
+
+ // None of the configs were supported by the device, hide this preference
+ pref.setVisible(false);
+ }
+
+ private void enforceMinMax(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final ZeppOsConfigService.ConfigArg config) {
+ final String prefKey = config.getPrefKey();
+ final Preference pref = handler.findPreference(prefKey);
+ if (pref == null) {
+ return;
+ }
+
+ if (!(pref instanceof EditTextPreference)) {
+ return;
+ }
+
+ final int minValue = prefs.getInt(ZeppOsConfigService.getPrefMinKey(prefKey), Integer.MAX_VALUE);
+ if (minValue == Integer.MAX_VALUE) {
+ LOG.warn("Missing min value for {}", prefKey);
+ return;
+ }
+
+ final int maxValue = prefs.getInt(ZeppOsConfigService.getPrefMaxKey(prefKey), Integer.MIN_VALUE);
+ if (maxValue == Integer.MAX_VALUE) {
+ LOG.warn("Missing max value for {}", prefKey);
+ return;
+ }
+
+ DeviceSettingsUtils.enforceMinMax((EditTextPreference) pref, minValue, maxValue);
+ }
+
+ public static final Creator<ZeppOsSettingsCustomizer> CREATOR = new Creator<ZeppOsSettingsCustomizer>() {
+ @Override
+ public ZeppOsSettingsCustomizer createFromParcel(final Parcel in) {
+ final GBDevice device = in.readParcelable(ZeppOsSettingsCustomizer.class.getClassLoader());
+ final List<HuamiVibrationPatternNotificationType> vibrationPatternNotificationTypes = new ArrayList<>();
+ in.readList(vibrationPatternNotificationTypes, HuamiVibrationPatternNotificationType.class.getClassLoader());
+ return new ZeppOsSettingsCustomizer(device, vibrationPatternNotificationTypes);
+ }
+
+ @Override
+ public ZeppOsSettingsCustomizer[] newArray(final int size) {
+ return new ZeppOsSettingsCustomizer[size];
+ }
+ };
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HeartRateZonesConfig.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HeartRateZonesConfig.java
new file mode 100644
index 0000000..540c893
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HeartRateZonesConfig.java
@@ -0,0 +1,287 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+// TODO: make this configurable
+// TODO: retrieve from device and set real Rest HR
+// NOTE: algorithms used in this class are generic. So this data can be used with other devices.
+// We can move this class to global scope.
+public class HeartRateZonesConfig {
+
+ public static final int TYPE_UPRIGHT = 1;
+ public static final int TYPE_SITTING = 2;
+ public static final int TYPE_SWIMMING = 3;
+ public static final int TYPE_OTHER = 4;
+
+ public static final int CALCULATE_METHOD_MHR = 0;
+ public static final int CALCULATE_METHOD_HRR = 1;
+ public static final int CALCULATE_METHOD_LTHR = 3;
+
+
+ private static final int DEFAULT_REST_HEART_RATE = 60;
+ public static final int MAXIMUM_HEART_RATE = 220;
+
+ private final int configType;
+ private int calculateMethod = CALCULATE_METHOD_MHR; // 0 - MHR, 1 - HRR, 3 - LTHR
+
+ private int maxHRThreshold;
+ private int restHeartRate = DEFAULT_REST_HEART_RATE;
+
+ private boolean warningEnable;
+ private int warningHRLimit;
+
+ //MHR percentage
+ private int MHRExtreme;
+ private int MHRAnaerobic;
+ private int MHRAerobic;
+ private int MHRFatBurning;
+ private int MHRWarmUp;
+
+ //HRR percentage
+ private int HRRAdvancedAnaerobic;
+ private int HRRBasicAnaerobic;
+ private int HRRLactate;
+ private int HRRAdvancedAerobic;
+ private int HRRBasicAerobic;
+
+ //LTHR percentage
+ private int LTHRAnaerobic;
+ private int LTHRLactate;
+ private int LTHRAdvancedAerobic;
+ private int LTHRBasicAerobic;
+ private int LTHRWarmUp;
+
+
+ private int LTHRThresholdHeartRate;
+
+
+ public HeartRateZonesConfig(int type, int age) {
+ this.configType = type;
+ this.warningEnable = true;
+ this.warningHRLimit = MAXIMUM_HEART_RATE - age;
+ resetHRZones(age);
+ }
+
+ public void resetHRZones(int age) {
+ this.maxHRThreshold = (MAXIMUM_HEART_RATE - age) - getHRCorrection();
+ resetHRRZonesConfig();
+ resetMHRZonesConfig();
+ //NOTE: LTHR only supported for TYPE_UPRIGHT
+ if (this.configType == TYPE_UPRIGHT) {
+ resetLTHRZonesConfig();
+ }
+ }
+
+ private void resetLTHRZonesConfig() {
+ this.LTHRThresholdHeartRate = Math.round(((float) this.restHeartRate) + (((float) ((this.maxHRThreshold - this.restHeartRate) * 85)) / 100.0f));
+ this.LTHRAnaerobic = Math.round(((float) (this.LTHRThresholdHeartRate * 102)) / 100.0f);
+ this.LTHRLactate = Math.round(((float) (this.LTHRThresholdHeartRate * 97)) / 100.0f);
+ this.LTHRAdvancedAerobic = Math.round(((float) (this.LTHRThresholdHeartRate * 89)) / 100.0f);
+ this.LTHRBasicAerobic = Math.round(((float) (this.LTHRThresholdHeartRate * 80)) / 100.0f);
+ this.LTHRWarmUp = Math.round(((float) (this.LTHRThresholdHeartRate * 67)) / 100.0f);
+ }
+
+ private void resetMHRZonesConfig() {
+ this.MHRExtreme = Math.round(((float) (this.maxHRThreshold * 90)) / 100.0f);
+ this.MHRAnaerobic = Math.round(((float) (this.maxHRThreshold * 80)) / 100.0f);
+ this.MHRAerobic = Math.round(((float) (this.maxHRThreshold * 70)) / 100.0f);
+ this.MHRFatBurning = Math.round(((float) (this.maxHRThreshold * 60)) / 100.0f);
+ this.MHRWarmUp = Math.round(((float) (this.maxHRThreshold * 50)) / 100.0f);
+ }
+
+ private void resetHRRZonesConfig() {
+ int calcHR = this.maxHRThreshold - this.restHeartRate;
+ this.HRRAdvancedAnaerobic = Math.round(((float) (calcHR * 95)) / 100.0f) + this.restHeartRate;
+ this.HRRBasicAnaerobic = Math.round(((float) (calcHR * 88)) / 100.0f) + this.restHeartRate;
+ this.HRRLactate = Math.round(((float) (calcHR * 84)) / 100.0f) + this.restHeartRate;
+ this.HRRAdvancedAerobic = Math.round(((float) (calcHR * 74)) / 100.0f) + this.restHeartRate;
+ this.HRRBasicAerobic = Math.round(((float) (calcHR * 59)) / 100.0f) + this.restHeartRate;
+ }
+
+ //TODO: I am not sure about this. But it looks correct.
+ private int getHRCorrection() {
+ switch (this.configType) {
+ case TYPE_SITTING:
+ return 6;
+ case TYPE_SWIMMING:
+ return 10;
+ case TYPE_OTHER:
+ return 5;
+ default:
+ return 0;
+ }
+ }
+
+ public int getCalculateMethod() {
+ return this.calculateMethod;
+ }
+
+ public boolean getWarningEnable() {
+ return this.warningEnable;
+ }
+
+ public int getWarningHRLimit() {
+ return this.warningHRLimit;
+ }
+
+ public int getMaxHRThreshold() {
+ return this.maxHRThreshold;
+ }
+
+ public int getRestHeartRate() {
+ return this.restHeartRate;
+ }
+
+ public int getMHRWarmUp() {
+ return this.MHRWarmUp;
+ }
+
+ public int getMHRFatBurning() {
+ return this.MHRFatBurning;
+ }
+
+ public int getMHRAerobic() {
+ return this.MHRAerobic;
+ }
+
+ public int getMHRAnaerobic() {
+ return this.MHRAnaerobic;
+ }
+
+ public int getMHRExtreme() {
+ return this.MHRExtreme;
+ }
+
+ public int getHRRBasicAerobic() {
+ return this.HRRBasicAerobic;
+ }
+
+ public int getHRRAdvancedAerobic() {
+ return this.HRRAdvancedAerobic;
+ }
+
+ public int getHRRLactate() {
+ return this.HRRLactate;
+ }
+
+ public int getHRRBasicAnaerobic() {
+ return this.HRRBasicAnaerobic;
+ }
+
+ public int getHRRAdvancedAnaerobic() {
+ return this.HRRAdvancedAnaerobic;
+ }
+
+ public int getLTHRThresholdHeartRate() {
+ return this.LTHRThresholdHeartRate;
+ }
+
+ public int getLTHRAnaerobic() {
+ return this.LTHRAnaerobic;
+ }
+
+ public int getLTHRLactate() {
+ return this.LTHRLactate;
+ }
+
+ public int getLTHRAdvancedAerobic() {
+ return this.LTHRAdvancedAerobic;
+ }
+
+ public int getLTHRBasicAerobic() {
+ return this.LTHRBasicAerobic;
+ }
+
+ public int getLTHRWarmUp() {
+ return this.LTHRWarmUp;
+ }
+
+ private boolean checkValue(int val) {
+ return val >= 0 && val < MAXIMUM_HEART_RATE;
+ }
+
+ public boolean isValid() {
+ return checkValue(this.configType) &&
+ checkValue(this.calculateMethod) &&
+ checkValue(this.warningHRLimit) &&
+ checkValue(this.maxHRThreshold) &&
+ checkValue(this.restHeartRate) &&
+ checkValue(this.MHRWarmUp) &&
+ checkValue(this.MHRFatBurning) &&
+ checkValue(this.MHRAerobic) &&
+ checkValue(this.MHRAnaerobic) &&
+ checkValue(this.MHRExtreme) &&
+ checkValue(this.HRRBasicAerobic) &&
+ checkValue(this.HRRAdvancedAerobic) &&
+ checkValue(this.HRRLactate) &&
+ checkValue(this.HRRBasicAnaerobic) &&
+ checkValue(this.HRRAdvancedAnaerobic) &&
+ checkValue(this.LTHRThresholdHeartRate) &&
+ checkValue(this.LTHRAnaerobic) &&
+ checkValue(this.LTHRLactate) &&
+ checkValue(this.LTHRAdvancedAerobic) &&
+ checkValue(this.LTHRBasicAerobic) &&
+ checkValue(this.LTHRWarmUp) &&
+ this.warningHRLimit > 0;
+ }
+
+ public boolean hasValidMHRData() {
+ return MHRWarmUp > 0 && MHRFatBurning > 0 && MHRAerobic > 0 && MHRAnaerobic > 0 && MHRExtreme > 0 && maxHRThreshold > 0;
+ }
+
+ public boolean hasValidHRRData() {
+ return restHeartRate > 0 && HRRBasicAerobic > 0 && HRRAdvancedAerobic > 0 && HRRLactate > 0 && HRRBasicAnaerobic > 0 && HRRAdvancedAnaerobic > 0;
+ }
+
+ public boolean hasValidLTHRData() {
+ return LTHRThresholdHeartRate > 0 && LTHRAnaerobic > 0 && LTHRLactate > 0 && LTHRAdvancedAerobic > 0 && LTHRBasicAerobic > 0 && LTHRWarmUp > 0;
+ }
+
+ private int getZoneForHR(int heartRate, int zone5Threshold, int zone4Threshold, int zone3Threshold, int zone2Threshold, int zone1Threshold) {
+ if (heartRate >= MAXIMUM_HEART_RATE) {
+ return -1;
+ }
+ if (heartRate >= zone5Threshold) {
+ return 4;
+ }
+ if (heartRate >= zone4Threshold) {
+ return 3;
+ }
+ if (heartRate >= zone3Threshold) {
+ return 2;
+ }
+ if (heartRate >= zone2Threshold) {
+ return 1;
+ }
+ return heartRate >= zone1Threshold ? 0 : -1;
+ }
+
+ public int getMHRZone(int heartRate) {
+ return getZoneForHR(heartRate, MHRExtreme, MHRAnaerobic, MHRAerobic, MHRFatBurning, MHRWarmUp);
+ }
+
+ public int getHHRZone(int heartRate) {
+ return getZoneForHR(heartRate, HRRAdvancedAnaerobic, HRRBasicAnaerobic, HRRLactate, HRRAdvancedAerobic, HRRBasicAerobic);
+ }
+
+ public int getLTHRZone(int heartRate) {
+ return getZoneForHR(heartRate, LTHRAnaerobic, LTHRLactate, LTHRAdvancedAerobic, LTHRBasicAerobic, LTHRWarmUp);
+ }
+
+ public int getZoneByMethod(int heartRate, int method) {
+ if(method == CALCULATE_METHOD_LTHR) {
+ return getLTHRZone(heartRate);
+ } else if(method == CALCULATE_METHOD_MHR) {
+ return getMHRZone(heartRate);
+ }
+ return getHHRZone(heartRate);
+ }
+
+ public static boolean isCalculateMethodValidFroType(int type, int method) {
+ if(method == CALCULATE_METHOD_LTHR && type == TYPE_UPRIGHT) {
+ return true;
+ }
+ return (method == CALCULATE_METHOD_MHR) || (method == CALCULATE_METHOD_HRR);
+
+ }
+
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java
new file mode 100644
index 0000000..447e9a1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java
@@ -0,0 +1,260 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiBRSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiWorkoutGbParser;
+
+public abstract class HuaweiBRCoordinator extends AbstractBLClassicDeviceCoordinator implements HuaweiCoordinatorSupplier {
+
+ private final HuaweiCoordinator huaweiCoordinator = new HuaweiCoordinator(this);
+ private GBDevice device;
+
+ @Override
+ public HuaweiCoordinator getHuaweiCoordinator() {
+ return huaweiCoordinator;
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid huaweiService = new ParcelUuid(HuaweiConstants.UUID_SERVICE_HUAWEI_SERVICE);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(huaweiService).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new HuaweiSettingsCustomizer(device);
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(GBDevice device) {
+ return huaweiCoordinator.getSupportedLanguageSettings(device);
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificAuthenticationSettings() {
+ return new int[]{R.xml.devicesettings_huawei_account};
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ huaweiCoordinator.deleteDevice(gbDevice, device, session);
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Huawei";
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(GBDevice device, int position) {
+ return huaweiCoordinator.supportsSmartAlarm(device, position);
+ }
+
+ @Override
+ public boolean supportsSmartWakeupInterval(GBDevice device, int alarmPosition) {
+ return supportsSmartWakeup(device, alarmPosition);
+ }
+
+ @Override
+ public boolean forcedSmartWakeup(GBDevice device, int alarmPosition) {
+ return huaweiCoordinator.forcedSmartWakeup(device, alarmPosition);
+ }
+
+ @Override
+ public boolean supportsAlarmTitle(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return huaweiCoordinator.supportsWeather();
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return huaweiCoordinator.getAppManagerActivity();
+ }
+
+ @Override
+ public boolean supportsAppListFetching() {
+ return huaweiCoordinator.getSupportsAppListFetching();
+ }
+ @Override
+ public boolean supportsAppsManagement(GBDevice device) {
+ return huaweiCoordinator.getSupportsAppsManagement(device);
+ }
+
+ @Override
+ public boolean supportsWatchfaceManagement(GBDevice device) {
+ return supportsAppsManagement(device);
+ }
+
+ @Override
+ public boolean supportsInstalledAppManagement(GBDevice device) {
+ return huaweiCoordinator.getSupportsInstalledAppManagement(device);
+ }
+
+ @Override
+ public boolean supportsCachedAppManagement(GBDevice device) {
+ return huaweiCoordinator.getSupportsCachedAppManagement(device);
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return huaweiCoordinator.getAlarmSlotCount(device);
+ }
+
+ @Override
+ public int getContactsSlotCount(GBDevice device) {
+ return huaweiCoordinator.getContactsSlotCount(device);
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return huaweiCoordinator.supportsCalendarEvents();
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActiveCalories() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return huaweiCoordinator.supportsHeartRate(device);
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return huaweiCoordinator.supportsSPo2(device);
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return huaweiCoordinator.supportsMusic();
+ }
+
+ @Override
+ public boolean supportsTemperatureMeasurement() {
+ return huaweiCoordinator.supportsTemperature();
+ }
+
+ @Override
+ public boolean supportsContinuousTemperature() {
+ return huaweiCoordinator.supportsTemperature();
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return huaweiCoordinator.getInstallHandler(uri, context);
+ }
+
+ @Override
+ public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
+ return new HuaweiWorkoutGbParser(device, context);
+ }
+
+ @Override
+ public SampleProvider<? extends AbstractActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new HuaweiSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(GBDevice device, DaoSession session) {
+ return new HuaweiSpo2SampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends TemperatureSample> getTemperatureSampleProvider(final GBDevice device, final DaoSession session) {
+ return new HuaweiTemperatureSampleProvider(device, session);
+ }
+
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ return huaweiCoordinator.getDeviceSpecificSettings(device);
+ }
+
+ @Override
+ public HuaweiDeviceType getHuaweiType() {
+ return HuaweiDeviceType.BR;
+ }
+
+ @Override
+ public void setDevice(GBDevice device) {
+ this.device = device;
+ }
+
+ @Override
+ public GBDevice getDevice() {
+ return this.device;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return HuaweiBRSupport.class;
+ }
+
+ @Override
+ public boolean addBatteryPollingSettings() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBinAppParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBinAppParser.java
new file mode 100644
index 0000000..46b619f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBinAppParser.java
@@ -0,0 +1,111 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+
+public class HuaweiBinAppParser {
+
+ public static class HuaweiBinAppParseError extends Exception {
+ public HuaweiBinAppParseError(String str) {
+ super(str);
+ }
+ }
+
+ public static class AppFileEntry {
+ public String filename;
+ public String path;
+ public byte[] content;
+ }
+
+ private static final byte BIN_APP_MAGIC = (byte) 0xbe;
+
+ private static final int SIGN_HEADER_LEN = 32;
+ private static final byte[] SIGN_MAGIC = "hw signed app ".getBytes();
+ private static final byte[] SIGN_VERSION = "1000".getBytes();
+
+
+ private String packageName;
+ private final ArrayList<AppFileEntry> entries = new ArrayList<>();
+
+ public String getPackageName() {
+ return packageName;
+ }
+
+ public byte[] getEntryContent(String filename) {
+ for (AppFileEntry en : entries) {
+ if (en.filename.equals(filename)) {
+ return en.content;
+ }
+ }
+ return null;
+ }
+
+ private byte[] readData(ByteBuffer data) throws Exception {
+ long len = data.getLong();
+ if (len > Integer.MAX_VALUE || len <= 0) {
+ throw new HuaweiBinAppParseError("Invalid data length");
+ }
+ byte[] newPayload = new byte[(int) len];
+ data.get(newPayload, 0, (int) len);
+ return newPayload;
+ }
+
+ private String readStringInternal(ByteBuffer data, int len) throws Exception {
+ byte[] newPayload = new byte[len];
+ data.get(newPayload, 0, len);
+ return new String(newPayload, StandardCharsets.UTF_8);
+ }
+
+
+ private String readString(ByteBuffer data) throws Exception {
+ int len = data.getInt();
+ if (len <= 0 || len > data.remaining()) {
+ throw new HuaweiBinAppParseError("Invalid string length");
+ }
+ return readStringInternal(data, len);
+ }
+
+ private String readEmptyString(ByteBuffer data) throws Exception {
+ int len = data.getInt();
+ if (len < 0 || len > data.remaining()) {
+ throw new HuaweiBinAppParseError("Invalid string length");
+ }
+ return readStringInternal(data, len);
+ }
+
+ private int getSignLen(byte[] in) {
+ byte[] signatureHeader = Arrays.copyOfRange(in, in.length - SIGN_HEADER_LEN, in.length);
+ ByteBuffer signatureHeaderBuf = ByteBuffer.wrap(signatureHeader);
+ byte[] magic = new byte[SIGN_MAGIC.length];
+ signatureHeaderBuf.get(magic, 0, SIGN_MAGIC.length);
+ if (!Arrays.equals(SIGN_MAGIC, magic))
+ return 0;
+ byte[] version = new byte[SIGN_VERSION.length];
+ signatureHeaderBuf.get(version, 0, SIGN_VERSION.length);
+ if (!Arrays.equals(SIGN_VERSION, version))
+ return 0;
+ //NOTE: we need only signature size.
+ return signatureHeaderBuf.getInt();
+ }
+
+ public void parseData(byte[] in) throws Exception {
+ //NOTE: Binary app file signed. We should avoid to read this signature.
+ int signLen = getSignLen(in);
+ ByteBuffer data = ByteBuffer.wrap(in);
+
+ byte magic = data.get();
+ if (magic != BIN_APP_MAGIC) {
+ throw new HuaweiBinAppParseError("Invalid magic");
+ }
+ this.packageName = readString(data);
+ while (data.remaining() > signLen) {
+ AppFileEntry ent = new AppFileEntry();
+ ent.filename = readString(data);
+ ent.path = readEmptyString(data);
+ ent.content = readData(data);
+ entries.add(ent);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiConstants.java
new file mode 100644
index 0000000..e9f88ad
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiConstants.java
@@ -0,0 +1,99 @@
+/* Copyright (C) 2024 Damien Gaignon, Vitalii Tomin
+
+ 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.devices.huawei;
+
+import java.util.UUID;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport.BASE_UUID;
+
+public final class HuaweiConstants {
+
+ public static final UUID UUID_SERVICE_HUAWEI_SERVICE = UUID.fromString(String.format(BASE_UUID, "FE86"));
+ public static final UUID UUID_CHARACTERISTIC_HUAWEI_WRITE = UUID.fromString(String.format(BASE_UUID, "FE01"));
+ public static final UUID UUID_CHARACTERISTIC_HUAWEI_READ = UUID.fromString(String.format(BASE_UUID, "FE02"));
+ public static final UUID UUID_SERVICE_HUAWEI_SDP = UUID.fromString("82FF3820-8411-400C-B85A-55BDB32CF060");
+
+ public static final String GROUP_ID = "7B0BC0CBCE474F6C238D9661C63400B797B166EA7849B3A370FC73A9A236E989";
+ public static final byte[] KEY_TYPE = new byte[]{0x00, 0x07};
+
+ public static final byte HUAWEI_MAGIC = 0x5A;
+
+ public static final byte PROTOCOL_VERSION = 0x02;
+
+ public static final int TAG_RESULT = 127;
+ public static final byte[] RESULT_SUCCESS = new byte[]{0x00, 0x01, (byte)0x86, (byte)0xA0};
+ public static final int RESULT_SUCCESS_INT = 0x186a0;
+
+ public static class CryptoTags {
+ public static final int encryption = 124;
+ public static final int initVector = 125;
+ public static final int cipherText = 126;
+ }
+
+ public static final String HO_BAND3_NAME = "honor band 3-";
+ public static final String HO_BAND4_NAME = "honor band 4-";
+ public static final String HO_BAND5_NAME = "honor band 5-";
+ public static final String HO_BAND6_NAME = "honor band 6-";
+ public static final String HO_BAND7_NAME = "honor band 7-";
+ public static final String HO_MAGICWATCH2_NAME = "honor magicwatch 2-";
+ public static final String HO_WATCHGS3_NAME = "honor watch gs 3-";
+ public static final String HO_WATCHGSPRO_NAME = "honor watch gs pro-";
+ public static final String HU_BAND3E_NAME = "huawei band 3e-";
+ public static final String HU_BAND4E_NAME = "huawei band 4e-";
+ public static final String HU_BAND6_NAME = "huawei band 6-";
+ public static final String HU_WATCHGT_NAME = "huawei watch gt-";
+ public static final String HU_BAND2_NAME = "huawei band 2-";
+ public static final String HU_BAND3_NAME = "huawei band 3-";
+ public static final String HU_BAND4_NAME = "huawei band 4-";
+ public static final String HU_BAND2PRO_NAME = "huawei band 2 pro-";
+ public static final String HU_BAND3PRO_NAME = "huawei band 3 pro-";
+ public static final String HU_BAND4PRO_NAME = "huawei band 4 pro-";
+ public static final String HU_WATCHGT2_NAME = "huawei watch gt 2-";
+ public static final String HU_WATCHGT2E_NAME = "huawei watch gt 2e-";
+ public static final String HU_WATCHGT2PRO_NAME = "huawei watch gt 2 pro-";
+ public static final String HU_TALKBANDB6_NAME = "huawei b6-";
+ public static final String HU_BAND7_NAME = "huawei band 7-";
+ public static final String HU_BAND8_NAME = "huawei band 8-";
+ public static final String HU_BAND9_NAME = "huawei band 9-";
+ public static final String HU_WATCHD2_NAME = "huawei watch d2-";
+ public static final String HU_WATCHGT3_NAME = "huawei watch gt 3-";
+ public static final String HU_WATCHGT3SE_NAME = "huawei watch gt 3 se-";
+ public static final String HU_WATCHGT3PRO_NAME = "huawei watch gt 3 pro-";
+ public static final String HU_WATCHGTRUNNER_NAME = "huawei watch gt runner-";
+ public static final String HU_WATCHGTCYBER_NAME = "huawei watch gt cyber-";
+ public static final String HU_WATCH3_NAME = "huawei watch 3-";
+ public static final String HU_WATCH3PRO_NAME = "huawei watch 3 pro-";
+ public static final String HU_WATCHGT4_NAME = "huawei watch gt 4-";
+ public static final String HU_WATCHGT5_NAME = "huawei watch gt 5-";
+ public static final String HU_WATCHGT5PRO_NAME = "huawei watch gt 5 pro-";
+ public static final String HU_WATCHFIT_NAME = "huawei watch fit-";
+ public static final String HU_WATCHFIT2_NAME = "huawei watch fit 2-";
+ public static final String HU_WATCHFIT3_NAME = "huawei watch fit 3-";
+ public static final String HU_WATCHULTIMATE_NAME = "huawei watch ultimate-";
+ public static final String HU_WATCH4_NAME = "huawei watch 4-";
+ public static final String HU_WATCH4PRO_NAME = "huawei watch 4 pro-";
+
+ 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";
+ public static final String PREF_HUAWEI_CONTINUOUS_SKIN_TEMPERATURE_MEASUREMENT = "continuous_skin_temperature_measurement";
+
+ public static final String PKG_NAME = "com.huawei.devicegroupmanage";
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java
new file mode 100644
index 0000000..36af870
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java
@@ -0,0 +1,843 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM, Vitalii Tomin
+
+ 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.devices.huawei;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.net.Uri;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import org.slf4j.LoggerFactory;
+import org.slf4j.Logger;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.CameraActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.App;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Notifications;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Notifications.NotificationConstraintsType;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Watchface;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiDictData;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiDictDataDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiDictDataValuesDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutDataSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutPaceSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSwimSegmentsSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.*;
+
+import androidx.annotation.NonNull;
+
+public class HuaweiCoordinator {
+ Logger LOG = LoggerFactory.getLogger(HuaweiCoordinator.class);
+
+ TreeMap<Integer, byte[]> commandsPerService = new TreeMap<>();
+ // Each byte of expandCapabilities represent a "service"
+ // Each bit in a "service" represent a feature so 1 or 0 is used to check is support or not
+ byte[] expandCapabilities = null;
+ byte notificationCapabilities = -0x01;
+ ByteBuffer notificationConstraints = null;
+
+ private boolean supportsTruSleepNewSync = false;
+ private boolean supportsGpsNewSync = false;
+
+ private Watchface.WatchfaceDeviceParams watchfaceDeviceParams;
+
+ private App.AppDeviceParams appDeviceParams;
+
+ private HuaweiMusicUtils.MusicCapabilities musicDeviceParams = null;
+
+ private HuaweiMusicUtils.MusicCapabilities musicExtendedDeviceParams = null;
+
+ private final HuaweiCoordinatorSupplier parent;
+
+ private boolean transactionCrypted=true;
+
+ private int maxContactsCount = 0;
+
+ public HuaweiCoordinator(HuaweiCoordinatorSupplier parent) {
+ this.parent = parent;
+
+ // Set non-numeric capabilities
+ this.expandCapabilities = GB.hexStringToByteArray(getCapabilitiesSharedPreferences().getString("expandCapabilities", "00"));
+ this.notificationCapabilities = (byte)getCapabilitiesSharedPreferences().getInt("notificationCapabilities", -0x01);
+ this.notificationConstraints = ByteBuffer.wrap(GB.hexStringToByteArray(
+ getCapabilitiesSharedPreferences().getString(
+ "notificationConstraints",
+ GB.hexdump(Notifications.defaultConstraints)
+ )));
+ this.maxContactsCount = getCapabilitiesSharedPreferences().getInt("maxContactsCount", 0);
+
+ for (String key : getCapabilitiesSharedPreferences().getAll().keySet()) {
+ int service;
+ try {
+ service = Integer.parseInt(key);
+ byte[] commands = GB.hexStringToByteArray(getCapabilitiesSharedPreferences().getString(key, "00"));
+ this.commandsPerService.put(service, commands);
+ } catch (NumberFormatException e) {
+ // These are the non-numeric capabilities, which have been set already
+ }
+ }
+ }
+
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getHuaweiActivitySampleDao().queryBuilder();
+ qb.where(HuaweiActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+
+ QueryBuilder<HuaweiWorkoutSummarySample> qb2 = session.getHuaweiWorkoutSummarySampleDao().queryBuilder();
+ List<HuaweiWorkoutSummarySample> workouts = qb2.where(HuaweiWorkoutSummarySampleDao.Properties.DeviceId.eq(deviceId)).build().list();
+ for (HuaweiWorkoutSummarySample sample : workouts) {
+ session.getHuaweiWorkoutDataSampleDao().queryBuilder().where(
+ HuaweiWorkoutDataSampleDao.Properties.WorkoutId.eq(sample.getWorkoutId())
+ ).buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getHuaweiWorkoutPaceSampleDao().queryBuilder().where(
+ HuaweiWorkoutPaceSampleDao.Properties.WorkoutId.eq(sample.getWorkoutId())
+ ).buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getHuaweiWorkoutSwimSegmentsSampleDao().queryBuilder().where(
+ HuaweiWorkoutSwimSegmentsSampleDao.Properties.WorkoutId.eq(sample.getWorkoutId())
+ ).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ session.getHuaweiWorkoutSummarySampleDao().queryBuilder().where(HuaweiWorkoutSummarySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getBaseActivitySummaryDao().queryBuilder().where(BaseActivitySummaryDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+
+ QueryBuilder<HuaweiDictData> qb3 = session.getHuaweiDictDataDao().queryBuilder();
+ List<HuaweiDictData> dictData = qb3.where(HuaweiDictDataDao.Properties.DeviceId.eq(deviceId)).build().list();
+ for (HuaweiDictData data : dictData) {
+ session.getHuaweiDictDataValuesDao().queryBuilder().where(
+ HuaweiDictDataValuesDao.Properties.DictId.eq(data.getDictId())
+ ).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ session.getHuaweiDictDataDao().queryBuilder().where(HuaweiDictDataDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ private SharedPreferences getCapabilitiesSharedPreferences() {
+ return GBApplication.getContext().getSharedPreferences("huawei_coordinator_capatilities" + parent.getDeviceType().name(), Context.MODE_PRIVATE);
+ }
+
+ private SharedPreferences getDeviceSpecificSharedPreferences(GBDevice gbDevice) {
+ return GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
+ }
+
+ public boolean getForceOption(GBDevice gbDevice, String option) {
+ return getDeviceSpecificSharedPreferences(gbDevice).getBoolean(option, false);
+ }
+
+ private void saveCommandsForService(int service, byte[] commands) {
+ commandsPerService.put(service, commands);
+ getCapabilitiesSharedPreferences().edit().putString(String.valueOf(service), GB.hexdump(commands)).apply();
+ }
+
+ public void saveExpandCapabilities(byte[] capabilities) {
+ expandCapabilities = capabilities;
+ getCapabilitiesSharedPreferences().edit().putString("expandCapabilities", GB.hexdump(capabilities)).apply();
+ }
+
+ public void saveNotificationCapabilities(byte capabilities) {
+ notificationCapabilities = capabilities;
+ getCapabilitiesSharedPreferences().edit().putInt("notificationCapabilities", (int)capabilities).apply();
+ }
+
+ public void saveNotificationConstraints(ByteBuffer constraints) {
+ notificationConstraints = constraints;
+ getCapabilitiesSharedPreferences().edit().putString("notificationConstraints", GB.hexdump(constraints.array())).apply();
+ }
+
+ public void saveMaxContactsCount(int maxContactsCount) {
+ this.maxContactsCount = maxContactsCount;
+ getCapabilitiesSharedPreferences().edit().putInt("maxContactsCount", maxContactsCount).apply();
+ }
+
+ public void addCommandsForService(int service, byte[] commands) {
+ if (!commandsPerService.containsKey(service)) {
+ saveCommandsForService(service, commands);
+ return;
+ }
+ byte[] saved = commandsPerService.get(service);
+ if (saved == null) {
+ saveCommandsForService(service, commands);
+ return;
+ }
+ if (saved.length != commands.length) {
+ saveCommandsForService(service, commands);
+ return;
+ }
+ boolean changed = false;
+ for (int i = 0; i < saved.length; i++) {
+ if (saved[i] != commands[i]) {
+ changed = true;
+ break;
+ }
+ }
+ if (changed)
+ saveCommandsForService(service, commands);
+ }
+
+ public byte[] getCommandsForService(int service) {
+ return commandsPerService.get(service);
+ }
+
+ // Print all Services ID and Commands ID
+ public void printCommandsPerService() {
+ StringBuilder msg = new StringBuilder();
+ for(Map.Entry<Integer, byte[]> entry : commandsPerService.entrySet()) {
+ msg.append("ServiceID: ").append(Integer.toHexString(entry.getKey())).append(" => Commands: ");
+ for (byte b: entry.getValue()) {
+ msg.append(Integer.toHexString(b)).append(" ");
+ }
+ msg.append("\n");
+ }
+ LOG.info(msg.toString());
+ }
+
+ private boolean supportsCommandForService(int service, int command) {
+ byte[] commands = commandsPerService.get(service);
+ if (commands == null)
+ return false;
+ for (byte b : commands)
+ if (b == (byte) command)
+ return true;
+ return false;
+ }
+
+ private boolean supportsExpandCapability(int which) {
+ // capability is a number containing :
+ // - the index of the "service"
+ // - the real capability number
+ if (expandCapabilities == null) {
+ LOG.debug("Expand capabilities is null");
+ return false;
+ }
+ if (which >= expandCapabilities.length * 8) {
+ LOG.debug("Capability is not supported");
+ return false;
+ }
+ int capability = 1 << (which % 8);
+ if ((expandCapabilities[which / 8] & capability) == capability) return true;
+ return false;
+ }
+
+ private boolean supportsNotificationConstraint(byte which) {
+ return notificationConstraints.get(which) == 0x01;
+ }
+
+ private int getNotificationConstraint(byte which) {
+ return (int)notificationConstraints.getShort(which);
+ }
+
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ // Health
+ if (supportsInactivityWarnings())
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH, R.xml.devicesettings_inactivity_sheduled);
+ if (supportsTruSleep())
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH, R.xml.devicesettings_trusleep);
+ if (supportsHeartRate())
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH, R.xml.devicesettings_heartrate_automatic_enable);
+ if (supportsSPo2())
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH, R.xml.devicesettings_spo_automatic_enable);
+ if(supportsTemperature()) {
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH, R.xml.devicesettings_temperature_automatic_enable);
+ }
+
+ // Notifications
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_notifications_enable);
+ if (supportsNotificationOnBluetoothLoss())
+ notifications.add(R.xml.devicesettings_disconnectnotification_noshed);
+ if (supportsDoNotDisturb(device))
+ notifications.add(R.xml.devicesettings_donotdisturb_allday_liftwirst_notwear);
+
+ // Workout
+ if (supportsSendingGps())
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.WORKOUT, R.xml.devicesettings_workout_send_gps_to_band);
+
+ // Other
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_find_phone);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_disable_find_phone_with_dnd);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_allow_accept_reject_calls);
+
+ // Camera control
+ if (supportsCameraRemote())
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_camera_remote);
+
+ //Contacts
+ if (getContactsSlotCount(device) > 0) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_contacts);
+ }
+
+ //Music
+ if (supportsMusicUploading() && getMusicInfoParams() != null && device.isConnected()) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_musicmanagement);
+ }
+
+ // Time
+ if (supportsDateFormat()) {
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_dateformat);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ }
+
+ //Calendar
+ if( supportsP2PService() && supportsCalendar()) {
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR, R.xml.devicesettings_sync_calendar);
+ }
+
+ // Display
+ if (supportsWearLocation(device))
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY, R.xml.devicesettings_wearlocation);
+ if (supportsAutoWorkMode())
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY, R.xml.devicesettings_workmode);
+ if (supportsActivateOnLift())
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY, R.xml.devicesettings_liftwrist_display_noshed);
+ if (supportsRotateToCycleInfo())
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY, R.xml.devicesettings_rotatewrist_cycleinfo);
+ // Currently on main setting menu.
+ /*if (supportsLanguageSetting())
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY, R.xml.devicesettings_language_generic);*/
+ if(supportsTemperature()) {
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY, R.xml.devicesettings_temperature_scale_cf);
+ }
+
+ // Developer
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_huawei_debug);
+
+ return deviceSpecificSettings;
+ }
+
+ public boolean supportsDateFormat() {
+ return supportsCommandForService(0x01, 0x04);
+ }
+
+ public boolean supportsActivateOnLift() {
+ return supportsCommandForService(0x01, 0x09);
+ }
+
+ public boolean supportsDoNotDisturb() {
+ return supportsCommandForService(0x01, 0x0a);
+ }
+
+ public boolean supportsDoNotDisturb(GBDevice gbDevice) {
+ return supportsDoNotDisturb() || getForceOption(gbDevice, PREF_FORCE_DND_SUPPORT);
+ }
+
+ public boolean supportsActivityType() {
+ return supportsCommandForService(0x01, 0x12);
+ }
+
+ public boolean supportsWearLocation() {
+ return supportsCommandForService(0x01, 0x1a);
+ }
+
+ public boolean supportsWearLocation(GBDevice gbDevice) {
+ return supportsWearLocation() || getForceOption(gbDevice, PREF_FORCE_ENABLE_WEAR_LOCATION);
+ }
+
+ public boolean supportsRotateToCycleInfo() {
+ return supportsCommandForService(0x01, 0x1b);
+ }
+
+ public boolean supportsQueryDndLiftWristDisturbType() {
+ return supportsCommandForService(0x01, 0x1d);
+ }
+
+ public boolean supportsCameraRemote() {
+ return supportsCommandForService(0x01, 0x29) && CameraActivity.supportsCamera();
+ }
+
+ public boolean supportsContacts() {
+ return supportsCommandForService(0x03, 0x1);
+ }
+
+ public boolean supportsCalendarEvents() {
+ return supportsP2PService() && supportsCalendar();
+ }
+
+ public boolean supportsAcceptAgreement() {
+ return supportsCommandForService(0x01, 0x30);
+ }
+
+ public boolean supportsSettingRelated() {
+ return supportsCommandForService(0x01, 0x31);
+ }
+
+ public boolean supportsTimeAndZoneId() {
+ return supportsCommandForService(0x01, 0x32);
+ }
+
+ public boolean supportsConnectStatus() {
+ return supportsCommandForService(0x01, 0x35);
+ }
+
+ public boolean supportsExpandCapability() {
+ return supportsCommandForService(0x01, 0x37);
+ }
+
+ public boolean supportsNotificationAlert() {
+ return supportsCommandForService(0x02, 0x01);
+ }
+
+ public boolean supportsNotification() {
+ return supportsCommandForService(0x02, 0x04);
+ }
+
+ public boolean supportsWearMessagePush() {
+ return supportsCommandForService(0x02, 0x08);
+ }
+
+ public boolean supportsMotionGoal() {
+ return supportsCommandForService(0x07, 0x01);
+ }
+
+ public boolean supportsInactivityWarnings() {
+ return supportsCommandForService(0x07, 0x06);
+ }
+
+ public boolean supportsActivityReminder() {
+ return supportsCommandForService(0x07, 0x07);
+ }
+
+ public boolean supportsDeviceReportThreshold() {
+ return supportsCommandForService(0x07, 0x0e);
+ }
+
+ public boolean supportsTruSleep() {
+ return supportsCommandForService(0x07, 0x16);
+ }
+
+ public boolean supportsHeartRate() {
+ return supportsCommandForService(0x07, 0x17);
+ }
+
+ public boolean supportsHeartRate(GBDevice gbDevice) {
+ return supportsHeartRate() || getForceOption(gbDevice, PREF_FORCE_ENABLE_HEARTRATE_SUPPORT);
+ }
+
+ public boolean supportsHeartRateZones() {
+ return supportsCommandForService(0x07, 0x13);
+ }
+
+ public boolean supportsExtendedHeartRateZones() {
+ return supportsCommandForService(0x07, 0x21);
+ }
+
+ public boolean supportsFitnessRestHeartRate() {
+ return supportsCommandForService(0x07, 0x23);
+ }
+
+ public boolean supportsSPo2() {
+ return supportsCommandForService(0x07, 0x24);
+ }
+
+ public boolean supportsSPo2(GBDevice gbDevice) {
+ return supportsSPo2() || getForceOption(gbDevice, PREF_FORCE_ENABLE_SPO2_SUPPORT);
+ }
+
+ public boolean supportsRunPaceConfig() {
+ return supportsCommandForService(0x07, 0x28);
+ }
+
+ public boolean supportsFitnessThresholdValue() {
+ return supportsCommandForService(0x07, 0x29);
+ }
+ public boolean supportsFitnessThresholdValueV2() { return supportsExpandCapability(0x9a) || supportsExpandCapability(0x9c); }
+
+ // 0x1d - SupportTemperature
+ // 0xba - SupportTemperatureClassification
+ // 0x43 - SupportTemperatureStudy
+ public boolean supportsTemperature() { return supportsExpandCapability(0x1d); }
+
+ public boolean supportsBloodPressure() { return supportsExpandCapability(0x3b); }
+
+ public boolean supportsEventAlarm() {
+ return supportsCommandForService(0x08, 0x01);
+ }
+
+ public boolean supportsSmartAlarm() {
+ return supportsCommandForService(0x08, 0x02) ;
+ }
+
+ public boolean supportsSmartAlarm(GBDevice gbDevice) {
+ return supportsSmartAlarm() || getForceOption(gbDevice, PREF_FORCE_ENABLE_SMART_ALARM);
+ }
+
+ public boolean supportsSmartAlarm(GBDevice gbDevice, int alarmPosition) {
+ return supportsSmartAlarm(gbDevice) && alarmPosition == 0;
+ }
+
+ public boolean forcedSmartWakeup(GBDevice device, int alarmPosition) {
+ return supportsSmartAlarm(device, alarmPosition) && alarmPosition == 0;
+ }
+
+ /**
+ * @return True if alarms can be changed on the device, false otherwise
+ */
+ public boolean supportsChangingAlarm() {
+ return supportsCommandForService(0x08, 0x03);
+ }
+
+ public boolean supportsNotificationOnBluetoothLoss() {
+ return supportsCommandForService(0x0b, 0x03);
+ }
+
+ public boolean supportsLanguageSetting() {
+ return supportsCommandForService(0x0c, 0x01);
+ }
+
+ public boolean supportsWatchfaceParams(){ return supportsCommandForService(0x27, 0x01);}
+
+ public boolean supportsAppParams(){ return supportsCommandForService(0x2a, 0x06);}
+
+ public boolean supportsMusicUploading(){ return supportsCommandForService(0x25, 0x04);}
+
+ public boolean supportsWeather() {
+ return supportsCommandForService(0x0f, 0x01);
+ }
+
+ public boolean supportsWeatherUnit() {
+ return supportsCommandForService(0x0f, 0x05);
+ }
+
+ public boolean supportsWeatherExtended() {
+ return supportsCommandForService(0x0f, 0x06);
+ }
+
+ public boolean supportsWeatherErrorSimple() {
+ return supportsCommandForService(0x0f, 0x07);
+ }
+
+ public boolean supportsWeatherForecasts() {
+ return supportsCommandForService(0x0f, 0x08);
+ }
+
+ public boolean supportsWeatherMoonRiseSet() {
+ return supportsCommandForService(0x0f, 0x0a);
+ }
+
+ public boolean supportsWeatherTides() {
+ return supportsCommandForService(0x0f, 0x0b);
+ }
+
+ public boolean supportsWeatherErrorExtended() {
+ return supportsCommandForService(0x0f, 0x0c);
+ }
+
+ public boolean supportsWeatherUvIndex() {
+ return supportsExpandCapability(0x2f);
+ }
+
+ public boolean supportsWorkouts() {
+ return supportsCommandForService(0x17, 0x01);
+ }
+
+ public boolean supportsWorkoutsTrustHeartRate() {
+ return supportsCommandForService(0x17, 0x17);
+ }
+
+ public boolean supportsSendingGps() {
+ return supportsCommandForService(0x18, 0x02);
+ }
+
+ public boolean supportsAccount() {
+ return supportsCommandForService(0x1A, 0x01);
+ }
+
+ public boolean supportsAccountJudgment() {
+ return supportsCommandForService(0x1A, 0x05);
+ }
+
+ public boolean supportsAccountSwitch() {
+ return supportsCommandForService(0x1A, 0x06);
+ }
+
+ public boolean supportsDiffAccountPairingOptimization() {
+ if (supportsExpandCapability())
+ return supportsExpandCapability(0xac);
+ return false;
+ }
+
+ public boolean supportsMusic() {
+ return supportsCommandForService(0x25, 0x02);
+ }
+
+ public boolean supportsAutoWorkMode() {
+ return supportsCommandForService(0x26, 0x02);
+ }
+
+ public boolean supportsMenstrual() {
+ return supportsCommandForService(0x32, 0x01);
+ }
+
+ public boolean supportsP2PService() {
+ return supportsCommandForService(0x34, 0x1);
+ }
+
+ public boolean supportsExternalCalendarService() {
+ if (supportsExpandCapability())
+ return supportsExpandCapability(184);
+ return false;
+ }
+
+ public boolean supportsTrack() {
+ if (supportsExpandCapability())
+ return supportsExpandCapability(0x36);
+ return false;
+ }
+
+ public boolean supportsCalendar() {
+ if (supportsExpandCapability())
+ return supportsExpandCapability(171) || supportsExpandCapability(184);
+ return false;
+ }
+
+ public boolean supportsMultiDevice() {
+ if (supportsExpandCapability())
+ return supportsExpandCapability(109);
+ return false;
+ }
+
+ public boolean supportsUnknownGender() {
+ if (supportsExpandCapability())
+ return supportsExpandCapability(0x57);
+ return false;
+ }
+
+ public boolean supportsPrecisionWeight() {
+ if (supportsExpandCapability())
+ return supportsExpandCapability(0xb3);
+ return false;
+ }
+
+ public boolean supportsMoreMusic() {
+ if (supportsExpandCapability())
+ return supportsExpandCapability(122);
+ return false;
+ }
+
+
+ public boolean supportsPromptPushMessage () {
+// do not ask for capabilities under specific condition
+// if (deviceType == 10 && deviceVersion == 73617766697368 && deviceSoftVersion == 372E312E31) -> leo device
+// if V1V0Device
+// if (serviceId = 0x01 && commandId = 0x03) && productType == 3
+ return (((notificationCapabilities >> 1) & 1) == 0);
+ }
+
+ public boolean supportsOutgoingCall () {
+ return (((notificationCapabilities >> 2) & 1) == 0);
+ }
+
+ public boolean supportsYellowPages() {
+ return supportsNotificationConstraint(NotificationConstraintsType.yellowPagesSupport);
+ }
+
+ public boolean supportsContentSIgn() {
+ return supportsNotificationConstraint(NotificationConstraintsType.contentSignSupport);
+ }
+
+ public boolean supportsIncomingNumber() {
+ return supportsNotificationConstraint(NotificationConstraintsType.incomingNumberSupport);
+ }
+
+ public int getContentFormat() {
+ return getNotificationConstraint(NotificationConstraintsType.contentFormat);
+ }
+
+ public int getYellowPagesFormat() {
+ return getNotificationConstraint(NotificationConstraintsType.yellowPagesFormat);
+ }
+
+ public int getContentSignFormat() {
+ return getNotificationConstraint(NotificationConstraintsType.contentSignFormat);
+ }
+
+ public int getIncomingFormatFormat() {
+ return getNotificationConstraint(NotificationConstraintsType.incomingNumberFormat);
+ }
+
+ public int getContentLength() {
+ return getNotificationConstraint(NotificationConstraintsType.contentLength);
+ }
+
+ public int getYellowPagesLength() {
+ return getNotificationConstraint(NotificationConstraintsType.yellowPagesLength);
+ }
+
+ public int getContentSignLength() {
+ return getNotificationConstraint(NotificationConstraintsType.contentSignLength);
+ }
+
+ public int getIncomingNumberLength() {
+ return getNotificationConstraint(NotificationConstraintsType.incomingNumberLength);
+ }
+
+ public int getAlarmSlotCount(GBDevice gbDevice) {
+ int alarmCount = 0;
+ if (supportsEventAlarm())
+ alarmCount += 5; // Always five event alarms
+ if (supportsSmartAlarm(gbDevice))
+ alarmCount += 1; // Always a single smart alarm
+ return alarmCount;
+ }
+
+ public int getContactsSlotCount(GBDevice device) {
+ return supportsContacts()?maxContactsCount:0;
+ }
+
+ public void setTransactionCrypted(boolean crypted) {
+ this.transactionCrypted = crypted;
+ }
+
+ public boolean isTransactionCrypted() {
+ return this.transactionCrypted;
+ }
+
+ public String[] getSupportedLanguageSettings(GBDevice device) {
+ return new String[]{
+ "auto",
+ "ar_SA",
+ "cs_CZ",
+ "da_DK",
+ "de_DE",
+ "el_GR",
+ "en_GB",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "he_IL",
+ "it_IT",
+ "id_ID",
+ "ko_KO",
+ "nl_NL",
+ "pl_PL",
+ "pt_PT",
+ "pt_BR",
+ "ro_RO",
+ "ru_RU",
+ "sv_SE",
+ "th_TH",
+ "ja_JP",
+ "tr_TR",
+ "uk_UA",
+ "zh_CN",
+ "zh_TW",
+ };
+
+ }
+
+ public short getWidth() {
+ return (short)watchfaceDeviceParams.width;
+ }
+
+ public short getHeight() {
+ return (short)watchfaceDeviceParams.height;
+ }
+
+ public void setWatchfaceDeviceParams(Watchface.WatchfaceDeviceParams watchfaceDeviceParams) {
+ this.watchfaceDeviceParams = watchfaceDeviceParams;
+ }
+
+ public void setAppDeviceParams(App.AppDeviceParams appDeviceParams) {
+ this.appDeviceParams = appDeviceParams;
+ }
+
+ public App.AppDeviceParams getAppDeviceParams() {
+ return appDeviceParams;
+ }
+
+ public void setExtendedMusicInfoParams(HuaweiMusicUtils.MusicCapabilities musicDeviceParams) {
+ LOG.info(musicDeviceParams.toString());
+ this.musicExtendedDeviceParams = musicDeviceParams;
+ }
+ public HuaweiMusicUtils.MusicCapabilities getExtendedMusicInfoParams() {
+ return musicExtendedDeviceParams;
+ }
+
+ public void setMusicInfoParams(HuaweiMusicUtils.MusicCapabilities musicDeviceParams) {
+ LOG.info(musicDeviceParams.toString());
+ this.musicDeviceParams = musicDeviceParams;
+ }
+ public HuaweiMusicUtils.MusicCapabilities getMusicInfoParams() {
+ return musicDeviceParams;
+ }
+
+
+ public Class<? extends Activity> getAppManagerActivity() {
+ return AppManagerActivity.class;
+ }
+
+ public boolean getSupportsAppListFetching() { return true; }
+
+ public boolean getSupportsAppsManagement(GBDevice device) {
+ return true;
+ }
+
+ public boolean getSupportsInstalledAppManagement(GBDevice device) {
+ return this.supportsAppParams(); // NOTE: this check can be incorrect. But looks like it works
+ }
+
+ public boolean getSupportsCachedAppManagement(GBDevice device) {
+ return false;
+ }
+
+ public InstallHandler getInstallHandler(Uri uri, Context context) {
+ HuaweiInstallHandler handler = new HuaweiInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ public boolean getSupportsTruSleepNewSync() {
+ return supportsTruSleepNewSync;
+ }
+
+ public void setSupportsTruSleepNewSync(boolean supportsTruSleepNewSync) {
+ this.supportsTruSleepNewSync = supportsTruSleepNewSync;
+ }
+
+ public boolean isSupportsGpsNewSync() {
+ return supportsGpsNewSync;
+ }
+
+ public void setSupportsGpsNewSync(boolean supportsGpsNewSync) {
+ this.supportsGpsNewSync = supportsGpsNewSync;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinatorSupplier.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinatorSupplier.java
new file mode 100644
index 0000000..9f26012
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinatorSupplier.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public interface HuaweiCoordinatorSupplier {
+
+ enum HuaweiDeviceType {
+ AW(0), //BLE behind
+ BR(1),
+ BLE(2),
+ SMART(5) //BLE behind
+ ;
+
+ final int huaweiType;
+
+ HuaweiDeviceType(int huaweiType) {
+ this.huaweiType = huaweiType;
+ }
+
+ public int getType(){
+ return huaweiType;
+ }
+ }
+
+ HuaweiCoordinator getHuaweiCoordinator();
+ HuaweiDeviceType getHuaweiType();
+ DeviceType getDeviceType();
+ void setDevice(GBDevice Device);
+ GBDevice getDevice();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCrypto.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCrypto.java
new file mode 100644
index 0000000..cbc95d4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCrypto.java
@@ -0,0 +1,273 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei;
+
+import nodomain.freeyourgadget.gadgetbridge.util.CryptoUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.NoSuchAlgorithmException;
+import java.security.InvalidKeyException;
+import java.security.SecureRandom;
+import java.security.spec.InvalidKeySpecException;
+import java.util.Arrays;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class HuaweiCrypto {
+ private static final Logger LOG = LoggerFactory.getLogger(HuaweiCrypto.class);
+
+ public static class CryptoException extends Exception {
+ CryptoException(Exception e) {
+ super(e);
+ }
+ }
+
+ public static final byte[] SECRET_KEY_1_v1 = new byte[]{ 0x6F, 0x75, 0x6A, 0x79,
+ 0x6D, 0x77, 0x71, 0x34,
+ 0x63, 0x6C, 0x76, 0x39,
+ 0x33, 0x37, 0x38, 0x79};
+ public static final byte[] SECRET_KEY_2_v1 = new byte[]{ 0x62, 0x31, 0x30, 0x6A,
+ 0x67, 0x66, 0x64, 0x39,
+ 0x79, 0x37, 0x76, 0x73,
+ 0x75, 0x64, 0x61, 0x39};
+ public static final byte[] SECRET_KEY_1_v23 = new byte[]{ 0x55, 0x53, (byte)0x86, (byte)0xFC,
+ 0x63, 0x20, 0x07, (byte)0xAA,
+ (byte)0x86, 0x49, 0x35, 0x22,
+ (byte)0xB8, 0x6A, (byte)0xE2, 0x5C};
+ public static final byte[] SECRET_KEY_2_v23 = new byte[]{ 0x33, 0x07, (byte)0x9B, (byte)0xC5,
+ 0x7A, (byte)0x88, 0x6D, 0x3C,
+ (byte)0xF5, 0x61, 0x37, 0x09,
+ 0x6F, 0x22, (byte)0x80, 0x00};
+
+ public static final byte[] DIGEST_SECRET_v1 = new byte[]{ 0x70, (byte)0xFB, 0x6C, 0x24,
+ 0x03, 0x5F, (byte)0xDB, 0x55,
+ 0x2F, 0x38, (byte)0x89, (byte)0x8A,
+ (byte) 0xEE, (byte)0xDE, 0x3F, 0x69};
+ public static final byte[] DIGEST_SECRET_v2 = new byte[]{ (byte)0x93, (byte)0xAC, (byte)0xDE, (byte)0xF7,
+ 0x6A, (byte)0xCB, 0x09, (byte)0x85,
+ 0x7D, (byte)0xBF, (byte)0xE5, 0x26,
+ 0x1A, (byte)0xAB, (byte)0xCD, 0x78};
+ public static final byte[] DIGEST_SECRET_v3 = new byte[]{ (byte)0x9C, 0x27, 0x63, (byte)0xA9,
+ (byte)0xCC, (byte)0xE1, 0x34, 0x76,
+ 0x6D, (byte)0xE3, (byte)0xFF, 0x61,
+ 0x18, 0x20, 0x05, 0x53};
+
+ public static final byte[] MESSAGE_RESPONSE = new byte[]{0x01, 0x10};
+ public static final byte[] MESSAGE_CHALLENGE = new byte[]{0x01, 0x00};
+
+ public static final long ENCRYPTION_COUNTER_MAX = 0xFFFFFFFF;
+
+ protected int authVersion;
+ protected int deviceSupportType;
+ protected byte authAlgo;
+ protected byte authMode;
+
+ public HuaweiCrypto(int authVersion) {
+ this.authVersion = authVersion;
+ }
+
+ public HuaweiCrypto(int authVersion, byte authAlgo, int deviceSupportType, byte authMode) {
+ this(authVersion);
+ this.deviceSupportType = deviceSupportType;
+ this.authAlgo = authAlgo;
+ this.authMode = authMode;
+ }
+
+ public static byte[] generateNonce() {
+ // While technically not a nonce, we need it to be random and rely on the length for the chance of repitition to be small
+ byte[] returnValue = new byte[16];
+ (new SecureRandom()).nextBytes(returnValue);
+ return returnValue;
+ }
+
+ private byte[] getDigestSecret() {
+ byte[] digest;
+ if (authVersion == 1) {
+ digest = DIGEST_SECRET_v1.clone();
+ } else if (authVersion == 2) {
+ digest = DIGEST_SECRET_v2.clone();
+ } else {
+ digest = DIGEST_SECRET_v3.clone();
+ }
+ return digest;
+ }
+ public byte[] computeDigest(byte[] message, byte[] nonce) throws NoSuchAlgorithmException, InvalidKeyException {
+ byte[] digestSecret = getDigestSecret();
+ byte[] msgToDigest = ByteBuffer.allocate(16 + message.length)
+ .put(digestSecret)
+ .put(message)
+ .array();
+ byte[] digestStep1 = CryptoUtils.calcHmacSha256(msgToDigest, nonce);
+ byte[] challenge = ByteBuffer.allocate(0x40)
+ .put(CryptoUtils.calcHmacSha256(digestStep1, nonce))
+ .put(digestStep1)
+ .array();
+ return challenge;
+ }
+
+ public byte[] computeDigestHiChainLite(byte[] message, byte[] key, byte[] nonce) throws NoSuchAlgorithmException, InvalidKeyException, InvalidKeySpecException, UnsupportedEncodingException {
+ byte[] digestStep1;
+ byte[] hashKey = CryptoUtils.digest(GB.hexdump(key).getBytes("UTF-8"));
+ byte[] digestSecret = getDigestSecret();
+ for (int i = 0; i < digestSecret.length; i++) {
+ digestSecret[i] = (byte) (((0xFF & hashKey[i]) ^ (digestSecret[i] & 0xFF)) & 0xFF);
+ }
+ byte[] msgToDigest = ByteBuffer.allocate(18)
+ .put(digestSecret)
+ .put(message)
+ .array();
+ if (authAlgo == 0x01) {
+ digestStep1 = CryptoUtils.pbkdf2Sha256(GB.hexdump(msgToDigest), GB.hexdump(nonce), 0x3e8, 0x100);
+ } else {
+ digestStep1 = CryptoUtils.calcHmacSha256(msgToDigest, nonce);
+ }
+ byte[] challenge = ByteBuffer.allocate(0x40)
+ .put(CryptoUtils.calcHmacSha256(digestStep1, nonce))
+ .put(digestStep1)
+ .array();
+ return challenge;
+ }
+
+ public byte[] digestChallenge(byte[] secretKey, byte[] nonce) throws NoSuchAlgorithmException, InvalidKeyException, InvalidKeySpecException, UnsupportedEncodingException {
+ if (authMode == 0x02) {
+ if (secretKey == null)
+ return null;
+ if (authVersion == 0x02) {
+ byte[] key = ByteBuffer.allocate(18)
+ .put(secretKey)
+ .put(MESSAGE_CHALLENGE)
+ .array();
+ byte[] challenge = ByteBuffer.allocate(0x40)
+ .put(CryptoUtils.calcHmacSha256(key, nonce))
+ .put(key)
+ .array();
+ return challenge;
+ }
+ return computeDigestHiChainLite(MESSAGE_CHALLENGE, secretKey, nonce);
+ }
+ return computeDigest(MESSAGE_CHALLENGE, nonce);
+ }
+
+ public byte[] digestResponse(byte[] secretKey, byte[] nonce) throws NoSuchAlgorithmException, InvalidKeyException, InvalidKeySpecException, UnsupportedEncodingException {
+ if (authMode == 0x02) {
+ if (secretKey == null)
+ return null;
+ if (authVersion == 0x02) {
+ byte[] key = ByteBuffer.allocate(18)
+ .put(secretKey)
+ .put(MESSAGE_RESPONSE)
+ .array();
+ byte[] challenge = ByteBuffer.allocate(0x40)
+ .put(CryptoUtils.calcHmacSha256(key, nonce))
+ .put(key)
+ .array();
+ return challenge;
+ }
+ return computeDigestHiChainLite(MESSAGE_RESPONSE, secretKey, nonce);
+ }
+ return computeDigest(MESSAGE_RESPONSE, nonce);
+ }
+
+ public static ByteBuffer initializationVector(long counter) {
+ if (counter == ENCRYPTION_COUNTER_MAX) {
+ counter = 1;
+ } else {
+ counter += 1;
+ }
+ ByteBuffer ivCounter = ByteBuffer.allocate(16);
+ ivCounter.put(generateNonce(), 0, 12);
+ ivCounter.put(ByteBuffer.allocate(8).putLong(counter).array(), 4, 4);
+ ivCounter.rewind();
+ return ivCounter;
+ }
+
+ public byte[] createSecretKey(String macAddress) throws NoSuchAlgorithmException {
+ byte[] secret_key_1 = SECRET_KEY_1_v23;
+ byte[] secret_key_2 = SECRET_KEY_2_v23;
+ if (authVersion == 1) {
+ secret_key_1 = SECRET_KEY_1_v1;
+ secret_key_2 = SECRET_KEY_2_v1;
+ }
+
+ byte[] macAddressKey = (macAddress.replace(":", "") + "0000").getBytes(StandardCharsets.UTF_8);
+
+ byte[] mixedSecretKey = new byte[16];
+ for (int i = 0; i < 16; i++) {
+ mixedSecretKey[i] = (byte)((((0xFF & secret_key_1[i]) << 4) ^ (0xFF & secret_key_2[i])) & 0xFF);
+ }
+
+ byte[] mixedSecretKeyHash = CryptoUtils.digest(mixedSecretKey);
+ byte[] finalMixedKey = new byte[16];
+ for (int i = 0; i < 16; i++) {
+ finalMixedKey[i] = (byte)((((0xFF & mixedSecretKeyHash[i]) >> 6) ^ (0xFF & macAddressKey[i])) & 0xFF);
+ }
+ byte[] finalMixedKeyHash = CryptoUtils.digest(finalMixedKey);
+ return Arrays.copyOfRange(finalMixedKeyHash, 0, 16);
+ }
+
+ public byte[] encryptBondingKey(byte encryptMethod, byte[] data, byte[] encryptionKey, byte[] iv) throws NoSuchAlgorithmException, InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException, IllegalArgumentException {
+ if (encryptMethod == 0x01)
+ return CryptoUtils.encryptAES_GCM_NoPad(data, encryptionKey, iv, null);
+ return CryptoUtils.encryptAES_CBC_Pad(data, encryptionKey, iv);
+ }
+
+ public byte[] decryptBondingKey(byte encryptMethod, byte[] data, byte[] key, byte[] iv) throws NoSuchAlgorithmException, InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException, IllegalArgumentException {
+ if (encryptMethod == 0x1)
+ return CryptoUtils.decryptAES_GCM_NoPad(data, key, iv, null);
+ return CryptoUtils.decryptAES_CBC_Pad(data, key, iv);
+ }
+
+ public byte[] decryptPinCode(byte encryptMethod, byte[] message, byte[] iv) throws CryptoException {
+ byte[] secretKey = getDigestSecret();
+ try {
+ if (encryptMethod == 0x1)
+ return CryptoUtils.decryptAES_GCM_NoPad(message, secretKey, iv, null);
+ return CryptoUtils.decryptAES_CBC_Pad(message, secretKey, iv);
+ } catch (Exception e) {
+ throw new CryptoException(e);
+ }
+ }
+
+ public static byte[] encrypt(boolean useGCM, byte[] message, byte[] key, byte[] iv) throws CryptoException {
+ try {
+ if (useGCM)
+ return CryptoUtils.encryptAES_GCM_NoPad(message, key, iv, null);
+ return CryptoUtils.encryptAES_CBC_Pad(message, key, iv);
+ } catch (InvalidAlgorithmParameterException | NoSuchPaddingException | IllegalBlockSizeException | NoSuchAlgorithmException | BadPaddingException | InvalidKeyException | IllegalArgumentException e) {
+ throw new CryptoException(e);
+ }
+ }
+
+ public static byte[] decrypt(boolean useGCM, byte[] message, byte[] key, byte[] iv) throws CryptoException {
+ try {
+ if (useGCM)
+ return CryptoUtils.decryptAES_GCM_NoPad(message, key, iv, null);
+ return CryptoUtils.decryptAES_CBC_Pad(message, key, iv);
+ } catch (InvalidAlgorithmParameterException | NoSuchPaddingException | IllegalBlockSizeException | NoSuchAlgorithmException | BadPaddingException | InvalidKeyException | IllegalArgumentException e) {
+ throw new CryptoException(e);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiDictTypes.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiDictTypes.java
new file mode 100644
index 0000000..3d49e54
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiDictTypes.java
@@ -0,0 +1,10 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+public class HuaweiDictTypes {
+ public static final int BLOOD_PRESSURE_CLASS = 10002;
+
+ public static final int BODY_TEMPERATURE_CLASS = 400011;
+
+ public static final int SKIN_TEMPERATURE_CLASS = 400012;
+ public static final int SKIN_TEMPERATURE_VALUE = 400012430;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiFreebudsCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiFreebudsCoordinator.java
new file mode 100644
index 0000000..a5d964e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiFreebudsCoordinator.java
@@ -0,0 +1,94 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+
+public abstract class HuaweiFreebudsCoordinator extends AbstractBLClassicDeviceCoordinator implements HuaweiCoordinatorSupplier {
+
+ private final HuaweiCoordinator huaweiCoordinator = new HuaweiCoordinator(this);
+ private GBDevice gbDevice;
+
+ public HuaweiFreebudsCoordinator() {
+ huaweiCoordinator.setTransactionCrypted(false);
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ // TODO: implement
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Huawei";
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_nothingear;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_nothingear_disabled;
+ }
+
+ @Override
+ public HuaweiCoordinator getHuaweiCoordinator() {
+ return huaweiCoordinator;
+ }
+
+ @Override
+ public HuaweiDeviceType getHuaweiType() {
+ return HuaweiDeviceType.BR;
+ }
+
+ @Override
+ public void setDevice(GBDevice gbDevice) {
+ this.gbDevice = gbDevice;
+ }
+
+ @Override
+ public GBDevice getDevice() {
+ return this.gbDevice;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ // TODO: Check if correct
+ return BONDING_STYLE_ASK;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(GBDevice device) {
+ BatteryConfig battery1 = new BatteryConfig(2, R.drawable.ic_tws_case, R.string.battery_case);
+ BatteryConfig battery2 = new BatteryConfig(0, R.drawable.ic_nothing_ear_l, R.string.left_earbud);
+ BatteryConfig battery3 = new BatteryConfig(1, R.drawable.ic_nothing_ear_r, R.string.right_earbud);
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(GBDevice device) {
+ DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_huawei_freebuds);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_headphones);
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public boolean addBatteryPollingSettings() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiGpsParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiGpsParser.java
new file mode 100644
index 0000000..44cd4dc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiGpsParser.java
@@ -0,0 +1,101 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+import androidx.annotation.NonNull;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+
+public class HuaweiGpsParser {
+
+ public static class GpsPoint {
+ public int timestamp;
+ public double latitude;
+ public double longitude;
+ public boolean altitudeSupported;
+ public double altitude;
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "GpsPoint{" +
+ "timestamp=" + timestamp +
+ ", longitude=" + longitude +
+ ", latitude=" + latitude +
+ ", altitudeSupported=" + altitudeSupported +
+ ", altitude=" + altitude +
+ '}';
+ }
+ }
+
+ public static GpsPoint[] parseHuaweiGps(byte[] data) {
+ ByteBuffer buffer = ByteBuffer.wrap(data);
+ buffer.order(ByteOrder.LITTLE_ENDIAN);
+
+ // Skip trim
+ buffer.position(0x20);
+
+ int timestamp;
+ double lon_start;
+ double lat_start;
+ boolean alt_support;
+ double alt_start;
+
+ byte fileType = buffer.get();
+ if ((fileType & 0x03) != 0x03) {
+ alt_support = false;
+ timestamp = buffer.getInt();
+ lon_start = buffer.getDouble();
+ lat_start = buffer.getDouble();
+ alt_start = 0;
+ buffer.position(62); // Skip past unknown fields/padding
+ } else {
+ alt_support = true;
+ timestamp = buffer.getInt();
+ lon_start = buffer.getDouble();
+ lat_start = buffer.getDouble();
+ alt_start = buffer.getDouble();
+ buffer.position(70); // Skip past unknown fields/padding
+ }
+
+ lat_start = lat_start * 0.017453292519943;
+ lon_start = lon_start * 0.017453292519943;
+
+ // Working values
+ int time = timestamp;
+ double lat = lat_start;
+ double lon = lon_start;
+ double alt = alt_start;
+
+ int data_size = 15;
+ if (alt_support)
+ data_size += 4;
+
+ ArrayList<GpsPoint> retv = new ArrayList<>(buffer.remaining() / data_size);
+ while (buffer.remaining() > data_size) {
+ short time_delta = buffer.getShort();
+ buffer.getShort(); // Unknown value
+ float lon_delta = buffer.getFloat();
+ float lat_delta = buffer.getFloat();
+ buffer.get(); buffer.get(); buffer.get(); // Unknown values
+
+ time = time + time_delta;
+ lat = lat + lat_delta;
+ lon = lon + lon_delta;
+
+ GpsPoint point = new GpsPoint();
+ point.timestamp = time;
+ point.latitude = (lat / 6383807.0d + lat_start) / 0.017453292519943d;
+ point.longitude = (lon / 6383807.0d / Math.cos(lat_start) + lon_start) / 0.017453292519943d;
+ point.altitudeSupported = alt_support;
+ if (alt_support) {
+ alt = buffer.getShort();
+ buffer.getShort(); // Unknown values
+ point.altitude = alt;
+ }
+ retv.add(point);
+ }
+
+ return retv.toArray(new GpsPoint[0]);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiInstallHandler.java
new file mode 100644
index 0000000..52ab1a8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiInstallHandler.java
@@ -0,0 +1,272 @@
+/* Copyright (C) 2024 Vitalii Tomin
+
+ 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.devices.huawei;
+
+import android.content.Context;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiAppManager;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiFwHelper;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiMusicManager;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiWatchfaceManager;
+
+public class HuaweiInstallHandler implements InstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(HuaweiInstallHandler.class);
+
+ private final Context context;
+ protected final HuaweiFwHelper helper;
+
+ boolean valid = false;
+
+ public HuaweiInstallHandler(Uri uri, Context context) {
+ this.context = context;
+ this.helper = new HuaweiFwHelper(uri, context);
+ }
+
+ private HuaweiMusicUtils.FormatRestrictions getRestriction(HuaweiMusicUtils.MusicCapabilities capabilities, String ext) {
+ List<HuaweiMusicUtils.FormatRestrictions> restrictions = capabilities.formatsRestrictions;
+ if(restrictions == null)
+ return null;
+
+ for(HuaweiMusicUtils.FormatRestrictions r: restrictions) {
+ if(ext.equals(r.getName())) {
+ return r;
+ }
+ }
+ return null;
+ }
+
+ //TODO: add proper checks
+ private boolean checkMediaCompatibility(HuaweiMusicUtils.MusicCapabilities capabilities, HuaweiMusicManager.AudioInfo currentMusicInfo) {
+ if(capabilities == null) {
+ LOG.error("No media info from device");
+ return false;
+ }
+ String ext = currentMusicInfo.getExtension();
+
+ List<String> supportedFormats = capabilities.supportedFormats;
+ if(supportedFormats == null) {
+ LOG.error("Format not supported {}", ext);
+ return false;
+ }
+ if(!supportedFormats.contains(ext)) {
+ LOG.error("Format not supported {}", ext);
+ return false;
+ }
+
+ HuaweiMusicUtils.FormatRestrictions restrictions = getRestriction(capabilities, ext);
+ if(restrictions == null) {
+ LOG.info("no restriction for: {}", ext);
+ return true;
+ }
+
+ LOG.info("bitrate {}", restrictions.bitrate);
+ LOG.info("channels {}", restrictions.channels);
+ LOG.info("musicEncode {}", restrictions.musicEncode);
+ LOG.info("sampleRate {}", Arrays.toString(restrictions.sampleRates));
+ LOG.info("unknownBitrate {}", restrictions.unknownBitrate);
+
+ if(currentMusicInfo.getChannels() > restrictions.channels) {
+ LOG.error("Not supported channels count {} > {}", currentMusicInfo.getChannels(), restrictions.channels);
+ return false;
+ }
+
+ //TODO: check other restrictions.
+
+ return true;
+ }
+
+
+ @Override
+ public void validateInstallation(InstallActivity installActivity, GBDevice device) {
+
+ final DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ if (!(coordinator instanceof HuaweiCoordinatorSupplier)) {
+ LOG.warn("Coordinator is not a HuaweiCoordinatorSupplier: {}", coordinator.getClass());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (helper.isWatchface()) {
+ final HuaweiCoordinatorSupplier huaweiCoordinatorSupplier = (HuaweiCoordinatorSupplier) coordinator;
+
+ HuaweiWatchfaceManager.WatchfaceDescription description = helper.getWatchfaceDescription();
+
+ HuaweiWatchfaceManager.Resolution resolution = new HuaweiWatchfaceManager.Resolution();
+ String deviceScreen = String.format("%d*%d", huaweiCoordinatorSupplier.getHuaweiCoordinator().getHeight(),
+ huaweiCoordinatorSupplier.getHuaweiCoordinator().getWidth());
+ this.valid = resolution.isValid(description.screen, deviceScreen);
+
+ installActivity.setInstallEnabled(true);
+
+ GenericItem installItem = new GenericItem();
+
+ if (helper.getPreviewBitmap() != null) {
+ installItem.setPreview(helper.getPreviewBitmap());
+ }
+
+ installItem.setName(description.title);
+ installActivity.setInstallItem(installItem);
+ if (device.isBusy()) {
+ LOG.error("Watchface cannot be installed (device busy)");
+ installActivity.setInfoText("Watchface cannot be installed (device busy)");
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!device.isConnected()) {
+ LOG.error("Watchface cannot be installed (not connected or wrong device)");
+ installActivity.setInfoText("Watchface cannot be installed (not connected or wrong device)");
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!this.valid) {
+ LOG.error("Watchface cannot be installed");
+ installActivity.setInfoText(context.getString(R.string.watchface_resolution_doesnt_match,
+ resolution.screenByThemeVersion(description.screen), deviceScreen));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ installItem.setDetails(description.version);
+
+ installItem.setIcon(R.drawable.ic_watchface);
+ installActivity.setInfoText(context.getString(R.string.watchface_install_info, installItem.getName(), description.version, description.author));
+
+ LOG.debug("Initialized HuaweiInstallHandler: Watchface");
+ } else if (helper.isAPP()) {
+ final HuaweiAppManager.AppConfig config = helper.getAppConfig();
+
+ this.valid = true; //NOTE: nothing to verify for now
+
+ installActivity.setInstallEnabled(true);
+
+ GenericItem installItem = new GenericItem();
+
+ if (helper.getPreviewBitmap() != null) {
+ installItem.setPreview(helper.getPreviewBitmap());
+ }
+
+ installItem.setName(config.bundleName);
+ installActivity.setInstallItem(installItem);
+ if (device.isBusy()) {
+ LOG.error("App cannot be installed (device busy)");
+ installActivity.setInfoText("App cannot be installed (device busy)");
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!device.isConnected()) {
+ LOG.error("App cannot be installed (not connected or wrong device)");
+ installActivity.setInfoText("App cannot be installed (not connected or wrong device)");
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!this.valid) {
+ LOG.error("App cannot be installed");
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ installItem.setDetails(config.version);
+
+ installItem.setIcon(R.drawable.ic_watchapp);
+
+ installActivity.setInfoText(context.getString(R.string.app_install_info, installItem.getName(), config.version, config.vendor));
+
+ LOG.debug("Initialized HuaweiInstallHandler: App");
+ } else if (helper.isMusic()) {
+ final HuaweiCoordinatorSupplier huaweiCoordinatorSupplier = (HuaweiCoordinatorSupplier) coordinator;
+
+ HuaweiMusicUtils.MusicCapabilities capabilities = huaweiCoordinatorSupplier.getHuaweiCoordinator().getExtendedMusicInfoParams();
+ if(capabilities == null) {
+ capabilities = huaweiCoordinatorSupplier.getHuaweiCoordinator().getMusicInfoParams();
+ }
+ HuaweiMusicManager.AudioInfo currentMusicInfo = helper.getMusicInfo();
+
+ boolean isMediaCompatible = checkMediaCompatibility(capabilities, currentMusicInfo);
+
+ this.valid = isMediaCompatible && !TextUtils.isEmpty(helper.getMusicInfo().getFileName()) && !TextUtils.isEmpty(helper.getMusicInfo().getArtist()) && !TextUtils.isEmpty(helper.getMusicInfo().getTitle());
+
+ installActivity.setInstallEnabled(true);
+
+ GenericItem installItem = new GenericItem();
+
+ installItem.setName(helper.getFileName());
+ installActivity.setInstallItem(installItem);
+ if (device.isBusy()) {
+ LOG.error("Music cannot be uploaded (device busy)");
+ installActivity.setInfoText("Music cannot be uploaded (device busy)");
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!device.isConnected()) {
+ LOG.error("Music cannot be uploaded (not connected or wrong device)");
+ installActivity.setInfoText("Music cannot be uploaded (not connected or wrong device)");
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!this.valid) {
+ LOG.error("Music cannot be uploaded");
+ installActivity.setInfoText("Music cannot be uploaded");
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ installItem.setDetails(helper.getMusicInfo().getFileName());
+
+ installItem.setIcon(R.drawable.ic_music);
+
+ installActivity.setInfoText(context.getString(R.string.music_upload_info, helper.getMusicInfo().getFileName(), helper.getMusicInfo().getTitle(), helper.getMusicInfo().getArtist()));
+
+ LOG.debug("Initialized HuaweiInstallHandler: Music");
+ }
+
+ }
+
+ @Override
+ public boolean isValid() {
+ return helper.isValid();
+ }
+
+ @Override
+ public void onStartInstall(GBDevice device) {
+ helper.unsetFwBytes();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java
new file mode 100644
index 0000000..13d06cc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java
@@ -0,0 +1,269 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiLESupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiWorkoutGbParser;
+
+public abstract class HuaweiLECoordinator extends AbstractBLEDeviceCoordinator implements HuaweiCoordinatorSupplier {
+
+ private final HuaweiCoordinator huaweiCoordinator = new HuaweiCoordinator(this);
+ private GBDevice device;
+
+ @Override
+ public HuaweiCoordinator getHuaweiCoordinator() {
+ return huaweiCoordinator;
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid huaweiService = new ParcelUuid(HuaweiConstants.UUID_SERVICE_HUAWEI_SERVICE);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(huaweiService).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new HuaweiSettingsCustomizer(device);
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(GBDevice device) {
+ return huaweiCoordinator.getSupportedLanguageSettings(device);
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificAuthenticationSettings() {
+ return new int[]{
+ R.xml.devicesettings_huawei_account,
+ R.xml.devicesettings_miband6_new_protocol
+ };
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ huaweiCoordinator.deleteDevice(gbDevice, device, session);
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Huawei";
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(GBDevice device, int position) {
+ return huaweiCoordinator.supportsSmartAlarm(device, position);
+ }
+
+ @Override
+ public boolean supportsSmartWakeupInterval(GBDevice device, int alarmPosition) {
+ return supportsSmartWakeup(device, alarmPosition);
+ }
+
+ @Override
+ public boolean forcedSmartWakeup(GBDevice device, int alarmPosition) {
+ return huaweiCoordinator.forcedSmartWakeup(device, alarmPosition);
+ }
+
+ @Override
+ public boolean supportsAlarmTitle(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return huaweiCoordinator.supportsWeather();
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return huaweiCoordinator.getAppManagerActivity();
+ }
+
+ @Override
+ public boolean supportsAppListFetching() {
+ return huaweiCoordinator.getSupportsAppListFetching();
+ }
+ @Override
+ public boolean supportsAppsManagement(GBDevice device) {
+ return huaweiCoordinator.getSupportsAppsManagement(device);
+ }
+
+ @Override
+ public boolean supportsWatchfaceManagement(GBDevice device) {
+ return supportsAppsManagement(device);
+ }
+
+ @Override
+ public boolean supportsInstalledAppManagement(GBDevice device) {
+ return huaweiCoordinator.getSupportsInstalledAppManagement(device);
+ }
+
+ @Override
+ public boolean supportsCachedAppManagement(GBDevice device) {
+ return huaweiCoordinator.getSupportsCachedAppManagement(device);
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return huaweiCoordinator.getAlarmSlotCount(device);
+ }
+
+ @Override
+ public int getContactsSlotCount(GBDevice device) {
+ return huaweiCoordinator.getContactsSlotCount(device);
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return huaweiCoordinator.supportsCalendarEvents();
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActiveCalories() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return huaweiCoordinator.supportsHeartRate(device);
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return huaweiCoordinator.supportsSPo2(device);
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return huaweiCoordinator.supportsMusic();
+ }
+
+ @Override
+ public boolean supportsTemperatureMeasurement() {
+ return huaweiCoordinator.supportsTemperature();
+ }
+
+ @Override
+ public boolean supportsContinuousTemperature() {
+ return huaweiCoordinator.supportsTemperature();
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return huaweiCoordinator.getInstallHandler(uri, context);
+ }
+
+ @Override
+ public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
+ return new HuaweiWorkoutGbParser(device, context);
+ }
+
+ @Override
+ public SampleProvider<? extends AbstractActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new HuaweiSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(GBDevice device, DaoSession session) {
+ return new HuaweiSpo2SampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends TemperatureSample> getTemperatureSampleProvider(final GBDevice device, final DaoSession session) {
+ return new HuaweiTemperatureSampleProvider(device, session);
+ }
+
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ return huaweiCoordinator.getDeviceSpecificSettings(device);
+ }
+
+ @Override
+ public HuaweiDeviceType getHuaweiType() {
+ return HuaweiDeviceType.BLE;
+ }
+
+ @Override
+ public void setDevice(GBDevice device) {
+ this.device = device;
+ }
+
+ @Override
+ public GBDevice getDevice() {
+ return this.device;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return HuaweiLESupport.class;
+ }
+
+ @Override
+ public boolean addBatteryPollingSettings() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiMusicUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiMusicUtils.java
new file mode 100644
index 0000000..31dfb78
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiMusicUtils.java
@@ -0,0 +1,130 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class HuaweiMusicUtils {
+
+ public static class PageStruct {
+ public short startFrame = 0;
+ public short endFrame = 0;
+ public short count = 0;
+ public byte[] hashCode = null;
+
+ @Override
+ public String toString() {
+ final StringBuffer sb = new StringBuffer("PageStruct{");
+ sb.append("startFrame=").append(startFrame);
+ sb.append(", endFrame=").append(endFrame);
+ sb.append(", count=").append(count);
+ sb.append(", hashCode=");
+ if (hashCode == null) sb.append("null");
+ else {
+ sb.append('[');
+ for (int i = 0; i < hashCode.length; ++i)
+ sb.append(i == 0 ? "" : ", ").append(hashCode[i]);
+ sb.append(']');
+ }
+ sb.append('}');
+ return sb.toString();
+ }
+ }
+
+ public static class FormatRestrictions {
+ public byte formatIdx = -1;
+ public String[] sampleRates = null;
+ public byte musicEncode = -1; // TODO: not sure
+ public short bitrate = -1;
+ public byte channels = 2;
+ public short unknownBitrate = -1; // TODO: not sure
+
+ // TODO: I am not sure about this. Most of formats unknown for me.
+ private static final String[] formats = {"mp3", "wav", "aac", "sbc", "msbc", "hwa", "cvsd", "pcm", "ape", "m4a", "flac", "opus", "ogg", "butt", "amr", "imy"};
+
+ public String getName() {
+ return (formatIdx >= 0 && formatIdx < formats.length)?formats[formatIdx]:null;
+ }
+
+ @Override
+ public String toString() {
+ final StringBuffer sb = new StringBuffer("FormatRestrictions{");
+ sb.append("formatIdx=").append(formatIdx);
+ sb.append(", sampleRates=").append(Arrays.toString(sampleRates));
+ sb.append(", musicEncode=").append(musicEncode);
+ sb.append(", bitrate=").append(bitrate);
+ sb.append(", channels=").append(channels);
+ sb.append(", unknownBitrate=").append(unknownBitrate);
+ sb.append('}');
+ return sb.toString();
+ }
+ }
+
+ public static class MusicCapabilities {
+ public int availableSpace = 0;
+ public List<String> supportedFormats = null;
+ public int maxMusicCount = 0;
+ public int maxPlaylistCount = 0;
+ public int currentMusicCount = 0; // TODO: not sure
+ public int unknown = 0; // TODO: not sure
+ public List<FormatRestrictions> formatsRestrictions = null;
+
+ @Override
+ public String toString() {
+ final StringBuffer sb = new StringBuffer("MusicCapabilities{");
+ sb.append("availableSpace=").append(availableSpace);
+ sb.append(", supportedFormats=").append(supportedFormats);
+ sb.append(", maxMusicCount=").append(maxMusicCount);
+ sb.append(", maxPlaylistCount=").append(maxPlaylistCount);
+ sb.append(", currentMusicCount=").append(currentMusicCount);
+ sb.append(", unknown=").append(unknown);
+ sb.append(", formatsRestrictions=").append(formatsRestrictions);
+ sb.append('}');
+ return sb.toString();
+ }
+ }
+
+
+ // TODO: I am not sure about this. Most of formats unknown for me.
+ private static final String[] formatsByte0 = {"mp3", "wav", "aac", "sbc", "msbc", "hwa", "cvsd"};
+ private static final String[] formatsByte1 = {"pcm", "ape", "m4a", "flac", "opus", "ogg", "butt"};
+ private static final String[] formatsByte2 = {"amr", "imy"};
+
+ public static void parseNext(int dt, String[] info, List<String> res) {
+ for (byte k = 0; k < info.length; k++) {
+ if ((dt & (1 << k)) != 0) {
+ res.add(info[k]);
+ }
+ }
+ }
+
+ public static List<String> parseFormats(List<Integer> data) {
+ List<String> res = new ArrayList<>();
+
+ if (data.size() >= 1) {
+ parseNext(data.get(0), formatsByte0, res);
+ }
+
+ if (data.size() >= 2) {
+ parseNext(data.get(1), formatsByte1, res);
+ }
+
+ if (data.size() >= 3) {
+ parseNext(data.get(2), formatsByte2, res);
+ }
+
+ return res;
+ }
+
+ public static List<String> parseFormatBits(byte[] formatBits) {
+ if (formatBits.length == 0)
+ return null;
+ List<Integer> toDecode = new ArrayList<>();
+ for (byte formatBit : formatBits) {
+ int dt = formatBit & 0xFF;
+ toDecode.add(dt);
+ if ((dt & 128) == 0) break;
+ }
+ return parseFormats(toDecode);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiPacket.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiPacket.java
new file mode 100644
index 0000000..ac07495
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiPacket.java
@@ -0,0 +1,1006 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei;
+
+import static nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants.HUAWEI_MAGIC;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Alarms;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.AccountRelated;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.App;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Calls;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.CameraRemote;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Contacts;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Earphones;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.EphemerisFileUpload;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.FileDownloadService0A;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.FileDownloadService2C;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.GpsAndTime;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.P2P;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Watchface;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Weather;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Workout;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.DeviceConfig;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.FindPhone;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.FitnessData;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.MusicControl;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Notifications;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.FileUpload;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.Ephemeris;
+import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
+
+public class HuaweiPacket {
+ private static final Logger LOG = LoggerFactory.getLogger(HuaweiPacket.class);
+
+ public static class ParamsProvider {
+
+ protected byte authVersion;
+ protected byte deviceSupportType;
+ protected byte[] secretKey;
+ protected int slicesize = 0xf4;
+ protected boolean transactionsCrypted = true;
+ protected int mtu = 65535;
+ protected long encryptionCounter = 0;
+
+ protected byte[] pinCode = null;
+
+ protected byte interval;
+ protected byte authAlgo;
+ protected byte encryptMethod;
+ protected byte[] firstKey;
+ protected byte authMode;
+
+ public void setAuthVersion(byte authVersion) {
+ this.authVersion = authVersion;
+ }
+
+ public byte getAuthVersion() {
+ return this.authVersion;
+ }
+
+ public void setDeviceSupportType(byte deviceSupportType) {
+ this.deviceSupportType = deviceSupportType;
+ }
+
+ public byte getDeviceSupportType(){
+ return this.deviceSupportType;
+ }
+
+ public void setSecretKey(byte[] secretKey) {
+ this.secretKey = secretKey;
+ }
+
+ public byte[] getSecretKey() {
+ return this.secretKey;
+ }
+
+ public void setTransactionsCrypted(boolean transactionsCrypted) {
+ this.transactionsCrypted = transactionsCrypted;
+ }
+
+ public boolean areTransactionsCrypted() {
+ return this.transactionsCrypted;
+ }
+
+ public void setMtu(int mtu) {
+ this.mtu = mtu;
+ }
+
+ public int getMtu() {
+ return this.mtu;
+ }
+
+ public void setSliceSize(int sliceSize) {
+ this.slicesize = sliceSize;
+ }
+
+ public int getSliceSize() {
+ return this.slicesize;
+ }
+ public void setPinCode(byte[] pinCode) {
+ this.pinCode = pinCode;
+ }
+
+ public byte[] getPinCode() {
+ return this.pinCode;
+ }
+
+ public void setInterval(byte interval) {
+ this.interval = interval;
+ }
+
+ public byte getInterval() {
+ return this.interval;
+ }
+
+ public byte getAuthMode() { return this.authMode; }
+
+ public void setAuthMode(byte authMode) { this.authMode = authMode; }
+
+ public byte[] getIv() {
+ byte[] iv = null;
+ if (this.deviceSupportType == 0x04) {
+ iv = HuaweiCrypto.generateNonce();
+ } else {
+ ByteBuffer ivCounter = HuaweiCrypto.initializationVector(this.encryptionCounter);
+ iv = ivCounter.array();
+ this.encryptionCounter = (long)ivCounter.getInt(12) & 0xFFFFFFFFL;
+ }
+ return iv;
+ }
+
+ public void setEncryptionCounter(long counter) {
+ this.encryptionCounter = counter;
+ }
+
+ public void setAuthAlgo(byte authAlgo) {
+ this.authAlgo = authAlgo;
+ }
+
+ public byte getAuthAlgo () {
+ return this.authAlgo;
+ }
+
+ public void setEncryptMethod(byte encryptMethod) {
+ this.encryptMethod = encryptMethod;
+ }
+
+ public byte getEncryptMethod () {
+ return this.encryptMethod;
+ }
+
+ public void setFirstKey(byte[] firstKey) {
+ this.firstKey = firstKey;
+ }
+
+ public byte[] getFirstKey() {
+ return firstKey;
+ }
+ }
+
+ public static abstract class ParseException extends Exception {
+ ParseException(String message) {
+ super(message);
+ }
+
+ ParseException(String message, Exception e) {
+ super(message, e);
+ }
+ }
+
+ public static class LengthMismatchException extends ParseException {
+ public LengthMismatchException(String message) {
+ super(message);
+ }
+ }
+
+ public static class MagicMismatchException extends ParseException {
+ MagicMismatchException(String message) {
+ super(message);
+ }
+ }
+
+ public static class ChecksumIncorrectException extends ParseException {
+ ChecksumIncorrectException(String message) {
+ super(message);
+ }
+ }
+
+ public static class MissingTagException extends ParseException {
+ public MissingTagException(int tag) {
+ super("Missing tag: " + Integer.toHexString(tag));
+ }
+ }
+
+ public static class CryptoException extends ParseException {
+ public CryptoException(String message, Exception e) {
+ super(message, e);
+ }
+ }
+
+ public static class JsonException extends ParseException {
+ public JsonException(String message, Exception e) {
+ super(message, e);
+ }
+ }
+
+ public static class SupportedCommandsListException extends ParseException {
+ public SupportedCommandsListException(String message) {
+ super(message);
+ }
+ }
+
+ public static class SerializeException extends Exception {
+ public SerializeException(String message) {
+ super(message);
+ }
+ public SerializeException(String message, Exception e) {
+ super(message, e);
+ }
+ }
+
+ protected static final int PACKET_MINIMAL_SIZE = 3; // magic + data size
+
+ protected ParamsProvider paramsProvider;
+
+ public byte serviceId = 0;
+ public byte commandId = 0;
+ protected HuaweiTLV tlv = null;
+
+ private byte[] partialPacket = null;
+ protected byte[] payload = null;
+
+ public boolean complete = false;
+
+ // Encryption is enabled by default, packets which don't use it must disable it
+ protected boolean isEncrypted = true;
+
+ protected boolean isSliced = true;
+
+ public HuaweiPacket(ParamsProvider paramsProvider) {
+ this.paramsProvider = paramsProvider;
+ }
+
+ public boolean attemptDecrypt() throws ParseException {
+ if (paramsProvider == null || paramsProvider.getSecretKey() == null)
+ return false;
+ if (this.tlv == null)
+ return false;
+ if (this.tlv.contains(0x7C) && this.tlv.getBoolean(0x7C)) {
+ try {
+ this.tlv.decrypt(paramsProvider);
+ return true;
+ } catch (HuaweiCrypto.CryptoException e) {
+ throw new CryptoException("Decrypt exception", e);
+ }
+ } else {
+ if (this.isEncrypted && paramsProvider.areTransactionsCrypted()) {
+ // TODO: potentially a log message? We expect it to be encrypted, but it isn't.
+ }
+ }
+ return false;
+ }
+
+ /*
+ * This function is to convert the Packet into the proper subclass
+ */
+ protected HuaweiPacket fromPacket(HuaweiPacket packet) throws ParseException {
+ this.paramsProvider = packet.paramsProvider;
+ this.serviceId = packet.serviceId;
+ this.commandId = packet.commandId;
+ this.tlv = packet.tlv;
+ this.partialPacket = packet.partialPacket;
+ this.payload = packet.payload;
+ this.complete = packet.complete;
+ this.left = packet.left;
+
+ if (packet.isEncrypted)
+ this.isEncrypted = true;
+ else
+ this.isEncrypted = this.attemptDecrypt();
+
+ return this;
+ }
+
+ /*
+ * This function is to set up the subclass for easy usage
+ * Needs to be called separately so the exceptions can be used more easily
+ */
+ public void parseTlv() throws ParseException {}
+
+ private int left = 0;
+
+ public int getLeft() {
+ return this.left;
+ }
+
+ private void parseData(byte[] data) throws ParseException {
+ this.left = 0;
+ if (partialPacket != null) {
+ int newCapacity = partialPacket.length + data.length;
+ data = ByteBuffer.allocate(newCapacity)
+ .put(partialPacket)
+ .put(data)
+ .array();
+ }
+
+ ByteBuffer buffer = ByteBuffer.wrap(data);
+ if (buffer.capacity() < 1) {
+ throw new LengthMismatchException("Packet length mismatch : "
+ + buffer.capacity()
+ + " < 1");
+ }
+
+ byte magic = buffer.get();
+
+ if (magic != HUAWEI_MAGIC) {
+ throw new MagicMismatchException("Magic mismatch : "
+ + Integer.toHexString(magic)
+ + " != 0x5A");
+ }
+
+ if (buffer.capacity() < PACKET_MINIMAL_SIZE) {
+ this.partialPacket = data;
+ return;
+ }
+
+ short expectedSize = buffer.getShort();
+
+ if(expectedSize < 0) {
+ throw new LengthMismatchException("Expected length mismatch : " + expectedSize);
+ }
+
+ if (expectedSize + 2 > buffer.remaining()) {
+ // Older band and BT version do not handle message with more than 256 bits.
+ this.partialPacket = data;
+ return;
+ }
+
+ this.partialPacket = null;
+
+ int addLen = 1;
+ int isSliced = buffer.get();
+ if (isSliced == 1 || isSliced == 2 || isSliced == 3) {
+ buffer.get(); // Throw away slice flag
+ addLen++;
+ }
+
+ byte[] newPayload = new byte[expectedSize - addLen];
+ buffer.get(newPayload, 0, expectedSize - addLen);
+ short expectedChecksum = buffer.getShort();
+ this.left = buffer.remaining();
+ buffer.rewind();
+
+ byte[] dataNoCRC = new byte[expectedSize + 3];
+ buffer.get(dataNoCRC, 0, expectedSize + 3);
+ short actualChecksum = (short) CheckSums.getCRC16(dataNoCRC, 0x0000);
+ if (actualChecksum != expectedChecksum) {
+ throw new ChecksumIncorrectException("Checksum mismatch : "
+ + String.valueOf(actualChecksum)
+ + " != "
+ + String.valueOf(expectedChecksum));
+ }
+
+ if (isSliced == 1 || isSliced == 2 || isSliced == 3) {
+ if (payload != null) {
+ int newCapacity = payload.length + newPayload.length;
+ newPayload = ByteBuffer.allocate(newCapacity)
+ .put(payload)
+ .put(newPayload)
+ .array();
+ }
+
+ if (isSliced != 3) {
+ // Sliced packet isn't complete yet
+ this.payload = newPayload;
+ return;
+ }
+ }
+
+ this.serviceId = newPayload[0];
+ this.commandId = newPayload[1];
+ this.complete = true;
+
+ if (
+ (serviceId == 0x0a && commandId == 0x05) ||
+ (serviceId == 0x28 && commandId == 0x06) ||
+ (serviceId == 0x2c && commandId == 0x05) ||
+ (serviceId == 0x1c && commandId == 0x05)
+ ) {
+ // TODO: this doesn't seem to be TLV
+ this.payload = newPayload;
+ return;
+ }
+
+ this.tlv = new HuaweiTLV();
+ this.tlv.parse(newPayload, 2, newPayload.length - 2);
+ }
+
+ public HuaweiPacket parse(byte[] data) throws ParseException {
+ this.isEncrypted = false; // Will be changed if decrypt has been performed
+
+ parseData(data);
+ if (!this.complete)
+ return this;
+
+ switch (this.serviceId) {
+ case DeviceConfig.id:
+ switch (this.commandId) {
+ case DeviceConfig.LinkParams.id:
+ return new DeviceConfig.LinkParams.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.SupportedServices.id:
+ return new DeviceConfig.SupportedServices.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.SupportedCommands.id:
+ return new DeviceConfig.SupportedCommands.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.TimeRequest.id:
+ return new DeviceConfig.TimeRequest.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.ProductInfo.id:
+ return new DeviceConfig.ProductInfo.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.BondParams.id:
+ return new DeviceConfig.BondParams.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.PhoneInfo.id:
+ return new DeviceConfig.PhoneInfo.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.Auth.id:
+ return new DeviceConfig.Auth.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.BatteryLevel.id:
+ case DeviceConfig.BatteryLevel.id_change:
+ return new DeviceConfig.BatteryLevel.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.DeviceStatus.id:
+ return new DeviceConfig.DeviceStatus.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.DndLiftWristType.id:
+ return new DeviceConfig.DndLiftWristType.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.HiChain.id:
+ return new DeviceConfig.HiChain.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.PinCode.id:
+ return new DeviceConfig.PinCode.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.ExpandCapability.id:
+ return new DeviceConfig.ExpandCapability.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.ActivityType.id:
+ return new DeviceConfig.ActivityType.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.SettingRelated.id:
+ return new DeviceConfig.SettingRelated.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.SecurityNegotiation.id:
+ return new DeviceConfig.SecurityNegotiation.Response(paramsProvider).fromPacket(this);
+ case DeviceConfig.WearStatus.id:
+ return new DeviceConfig.WearStatus.Response(paramsProvider).fromPacket(this);
+
+ // Camera remote has same ID as DeviceConfig
+ case CameraRemote.CameraRemoteStatus.id:
+ return new CameraRemote.CameraRemoteStatus.Response(paramsProvider).fromPacket(this);
+
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case Notifications.id:
+ switch (this.commandId) {
+ case Notifications.NotificationConstraints.id:
+ return new Notifications.NotificationConstraints.Response(paramsProvider).fromPacket(this);
+ case Notifications.NotificationCapabilities.id:
+ return new Notifications.NotificationCapabilities.Response(paramsProvider).fromPacket(this);
+ default:
+ return this;
+ }
+ case Contacts.id:
+ switch (this.commandId) {
+ case Contacts.ContactsSet.id:
+ return new Contacts.ContactsSet.Response(paramsProvider).fromPacket(this);
+ case Contacts.ContactsCount.id:
+ return new Contacts.ContactsCount.Response(paramsProvider).fromPacket(this);
+ }
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ case Calls.id:
+ if (this.commandId == Calls.AnswerCallResponse.id)
+ return new Calls.AnswerCallResponse(paramsProvider).fromPacket(this);
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ case FitnessData.id:
+ switch (this.commandId) {
+ case FitnessData.FitnessTotals.id:
+ return new FitnessData.FitnessTotals.Response(paramsProvider).fromPacket(this);
+ case FitnessData.MessageCount.stepId:
+ return new FitnessData.MessageCount.Response(paramsProvider).fromPacket(this);
+ case FitnessData.MessageData.stepId:
+ return new FitnessData.MessageData.StepResponse(paramsProvider).fromPacket(this);
+ case FitnessData.MessageCount.sleepId:
+ return new FitnessData.MessageCount.Response(paramsProvider).fromPacket(this);
+ case FitnessData.MessageData.sleepId:
+ return new FitnessData.MessageData.SleepResponse(paramsProvider).fromPacket(this);
+ case FitnessData.RunPaceConfig.id:
+ return new FitnessData.RunPaceConfig.Response(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case Alarms.id:
+ switch (this.commandId) {
+ case Alarms.EventAlarmsList.id:
+ return new Alarms.EventAlarmsList.Response(paramsProvider).fromPacket(this);
+ case Alarms.SmartAlarmList.id:
+ return new Alarms.SmartAlarmList.Response(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case FileDownloadService0A.id:
+ switch (this.commandId) {
+ case FileDownloadService0A.FileDownloadInit.id:
+ return new FileDownloadService0A.FileDownloadInit.Response(paramsProvider).fromPacket(this);
+ case FileDownloadService0A.FileParameters.id:
+ return new FileDownloadService0A.FileParameters.Response(paramsProvider).fromPacket(this);
+ case FileDownloadService0A.FileInfo.id:
+ return new FileDownloadService0A.FileInfo.Response(paramsProvider).fromPacket(this);
+ case FileDownloadService0A.RequestBlock.id:
+ return new FileDownloadService0A.RequestBlock.Response(paramsProvider).fromPacket(this);
+ case FileDownloadService0A.BlockResponse.id:
+ return new FileDownloadService0A.BlockResponse(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt();
+ return this;
+ }
+ case FindPhone.id:
+ if (this.commandId == FindPhone.Response.id)
+ return new FindPhone.Response(paramsProvider).fromPacket(this);
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ case Weather.id:
+ switch (this.commandId) {
+ case Weather.WeatherSupport.id:
+ return new Weather.WeatherSupport.Response(paramsProvider).fromPacket(this);
+ case Weather.WeatherExtendedSupport.id:
+ return new Weather.WeatherExtendedSupport.Response(paramsProvider).fromPacket(this);
+ case Weather.WeatherStart.id:
+ return new Weather.WeatherStart.Response(paramsProvider).fromPacket(this);
+ case Weather.WeatherSunMoonSupport.id:
+ return new Weather.WeatherSunMoonSupport.Response(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case Workout.id:
+ switch (this.commandId) {
+ case Workout.WorkoutCount.id:
+ return new Workout.WorkoutCount.Response(paramsProvider).fromPacket(this);
+ case Workout.WorkoutTotals.id:
+ return new Workout.WorkoutTotals.Response(paramsProvider).fromPacket(this);
+ case Workout.WorkoutData.id:
+ return new Workout.WorkoutData.Response(paramsProvider).fromPacket(this);
+ case Workout.WorkoutPace.id:
+ return new Workout.WorkoutPace.Response(paramsProvider).fromPacket(this);
+ case Workout.WorkoutSwimSegments.id:
+ return new Workout.WorkoutSwimSegments.Response(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case GpsAndTime.id:
+ switch (this.commandId) {
+ case GpsAndTime.GpsParameters.id:
+ return new GpsAndTime.GpsParameters.Response(paramsProvider).fromPacket(this);
+ case GpsAndTime.GpsStatus.id:
+ return new GpsAndTime.GpsStatus.Response(paramsProvider).fromPacket(this);
+ case GpsAndTime.GpsData.id:
+ return new GpsAndTime.GpsData.Response(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case MusicControl.id:
+ switch (this.commandId) {
+ case MusicControl.MusicStatusResponse.id:
+ return new MusicControl.MusicStatusResponse(paramsProvider).fromPacket(this);
+ case MusicControl.MusicInfo.id:
+ return new MusicControl.MusicInfo.Response(paramsProvider).fromPacket(this);
+ case MusicControl.Control.id:
+ return new MusicControl.Control.Response(paramsProvider).fromPacket(this);
+ case MusicControl.MusicInfoParams.id:
+ return new MusicControl.MusicInfoParams.Response(paramsProvider).fromPacket(this);
+ case MusicControl.MusicList.id:
+ return new MusicControl.MusicList.Response(paramsProvider).fromPacket(this);
+ case MusicControl.MusicPlaylists.id:
+ return new MusicControl.MusicPlaylists.Response(paramsProvider).fromPacket(this);
+ case MusicControl.MusicPlaylistMusics.id:
+ return new MusicControl.MusicPlaylistMusics.Response(paramsProvider).fromPacket(this);
+ case MusicControl.MusicOperation.id:
+ return new MusicControl.MusicOperation.Response(paramsProvider).fromPacket(this);
+ case MusicControl.UploadMusicFileInfo.id:
+ return new MusicControl.UploadMusicFileInfo.UploadMusicFileInfoRequest(paramsProvider).fromPacket(this);
+ case MusicControl.ExtendedMusicInfoParams.id:
+ return new MusicControl.ExtendedMusicInfoParams.Response(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case AccountRelated.id:
+ switch(this.commandId) {
+ case AccountRelated.SendAccountToDevice.id:
+ return new AccountRelated.SendAccountToDevice.Response(paramsProvider).fromPacket(this);
+ case AccountRelated.SendExtendedAccountToDevice.id:
+ return new AccountRelated.SendExtendedAccountToDevice.Response(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case FileUpload.id:
+ switch(this.commandId) {
+ case FileUpload.FileInfoSend.id:
+ return new FileUpload.FileInfoSend.Response(paramsProvider).fromPacket(this);
+ case FileUpload.FileHashSend.id:
+ return new FileUpload.FileHashSend.Response(paramsProvider).fromPacket(this);
+ case FileUpload.FileUploadConsultAck.id:
+ return new FileUpload.FileUploadConsultAck.Response(paramsProvider).fromPacket(this);
+ case FileUpload.FileNextChunkParams.id:
+ return new FileUpload.FileNextChunkParams(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case Watchface.id:
+ switch (this.commandId) {
+ case Watchface.WatchfaceParams.id:
+ return new Watchface.WatchfaceParams.Response(paramsProvider).fromPacket(this);
+ case Watchface.DeviceWatchInfo.id:
+ return new Watchface.DeviceWatchInfo.Response(paramsProvider).fromPacket(this);
+ case Watchface.WatchfaceConfirm.id:
+ return new Watchface.WatchfaceConfirm.Response(paramsProvider).fromPacket(this);
+ case Watchface.WatchfaceNameInfo.id:
+ return new Watchface.WatchfaceNameInfo.Response(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case Earphones.id:
+ switch (this.commandId) {
+ case Earphones.InEarStateResponse.id:
+ return new Earphones.InEarStateResponse(paramsProvider).fromPacket(this);
+ case Earphones.GetAudioModeRequest.id:
+ return new Earphones.GetAudioModeRequest.Response(paramsProvider).fromPacket(this);
+ }
+ case FileDownloadService2C.id:
+ switch (this.commandId) {
+ case FileDownloadService2C.FileDownloadInit.id:
+ return new FileDownloadService2C.FileDownloadInit.Response(paramsProvider).fromPacket(this);
+ case FileDownloadService2C.FileInfo.id:
+ return new FileDownloadService2C.FileInfo.Response(paramsProvider).fromPacket(this);
+ case FileDownloadService2C.BlockResponse.id:
+ return new FileDownloadService2C.BlockResponse(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case App.id:
+ switch (this.commandId) {
+ case App.AppNames.id:
+ return new App.AppNames.Response(paramsProvider).fromPacket(this);
+ case App.AppInfoParams.id:
+ return new App.AppInfoParams.Response(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case P2P.id:
+ switch (this.commandId) {
+ case P2P.P2PCommand.id:
+ return new P2P.P2PCommand.Response(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case Ephemeris.id:
+ switch (this.commandId) {
+ case Ephemeris.OperatorData.id:
+ return new Ephemeris.OperatorData.OperatorIncomingRequest(paramsProvider).fromPacket(this);
+ case Ephemeris.ParameterConsult.id:
+ return new Ephemeris.ParameterConsult.Response(paramsProvider).fromPacket(this);
+ case Ephemeris.FileStatus.id:
+ return new Ephemeris.FileStatus.Response(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ case EphemerisFileUpload.id:
+ switch (this.commandId) {
+ case EphemerisFileUpload.FileList.id:
+ return new EphemerisFileUpload.FileList.FileListIncomingRequest(paramsProvider).fromPacket(this);
+ case EphemerisFileUpload.FileConsult.id:
+ return new EphemerisFileUpload.FileConsult.FileConsultIncomingRequest(paramsProvider).fromPacket(this);
+ case EphemerisFileUpload.QuerySingleFileInfo.id:
+ return new EphemerisFileUpload.QuerySingleFileInfo.QuerySingleFileInfoIncomingRequest(paramsProvider).fromPacket(this);
+ case EphemerisFileUpload.DataRequest.id:
+ return new EphemerisFileUpload.DataRequest.DataRequestIncomingRequest(paramsProvider).fromPacket(this);
+ case EphemerisFileUpload.UploadData.id:
+ return new EphemerisFileUpload.UploadData.UploadDataResponse(paramsProvider).fromPacket(this);
+ case EphemerisFileUpload.UploadDone.id:
+ return new EphemerisFileUpload.UploadDone.UploadDoneIncomingRequest(paramsProvider).fromPacket(this);
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ default:
+ this.isEncrypted = this.attemptDecrypt(); // Helps with debugging
+ return this;
+ }
+ }
+
+ public HuaweiPacket parseOutgoing(byte[] data) throws ParseException {
+ this.isEncrypted = false; // Will be changed if decrypt has been performed
+
+ parseData(data);
+ if (!this.complete)
+ return this;
+
+ // TODO: complete
+
+ switch (this.serviceId) {
+ case DeviceConfig.id:
+ switch (this.commandId) {
+ case DeviceConfig.SupportedServices.id:
+ return new DeviceConfig.SupportedServices.OutgoingRequest(paramsProvider).fromPacket(this);
+ case DeviceConfig.DateFormat.id:
+ return new DeviceConfig.DateFormat.OutgoingRequest(paramsProvider).fromPacket(this);
+ case DeviceConfig.Bond.id:
+ return new DeviceConfig.Bond.OutgoingRequest(paramsProvider).fromPacket(this);
+ case DeviceConfig.HiChain.id:
+ return new DeviceConfig.HiChain.OutgoingRequest(paramsProvider).fromPacket(this);
+ case DeviceConfig.Auth.id:
+ return new DeviceConfig.Auth.OutgoingRequest(paramsProvider).fromPacket(this);
+ default:
+ return this;
+ }
+ case Weather.id:
+ switch (this.commandId) {
+ case Weather.WeatherForecastData.id:
+ return new Weather.WeatherForecastData.OutgoingRequest(paramsProvider).fromPacket(this);
+ default:
+ return this;
+ }
+ default:
+ return this;
+ }
+ }
+
+ private List<byte[]> serializeSliced(byte[] serializedTLV) {
+ List<byte[]> retv = new ArrayList<>();
+ int headerLength = 5; // Magic + (short)(bodyLength + 1) + 0x00 + extra slice info
+ int bodyHeaderLength = 2; // sID + cID
+ int footerLength = 2; //CRC16
+ int maxBodySize = paramsProvider.getSliceSize() - headerLength - footerLength;
+ int packetCount = (int) Math.ceil(((double) serializedTLV.length + (double) bodyHeaderLength) / (double) maxBodySize);
+
+ if (packetCount == 1)
+ return serializeUnsliced(serializedTLV);
+
+ ByteBuffer buffer = ByteBuffer.wrap(serializedTLV);
+ byte slice = 0x01;
+ byte flag = 0x00;
+ for (int i = 0; i < packetCount; i++) {
+ short packetSize = (short) Math.min(paramsProvider.getSliceSize(), buffer.remaining() + headerLength + footerLength);
+
+ ByteBuffer packet = ByteBuffer.allocate(packetSize);
+
+ short contentSize = (short) (packetSize - headerLength - footerLength);
+ int start = packet.position();
+
+ packet.put((byte) 0x5a); // Magic byte
+ packet.putShort((short) (packetSize - headerLength)); // Length
+
+ if (i == packetCount - 1)
+ slice = 0x03;
+
+ packet.put(slice); // Slice
+ packet.put(flag); // Flag
+ flag += 1;
+
+ if (slice == 0x01) {
+ packet.put(this.serviceId); // Service ID
+ packet.put(this.commandId); // Command ID
+ slice = 0x02;
+ contentSize -= 2; // To prevent taking too much data
+ }
+
+ byte[] packetContent = new byte[contentSize];
+ buffer.get(packetContent);
+ packet.put(packetContent); // Packet data
+
+ int length = packet.position() - start;
+ if (length != packetSize - footerLength) {
+ // TODO: exception?
+ LOG.error(String.format(GBApplication.getLanguage(), "Packet lengths don't match! %d != %d", length, packetSize + headerLength));
+ }
+
+ byte[] complete = new byte[length];
+ packet.position(start);
+ packet.get(complete, 0, length);
+ int crc16 = CheckSums.getCRC16(complete, 0x0000);
+
+ packet.putShort((short) crc16); // CRC16
+
+ retv.add(packet.array());
+ }
+ return retv;
+ }
+
+ private List<byte[]> serializeUnsliced(byte[] serializedTLV) {
+ List<byte[]> retv = new ArrayList<>();
+ int headerLength = 4; // Magic + (short)(bodyLength + 1) + 0x00
+ int bodyHeaderLength = 2; // sID + cID
+ int footerLength = 2; //CRC16
+ int bodyLength = bodyHeaderLength + serializedTLV.length;
+ ByteBuffer buffer = ByteBuffer.allocate(headerLength + bodyLength);
+ buffer.put((byte) 0x5A);
+ buffer.putShort((short)(bodyLength + 1));
+ buffer.put((byte) 0x00);
+ buffer.put(this.serviceId);
+ buffer.put(this.commandId);
+ buffer.put(serializedTLV);
+ int crc16 = CheckSums.getCRC16(buffer.array(), 0x0000);
+ ByteBuffer finalBuffer = ByteBuffer.allocate(buffer.capacity() + footerLength);
+ finalBuffer.put(buffer.array());
+ finalBuffer.putShort((short)crc16);
+ retv.add(finalBuffer.array());
+ return retv;
+ }
+
+ public List<byte[]> serializeFileChunk(byte[] fileChunk, int uploadPosition, int unitSize, byte fileId, boolean isEncrypted) throws SerializeException {
+ List<byte[]> retv = new ArrayList<>();
+ final int subHeaderLength = 6;
+ final int packageHeaderAndFooterLength = 6;
+
+ int packetCount = (int) Math.ceil(((double) fileChunk.length) / (double) unitSize);
+
+ ByteBuffer buffer = ByteBuffer.wrap(fileChunk);
+
+ int sliceStart = uploadPosition;
+
+ for (int i = 0; i < packetCount; i++) {
+
+ int contentSize = Math.min(unitSize, buffer.remaining());
+
+ ByteBuffer payload = ByteBuffer.allocate(contentSize + subHeaderLength);
+ payload.put(fileId);
+ payload.put((byte)i);
+ payload.putInt(sliceStart);
+
+ byte[] packetContent = new byte[contentSize];
+ buffer.get(packetContent);
+ payload.put(packetContent);
+
+
+ byte[] new_payload = null;
+ if(isEncrypted) {
+ try {
+ HuaweiTLV encryptedTlv = HuaweiTLV.encryptRaw(this.paramsProvider, payload.array());
+ new_payload = encryptedTlv.serialize();
+ } catch (HuaweiCrypto.CryptoException e) {
+ throw new HuaweiPacket.SerializeException("Error to encrypt TLV");
+ }
+ } else {
+ new_payload = payload.array();
+ }
+
+ if (new_payload == null) {
+ throw new HuaweiPacket.SerializeException("new payload is null");
+ }
+
+ if ((new_payload.length + packageHeaderAndFooterLength) > paramsProvider.getSliceSize()) {
+ retv.addAll(serializeSliced(new_payload));
+ } else {
+ retv.addAll(serializeUnsliced(new_payload));
+ }
+
+ sliceStart += contentSize;
+
+ }
+ return retv;
+ }
+
+ public List<byte[]> serializeFileChunk1c(byte[] fileChunk, short transferSize, int packetCount) throws SerializeException {
+ List<byte[]> retv = new ArrayList<>();
+
+ final int subHeaderLength = 1;
+ final int packageHeaderAndFooterLength = 6;
+
+ ByteBuffer buffer = ByteBuffer.wrap(fileChunk);
+
+ for (int i = 0; i < packetCount; i++) {
+
+ short contentSize = (short) Math.min(transferSize, buffer.remaining());
+
+ ByteBuffer payload = ByteBuffer.allocate(contentSize + subHeaderLength);
+ payload.put((byte)i);
+
+ byte[] packetContent = new byte[contentSize];
+ buffer.get(packetContent);
+ payload.put(packetContent);
+
+ byte[] new_payload = payload.array();
+
+ if ((new_payload.length + packageHeaderAndFooterLength) > paramsProvider.getSliceSize()) {
+ retv.addAll(serializeSliced(new_payload));
+ } else {
+ retv.addAll(serializeUnsliced(new_payload));
+ }
+ }
+ return retv;
+ }
+
+
+
+ public List<byte[]> serialize() throws CryptoException {
+ // TODO: necessary for this to work:
+ // - serviceId
+ // - commandId
+ // - tlv
+ // TODO: maybe use the complete flag to know if it can be serialized?
+
+ HuaweiTLV serializableTlv;
+ if (this.isEncrypted && this.paramsProvider.areTransactionsCrypted()) {
+ try {
+ serializableTlv = this.tlv.encrypt(paramsProvider);
+ } catch (HuaweiCrypto.CryptoException e) {
+ throw new CryptoException("Encrypt exception", e);
+ }
+ } else {
+ serializableTlv = this.tlv;
+ }
+
+ byte[] serializedTLV = serializableTlv.serialize();
+ List<byte[]> retv;
+ if (isSliced) {
+ retv = serializeSliced(serializedTLV);
+ } else {
+ retv = serializeUnsliced(serializedTLV);
+ }
+ return retv;
+ }
+
+ public HuaweiTLV getTlv() {
+ return this.tlv;
+ }
+
+ public void setTlv(HuaweiTLV tlv) {
+ this.tlv = tlv;
+ }
+
+ public void setEncryption(boolean b) {
+ this.isEncrypted = b;
+ }
+
+ public void setSliced(boolean b) {
+ this.isSliced = b;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ HuaweiPacket that = (HuaweiPacket) o;
+
+ if (serviceId != that.serviceId) return false;
+ if (commandId != that.commandId) return false;
+ if (complete != that.complete) return false;
+ if (isEncrypted != that.isEncrypted) return false;
+ return Objects.equals(tlv, that.tlv);
+ }
+
+ @Override
+ public String toString() {
+ return "HuaweiPacket{" +
+ "paramsProvider=" + paramsProvider +
+ ", serviceId=" + serviceId +
+ ", commandId=" + commandId +
+ ", tlv=" + tlv +
+ ", partialPacket=" + Arrays.toString(partialPacket) +
+ ", payload=" + Arrays.toString(payload) +
+ ", complete=" + complete +
+ ", isEncrypted=" + isEncrypted +
+ ", isSliced=" + isSliced +
+ '}';
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiReportThreshold.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiReportThreshold.java
new file mode 100644
index 0000000..b3863b9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiReportThreshold.java
@@ -0,0 +1,41 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+
+public class HuaweiReportThreshold {
+ private int dataType;
+ private int valueType;
+ private int value;
+ private int action;
+
+ public HuaweiReportThreshold(int dataType, int valueType, int value, int action) {
+ this.dataType = dataType;
+ this.valueType = valueType;
+ this.value = value;
+ this.action = action;
+ }
+
+ public byte[] getBytes() {
+ ByteBuffer buffer = ByteBuffer.allocate(5);
+ buffer.put((byte) dataType);
+ buffer.put((byte) valueType);
+ buffer.putShort((short) value);
+ buffer.put((byte) action);
+ return buffer.array();
+ }
+
+ public static List<HuaweiReportThreshold> getReportThresholds() {
+ List<HuaweiReportThreshold> thresholds = new ArrayList<>();
+ thresholds.add(new HuaweiReportThreshold(1,3, 500, 2));
+ thresholds.add(new HuaweiReportThreshold(3,3, 100, 2));
+
+ int current_hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
+ int value = (current_hour < 6)?21600:3600;
+ thresholds.add(new HuaweiReportThreshold(4,3, value, 2));
+
+ return thresholds;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiRunPaceConfig.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiRunPaceConfig.java
new file mode 100644
index 0000000..a7402fd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiRunPaceConfig.java
@@ -0,0 +1,62 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+// TODO: make this configurable
+// NOTE: algorithms used in this class are generic. So this data can be used with other devices.
+// We can move this class to global scope.
+public class HuaweiRunPaceConfig {
+
+ private int zone5HIITRunMax = 300;
+ private int zone5HIITRunMin = 330;
+ private int zone4AnaerobicMin = 360;
+ private int zone3LactateThresholdMin = 390;
+ private int zone2MarathonMin = 420;
+ private int zone1JogMin = 450;
+
+ public int getZone5HIITRunMax() {
+ return zone5HIITRunMax;
+ }
+
+ public void setZone5HIITRunMax(int zone5HIITRunMax) {
+ this.zone5HIITRunMax = zone5HIITRunMax;
+ }
+
+ public int getZone5HIITRunMin() {
+ return zone5HIITRunMin;
+ }
+
+ public void setZone5HIITRunMin(int zone5HIITRunMin) {
+ this.zone5HIITRunMin = zone5HIITRunMin;
+ }
+
+ public int getZone4AnaerobicMin() {
+ return zone4AnaerobicMin;
+ }
+
+ public void setZone4AnaerobicMin(int zone4AnaerobicMin) {
+ this.zone4AnaerobicMin = zone4AnaerobicMin;
+ }
+
+ public int getZone3LactateThresholdMin() {
+ return zone3LactateThresholdMin;
+ }
+
+ public void setZone3LactateThresholdMin(int zone3LactateThresholdMin) {
+ this.zone3LactateThresholdMin = zone3LactateThresholdMin;
+ }
+
+ public int getZone2MarathonMin() {
+ return zone2MarathonMin;
+ }
+
+ public void setZone2MarathonMin(int zone2MarathonMin) {
+ this.zone2MarathonMin = zone2MarathonMin;
+ }
+
+ public int getZone1JogMin() {
+ return zone1JogMin;
+ }
+
+ public void setZone1JogMin(int zone1JogMin) {
+ this.zone1JogMin = zone1JogMin;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSampleProvider.java
new file mode 100644
index 0000000..c1e8eaf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSampleProvider.java
@@ -0,0 +1,540 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutDataSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutDataSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+
+public class HuaweiSampleProvider extends AbstractSampleProvider<HuaweiActivitySample> {
+ /*
+ * We save all data by saving a marker at the begin and end. We do not actively use these for
+ * showing the data at the moment, but the samples are still saved as such, to keep the table
+ * entries consistent.
+ * Meaning of fields that are not self-explanatory:
+ * - `otherTimestamp`
+ * The timestamp of the other marker, if it's larger this is the begin, otherwise the end
+ * - `source`
+ * The source of the data, which Huawei Band message the data came from
+ * 0x0d for sleep data from activity, 0x0a for TruSleep data
+ */
+
+ private static class RawTypes {
+ public static final int NOT_MEASURED = -1;
+
+ public static final int UNKNOWN = 1;
+
+ public static final int DEEP_SLEEP = 0x07;
+ public static final int LIGHT_SLEEP = 0x06;
+ }
+
+ public HuaweiSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ switch (rawType) {
+ case RawTypes.DEEP_SLEEP:
+ return ActivityKind.DEEP_SLEEP;
+ case RawTypes.LIGHT_SLEEP:
+ return ActivityKind.LIGHT_SLEEP;
+ default:
+ return ActivityKind.UNKNOWN;
+ }
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ switch (activityKind) {
+ case DEEP_SLEEP:
+ return RawTypes.DEEP_SLEEP;
+ case LIGHT_SLEEP:
+ return RawTypes.LIGHT_SLEEP;
+ default:
+ return RawTypes.NOT_MEASURED;
+ }
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity;
+ }
+
+ @Override
+ public AbstractDao<HuaweiActivitySample, ?> getSampleDao() {
+ return getSession().getHuaweiActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return HuaweiActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HuaweiActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HuaweiActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public HuaweiActivitySample createActivitySample() {
+ return new HuaweiActivitySample();
+ }
+
+ private int getLastFetchTimestamp(QueryBuilder<HuaweiActivitySample> qb) {
+ Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null)
+ return 0;
+ Property deviceProperty = HuaweiActivitySampleDao.Properties.DeviceId;
+ Property timestampProperty = HuaweiActivitySampleDao.Properties.Timestamp;
+
+ qb.where(deviceProperty.eq(dbDevice.getId()))
+ .orderDesc(timestampProperty)
+ .limit(1);
+
+ List<HuaweiActivitySample> samples = qb.build().list();
+ if (samples.isEmpty())
+ return 0;
+
+ HuaweiActivitySample sample = samples.get(0);
+ return sample.getTimestamp();
+ }
+
+ /**
+ * Gets last timestamp where the sleep data has been fully synchronized
+ * @return Last fully synchronized timestamp for sleep data
+ */
+ public int getLastSleepFetchTimestamp() {
+ QueryBuilder<HuaweiActivitySample> qb = getSampleDao().queryBuilder();
+ Property sourceProperty = HuaweiActivitySampleDao.Properties.Source;
+ Property activityTypeProperty = HuaweiActivitySampleDao.Properties.RawKind;
+
+ qb.where(
+ qb.or(
+ sourceProperty.eq(0x0d),
+ sourceProperty.eq(0x0a)
+ ),
+ qb.or(
+ activityTypeProperty.eq(RawTypes.LIGHT_SLEEP),
+ activityTypeProperty.eq(RawTypes.DEEP_SLEEP)
+ )
+ );
+
+ return getLastFetchTimestamp(qb);
+ }
+
+ /**
+ * Gets last timestamp where the step data has been fully synchronized
+ * @return Last fully synchronized timestamp for step data
+ */
+ public int getLastStepFetchTimestamp() {
+ QueryBuilder<HuaweiActivitySample> qb = getSampleDao().queryBuilder();
+ Property sourceProperty = HuaweiActivitySampleDao.Properties.Source;
+
+ qb.where(sourceProperty.eq(0x0b));
+
+ return getLastFetchTimestamp(qb);
+ }
+
+ /**
+ * Makes a copy of a sample
+ * @param sample The sample to copy
+ * @return The copy of the sample
+ */
+ private HuaweiActivitySample copySample(HuaweiActivitySample sample) {
+ HuaweiActivitySample sampleCopy = new HuaweiActivitySample(
+ sample.getTimestamp(),
+ sample.getDeviceId(),
+ sample.getUserId(),
+ sample.getOtherTimestamp(),
+ sample.getSource(),
+ sample.getRawKind(),
+ sample.getRawIntensity(),
+ sample.getSteps(),
+ sample.getCalories(),
+ sample.getDistance(),
+ sample.getSpo(),
+ sample.getHeartRate()
+ );
+ sampleCopy.setProvider(sample.getProvider());
+ return sampleCopy;
+ }
+
+ @Override
+ public void addGBActivitySample(HuaweiActivitySample activitySample) {
+ HuaweiActivitySample start = copySample(activitySample);
+ HuaweiActivitySample end = copySample(activitySample);
+ end.setTimestamp(start.getOtherTimestamp());
+ end.setSteps(ActivitySample.NOT_MEASURED);
+ end.setCalories(ActivitySample.NOT_MEASURED);
+ end.setDistance(ActivitySample.NOT_MEASURED);
+ end.setSpo(ActivitySample.NOT_MEASURED);
+ end.setHeartRate(ActivitySample.NOT_MEASURED);
+ end.setOtherTimestamp(start.getTimestamp());
+
+ getSampleDao().insertOrReplace(start);
+ getSampleDao().insertOrReplace(end);
+ }
+
+ @Override
+ public void addGBActivitySamples(HuaweiActivitySample[] activitySamples) {
+ List<HuaweiActivitySample> newSamples = new ArrayList<>();
+ for (HuaweiActivitySample sample : activitySamples) {
+ HuaweiActivitySample start = copySample(sample);
+ HuaweiActivitySample end = copySample(sample);
+ end.setTimestamp(start.getOtherTimestamp());
+ end.setSteps(ActivitySample.NOT_MEASURED);
+ end.setCalories(ActivitySample.NOT_MEASURED);
+ end.setDistance(ActivitySample.NOT_MEASURED);
+ end.setSpo(ActivitySample.NOT_MEASURED);
+ end.setHeartRate(ActivitySample.NOT_MEASURED);
+ end.setOtherTimestamp(start.getTimestamp());
+
+ newSamples.add(start);
+ newSamples.add(end);
+ }
+ getSampleDao().insertOrReplaceInTx(newSamples);
+ }
+
+ /**
+ * Gets the activity samples, ordered by timestamp
+ * @param timestampFrom Start timestamp
+ * @param timestampTo End timestamp
+ * @return List of activities between the timestamps, ordered by timestamp
+ */
+ private List<HuaweiActivitySample> getRawOrderedActivitySamples(int timestampFrom, int timestampTo) {
+ QueryBuilder<HuaweiActivitySample> qb = getSampleDao().queryBuilder();
+ Property timestampProperty = getTimestampSampleProperty();
+ Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no samples
+ return Collections.emptyList();
+ }
+ Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(deviceProperty.eq(dbDevice.getId()), timestampProperty.ge(timestampFrom))
+ .where(timestampProperty.le(timestampTo))
+ .orderAsc(timestampProperty);
+ List<HuaweiActivitySample> samples = qb.build().list();
+ for (HuaweiActivitySample sample : samples) {
+ sample.setProvider(this);
+ }
+ detachFromSession();
+ return samples;
+ }
+
+ private List<HuaweiWorkoutDataSample> getRawOrderedWorkoutSamplesWithHeartRate(int timestampFrom, int timestampTo) {
+ Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null)
+ return Collections.emptyList();
+
+ QueryBuilder<HuaweiWorkoutDataSample> qb = getSession().getHuaweiWorkoutDataSampleDao().queryBuilder();
+ Property timestampProperty = HuaweiWorkoutDataSampleDao.Properties.Timestamp;
+ Property heartRateProperty = HuaweiWorkoutDataSampleDao.Properties.HeartRate;
+ Property deviceProperty = HuaweiWorkoutSummarySampleDao.Properties.DeviceId;
+ qb.join(HuaweiWorkoutDataSampleDao.Properties.WorkoutId, HuaweiWorkoutSummarySample.class, HuaweiWorkoutSummarySampleDao.Properties.WorkoutId)
+ .where(deviceProperty.eq(dbDevice.getId()));
+ qb.where(
+ timestampProperty.ge(timestampFrom),
+ timestampProperty.le(timestampTo),
+ heartRateProperty.notEq(ActivitySample.NOT_MEASURED)
+ ).orderAsc(timestampProperty);
+ List<HuaweiWorkoutDataSample> samples = qb.build().list();
+ getSession().getHuaweiWorkoutSummarySampleDao().detachAll();
+ return samples;
+ }
+
+ /*
+ * This takes the following three steps:
+ * - Generate a sample every minute
+ * - Add the activity sample data to the generated samples
+ * - Add the workout data to the generated samples
+ */
+ @Override
+ protected List<HuaweiActivitySample> getGBActivitySamples(int timestamp_from, int timestamp_to) {
+ List<HuaweiActivitySample> processedSamples = new ArrayList<>();
+
+ for (int timestamp = timestamp_from; timestamp <= timestamp_to; timestamp += 60) {
+ processedSamples.add(createDummySample(timestamp));
+ }
+
+ overlayActivitySamples(processedSamples, timestamp_from, timestamp_to);
+ overlayWorkoutSamples(processedSamples, timestamp_from, timestamp_to);
+
+ return processedSamples;
+ }
+
+ @Override
+ protected List<HuaweiActivitySample> getGBActivitySamplesHighRes(int timestamp_from, int timestamp_to) {
+ List<HuaweiActivitySample> processedSamples = getRawOrderedActivitySamples(timestamp_from, timestamp_to);
+ addWorkoutSamples(processedSamples, timestamp_from, timestamp_to);
+ // Filter out the end markers before returning
+ return processedSamples.stream().filter(sample -> sample.getTimestamp() <= sample.getOtherTimestamp()).collect(Collectors.toList());
+ }
+
+ @Override
+ public boolean hasHighResData() {
+ return true;
+ }
+
+ private HuaweiActivitySample createDummySample(int timestamp) {
+ HuaweiActivitySample activitySample = new HuaweiActivitySample(
+ timestamp,
+ -1,
+ -1,
+ timestamp + 60, // Make sure the duration is 60
+ (byte) 0x00,
+ ActivitySample.NOT_MEASURED,
+ 0,
+ ActivitySample.NOT_MEASURED,
+ ActivitySample.NOT_MEASURED,
+ ActivitySample.NOT_MEASURED,
+ ActivitySample.NOT_MEASURED,
+ ActivitySample.NOT_MEASURED);
+ activitySample.setProvider(this);
+ return activitySample;
+ }
+
+ /*
+ * For every activity sample, it adds the data into the following processed sample.
+ * If there are multiple activity samples, the steps, calories, and distance is added together.
+ * For the SpO and HR only the last value is used.
+ */
+ private void overlayActivitySamples(List<HuaweiActivitySample> processedSamples, int timestamp_from, int timestamp_to) {
+ List<HuaweiActivitySample> activitySamples = getRawOrderedActivitySamples(timestamp_from, timestamp_to);
+
+ int currentIndex = 0;
+
+ boolean hasData = false;
+
+ int stepCount = ActivitySample.NOT_MEASURED;
+ int calorieCount = ActivitySample.NOT_MEASURED;
+ int distanceCount = ActivitySample.NOT_MEASURED;
+
+ int lastSpo = ActivitySample.NOT_MEASURED;
+ int lastHr = ActivitySample.NOT_MEASURED;
+
+ int stateModifier = ActivitySample.NOT_MEASURED;
+
+ for (HuaweiActivitySample activitySample : activitySamples) {
+ // Ignore the end markers
+ if (activitySample.getTimestamp() > activitySample.getOtherTimestamp())
+ continue;
+
+ // Skip the processed samples that are before this activity sample
+ while (activitySample.getTimestamp() > processedSamples.get(currentIndex).getTimestamp()) {
+ // Add data to current index sample
+ if (hasData || stateModifier != ActivitySample.NOT_MEASURED)
+ processedSamples.get(currentIndex).setRawIntensity(1);
+ processedSamples.get(currentIndex).setSteps(stepCount);
+ processedSamples.get(currentIndex).setCalories(calorieCount);
+ processedSamples.get(currentIndex).setDistance(distanceCount);
+ processedSamples.get(currentIndex).setSpo(lastSpo);
+ processedSamples.get(currentIndex).setHeartRate(lastHr);
+ processedSamples.get(currentIndex).setRawKind(stateModifier);
+
+ // Reset counters
+ hasData = false;
+ stepCount = ActivitySample.NOT_MEASURED;
+ calorieCount = ActivitySample.NOT_MEASURED;
+ distanceCount = ActivitySample.NOT_MEASURED;
+ lastSpo = ActivitySample.NOT_MEASURED;
+ lastHr = ActivitySample.NOT_MEASURED;
+
+ currentIndex += 1;
+ if (currentIndex >= processedSamples.size())
+ return; // We cannot add the data to any samples, so we might as well return
+ }
+
+ // Update data
+ if (activitySample.getSteps() != ActivitySample.NOT_MEASURED) {
+ if (stepCount == ActivitySample.NOT_MEASURED)
+ stepCount = 0;
+ stepCount += activitySample.getSteps();
+ hasData = true;
+ }
+ if (activitySample.getCalories() != ActivitySample.NOT_MEASURED) {
+ if (calorieCount == ActivitySample.NOT_MEASURED)
+ calorieCount = 0;
+ calorieCount += activitySample.getCalories();
+ hasData = true;
+ }
+ if (activitySample.getDistance() != ActivitySample.NOT_MEASURED) {
+ if (distanceCount == ActivitySample.NOT_MEASURED)
+ distanceCount = 0;
+ distanceCount += activitySample.getDistance();
+ hasData = true;
+ }
+ if (activitySample.getSpo() != ActivitySample.NOT_MEASURED) {
+ lastSpo = activitySample.getSpo();
+ hasData = true;
+ }
+ if (activitySample.getHeartRate() != ActivitySample.NOT_MEASURED) {
+ lastHr = activitySample.getHeartRate();
+ hasData = true;
+ }
+ if (activitySample.getRawKind() != ActivitySample.NOT_MEASURED) {
+ if (activitySample.getTimestamp() < activitySample.getOtherTimestamp()) {
+ // Starting of modifier
+ stateModifier = activitySample.getRawKind();
+ } else {
+ // End of modifier, remove it if it was for the same state
+ if (activitySample.getRawKind() == stateModifier)
+ stateModifier = ActivitySample.NOT_MEASURED;
+ }
+ }
+ }
+
+ // If there is still data, it has to be part of the next index of processed samples
+ currentIndex += 1;
+ if (currentIndex >= processedSamples.size())
+ return;
+ if (hasData || stateModifier != ActivitySample.NOT_MEASURED)
+ processedSamples.get(currentIndex).setRawIntensity(10);
+ processedSamples.get(currentIndex).setSteps(stepCount);
+ processedSamples.get(currentIndex).setCalories(calorieCount);
+ processedSamples.get(currentIndex).setDistance(distanceCount);
+ processedSamples.get(currentIndex).setSpo(lastSpo);
+ processedSamples.get(currentIndex).setHeartRate(lastHr);
+ processedSamples.get(currentIndex).setRawKind(stateModifier);
+ }
+
+ /*
+ * For every workout sample, it adds the data into the following processed sample.
+ * It also detects if it is still in the same workout, and resets the HR and intensity for the
+ * samples in between, see #4126 for the reasoning.
+ * NOTE: Huawei devices tend to generate a lot more data - mine up to every 5 seconds. Most of
+ * this is lost in the conversion to data by the minute. It only shows the most recent value.
+ */
+ private void overlayWorkoutSamples(List<HuaweiActivitySample> processedSamples, int timestamp_from, int timestamp_to) {
+ int currentIndex = 0;
+
+ int lastHr = ActivitySample.NOT_MEASURED;
+
+ List<HuaweiWorkoutDataSample> workoutSamples = getRawOrderedWorkoutSamplesWithHeartRate(timestamp_from, timestamp_to);
+
+ for (int i = 0; i < workoutSamples.size(); i++) {
+ // Look behind to see if this is still the same workout
+ boolean inWorkout = i != 0 && workoutSamples.get(i).getWorkoutId() == workoutSamples.get(i - 1).getWorkoutId();
+
+ // Skip the processed sample that are before this workout sample
+ while (workoutSamples.get(i).getTimestamp() > processedSamples.get(currentIndex).getTimestamp()) {
+ if (inWorkout) {
+ processedSamples.get(currentIndex).setHeartRate(lastHr);
+ processedSamples.get(currentIndex).setRawIntensity(0);
+ }
+
+ // Reset
+ lastHr = ActivitySample.NOT_MEASURED;
+
+ currentIndex += 1;
+ if (currentIndex >= processedSamples.size())
+ return; // We cannot add the data to any samples, so we might as well return
+ }
+
+ if (workoutSamples.get(i).getHeartRate() != ActivitySample.NOT_MEASURED)
+ lastHr = workoutSamples.get(i).getHeartRate() & 0xFF;
+ }
+
+ // If there is still data, it has to be part of the next index of processed samples
+ // Data being present implies it's still in a workout
+ currentIndex += 1;
+ if (currentIndex >= processedSamples.size())
+ return;
+ if (lastHr != ActivitySample.NOT_MEASURED) {
+ processedSamples.get(currentIndex).setHeartRate(lastHr);
+ processedSamples.get(currentIndex).setRawIntensity(0);
+ }
+ }
+
+ private void addWorkoutSamples(List<HuaweiActivitySample> processedSamples, int timestamp_from, int timestamp_to) {
+ int currentIndex = 0;
+ List<HuaweiWorkoutDataSample> workoutSamples = getRawOrderedWorkoutSamplesWithHeartRate(timestamp_from, timestamp_to);
+
+ for (int i = 0; i < workoutSamples.size(); i++) {
+ // Look behind to see if this is still the same workout
+ boolean inWorkout = i != 0 && workoutSamples.get(i).getWorkoutId() == workoutSamples.get(i - 1).getWorkoutId();
+
+ // Skip the samples that are before this workout sample, and potentially clear the HR
+ // and intensity - see #4126 for the reasoning
+ while (currentIndex < processedSamples.size() && workoutSamples.get(i).getTimestamp() > processedSamples.get(currentIndex).getTimestamp()) {
+ if (inWorkout) {
+ processedSamples.get(currentIndex).setHeartRate(ActivitySample.NOT_MEASURED);
+ processedSamples.get(currentIndex).setRawIntensity(0);
+ }
+
+ currentIndex += 1;
+ }
+
+ if (i < workoutSamples.size() - 1) {
+ processedSamples.add(currentIndex, convertWorkoutSampleToActivitySample(workoutSamples.get(i), workoutSamples.get(i + 1).getTimestamp()));
+ } else {
+ // For the last workout sample we assume it is over 5 seconds
+ processedSamples.add(currentIndex, convertWorkoutSampleToActivitySample(workoutSamples.get(i), workoutSamples.get(i).getTimestamp() + 5));
+ }
+ currentIndex += 1; // Prevent clearing the sample in the next loop
+ }
+ }
+
+ private HuaweiActivitySample convertWorkoutSampleToActivitySample(HuaweiWorkoutDataSample workoutSample, int nextTimestamp) {
+ int hr = workoutSample.getHeartRate() & 0xFF;
+ HuaweiActivitySample newSample = new HuaweiActivitySample(
+ workoutSample.getTimestamp(),
+ -1,
+ -1,
+ nextTimestamp - 1, // Just to prevent overlap causing issues
+ (byte) 0x00,
+ ActivitySample.NOT_MEASURED,
+ ActivitySample.NOT_MEASURED,
+ ActivitySample.NOT_MEASURED,
+ ActivitySample.NOT_MEASURED,
+ ActivitySample.NOT_MEASURED,
+ ActivitySample.NOT_MEASURED,
+ hr
+ );
+ newSample.setProvider(this);
+ return newSample;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSettingsCustomizer.java
new file mode 100644
index 0000000..ed1ecf5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSettingsCustomizer.java
@@ -0,0 +1,164 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei;
+
+import android.content.SharedPreferences;
+import android.os.Parcel;
+import android.widget.Toast;
+
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.SwitchPreferenceCompat;
+
+import java.util.Set;
+import java.util.Collections;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiWorkoutGbParser;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import nodomain.freeyourgadget.gadgetbridge.util.XTimePreference;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.*;
+import static nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants.PREF_HUAWEI_DEBUG_REQUEST;
+import static nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants.PREF_HUAWEI_CONTINUOUS_SKIN_TEMPERATURE_MEASUREMENT;
+import static nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants.PREF_HUAWEI_TRUSLEEP;
+import static nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants.PREF_HUAWEI_WORKMODE;
+
+public class HuaweiSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ final GBDevice device;
+ final HuaweiCoordinator coordinator;
+
+ public HuaweiSettingsCustomizer(final GBDevice device) {
+ this.device = device;
+ this.coordinator = ((HuaweiCoordinatorSupplier) this.device.getDeviceCoordinator()).getHuaweiCoordinator();
+ }
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ if (preference.getKey().equals(PREF_DO_NOT_DISTURB)) {
+ final String dndState = ((ListPreference) preference).getValue();
+ final XTimePreference dndStart = handler.findPreference(PREF_DO_NOT_DISTURB_START);
+ final XTimePreference dndEnd = handler.findPreference(PREF_DO_NOT_DISTURB_END);
+ final SwitchPreferenceCompat dndLifWrist = handler.findPreference(PREF_DO_NOT_DISTURB_LIFT_WRIST);
+ final SwitchPreferenceCompat dndNotWear = handler.findPreference(PREF_DO_NOT_DISTURB_NOT_WEAR);
+ SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
+ boolean statusLiftWrist = sharedPrefs.getBoolean(PREF_LIFTWRIST_NOSHED, false);
+
+ dndStart.setEnabled(dndState.equals("scheduled"));
+ dndEnd.setEnabled(dndState.equals("scheduled"));
+ dndLifWrist.setEnabled(statusLiftWrist && !dndState.equals("off"));
+ dndNotWear.setEnabled(dndState.equals("off"));
+ }
+ if (preference.getKey().equals("huawei_reparse_workout_data")) {
+ if (((SwitchPreferenceCompat) preference).isChecked()) {
+ GB.toast("Starting workout reparse", Toast.LENGTH_SHORT, 0);
+ new HuaweiWorkoutGbParser(handler.getDevice(), handler.getContext()).parseAllWorkouts();
+ GB.toast("Workout reparse is complete", Toast.LENGTH_SHORT, 0);
+
+ ((SwitchPreferenceCompat) preference).setChecked(false);
+ }
+ }
+ if (preference.getKey().equals(PREF_FORCE_OPTIONS)) {
+ final Preference dnd = handler.findPreference("screen_do_not_disturb");
+ if (dnd != null)
+ dnd.setVisible(this.coordinator.supportsDoNotDisturb(handler.getDevice()));
+ final ListPreference wearLocation = handler.findPreference(PREF_WEARLOCATION);
+ if (wearLocation != null)
+ wearLocation.setVisible(this.coordinator.supportsWearLocation(handler.getDevice()));
+ final ListPreference heartRate = handler.findPreference(PREF_HEARTRATE_AUTOMATIC_ENABLE);
+ if (heartRate != null)
+ heartRate.setVisible(this.coordinator.supportsHeartRate(handler.getDevice()));
+ final ListPreference spo2 = handler.findPreference(PREF_SPO_AUTOMATIC_ENABLE);
+ if (spo2 != null)
+ spo2.setVisible(this.coordinator.supportsSPo2(handler.getDevice()));
+ }
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, Prefs prefs, final String rootKey) {
+
+ handler.addPreferenceHandlerFor(PREF_FORCE_OPTIONS);
+ handler.addPreferenceHandlerFor(PREF_FORCE_ENABLE_SMART_ALARM);
+ handler.addPreferenceHandlerFor(PREF_FORCE_ENABLE_WEAR_LOCATION);
+ handler.addPreferenceHandlerFor(PREF_FORCE_DND_SUPPORT);
+ handler.addPreferenceHandlerFor(PREF_FORCE_ENABLE_HEARTRATE_SUPPORT);
+ handler.addPreferenceHandlerFor(PREF_FORCE_ENABLE_SPO2_SUPPORT);
+
+ handler.addPreferenceHandlerFor(PREF_HUAWEI_WORKMODE);
+ handler.addPreferenceHandlerFor(PREF_HUAWEI_TRUSLEEP);
+ handler.addPreferenceHandlerFor(PREF_HUAWEI_DEBUG_REQUEST);
+ handler.addPreferenceHandlerFor(PREF_HUAWEI_CONTINUOUS_SKIN_TEMPERATURE_MEASUREMENT);
+
+ final Preference forceOptions = handler.findPreference(PREF_FORCE_OPTIONS);
+ if (forceOptions != null) {
+ boolean supportsSmartAlarm = this.coordinator.supportsSmartAlarm();
+ boolean supportsWearLocation = this.coordinator.supportsWearLocation();
+ boolean supportsHeartRate = this.coordinator.supportsHeartRate();
+ boolean supportsSpO2 = this.coordinator.supportsSPo2();
+ forceOptions.setVisible(!supportsSmartAlarm || !supportsWearLocation || !supportsHeartRate || !supportsSpO2);
+ final SwitchPreferenceCompat forceSmartAlarm = handler.findPreference(PREF_FORCE_ENABLE_SMART_ALARM);
+ forceSmartAlarm.setVisible(!supportsSmartAlarm);
+ final SwitchPreferenceCompat forceWearLocation = handler.findPreference(PREF_FORCE_ENABLE_WEAR_LOCATION);
+ forceWearLocation.setVisible(!supportsWearLocation);
+ final SwitchPreferenceCompat forceHeartRate = handler.findPreference(PREF_FORCE_ENABLE_HEARTRATE_SUPPORT);
+ forceHeartRate.setVisible(!supportsHeartRate);
+ final SwitchPreferenceCompat forceSpO2 = handler.findPreference(PREF_FORCE_ENABLE_SPO2_SUPPORT);
+ forceSpO2.setVisible(!supportsSpO2);
+ }
+
+ final SwitchPreferenceCompat reparseWorkout = handler.findPreference("huawei_reparse_workout_data");
+ if (reparseWorkout != null) {
+ reparseWorkout.setVisible(false);
+ if (this.coordinator.supportsWorkouts())
+ reparseWorkout.setVisible(true);
+ }
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeParcelable(device, 0);
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+
+ public static final Creator<HuaweiSettingsCustomizer> CREATOR= new Creator<HuaweiSettingsCustomizer>() {
+
+ @Override
+ public HuaweiSettingsCustomizer createFromParcel(Parcel parcel) {
+ final GBDevice device = parcel.readParcelable(HuaweiSettingsCustomizer.class.getClassLoader());
+ return new HuaweiSettingsCustomizer(device);
+ }
+
+ @Override
+ public HuaweiSettingsCustomizer[] newArray(int i) {
+ return new HuaweiSettingsCustomizer[0];
+ }
+ };
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java
new file mode 100644
index 0000000..62c5d5c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java
@@ -0,0 +1,230 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class HuaweiSpo2SampleProvider extends AbstractTimeSampleProvider<HuaweiSpo2SampleProvider.HuaweiSpo2Sample> {
+ private static final Logger LOG = LoggerFactory.getLogger(HuaweiSpo2SampleProvider.class);
+
+ private final HuaweiSampleProvider huaweiSampleProvider;
+
+ public HuaweiSpo2SampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ this.huaweiSampleProvider = new HuaweiSampleProvider(this.getDevice(), this.getSession());
+ }
+
+ /**
+ * Converts an Huawei activity sample to an SpO2 sample
+ * @param sample Activity sample to convert
+ * @return SpO sample containing the SpO value, timestamp, userID, and deviceID of the activity sample
+ */
+ @NonNull
+ private HuaweiSpo2Sample activityToSpo2Sample(HuaweiActivitySample sample) {
+ return new HuaweiSpo2Sample(
+ -1, // No difference between auto and manual for Huawei
+ sample.getTimestamp() * 1000L,
+ sample.getUserId(),
+ sample.getDeviceId(),
+ sample.getSpo()
+ );
+ }
+
+ @NonNull
+ @Override
+ public List<HuaweiSpo2Sample> getAllSamples(long timestampFrom, long timestampTo) {
+ // Using high res data is fine for the SpO2 sample provider at the time of writing
+ List<HuaweiActivitySample> activitySamples = huaweiSampleProvider.getAllActivitySamplesHighRes((int) (timestampFrom / 1000L), (int) (timestampTo / 1000L));
+ List<HuaweiSpo2Sample> spo2Samples = new ArrayList<>(activitySamples.size());
+ for (HuaweiActivitySample sample : activitySamples) {
+ if (sample.getSpo() == -1)
+ continue;
+ spo2Samples.add(activityToSpo2Sample(sample));
+ }
+ return spo2Samples;
+ }
+
+ @Override
+ public void addSample(HuaweiSpo2Sample activitySample) {
+ LOG.error("Huawei Spo2 sample provider addSample called!");
+ }
+
+ @Override
+ public void addSamples(List<HuaweiSpo2Sample> activitySamples) {
+ LOG.error("Huawei Spo2 sample provider addSamples called!");
+ }
+
+ @Nullable
+ @Override
+ public HuaweiSpo2Sample getLatestSample() {
+ QueryBuilder<HuaweiActivitySample> qb = this.huaweiSampleProvider.getSampleDao().queryBuilder();
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null)
+ return null;
+ final Property deviceProperty = this.huaweiSampleProvider.getDeviceIdentifierSampleProperty();
+ qb
+ .where(deviceProperty.eq(dbDevice.getId()))
+ .where(HuaweiActivitySampleDao.Properties.Spo.notEq(-1))
+ .orderDesc(this.huaweiSampleProvider.getTimestampSampleProperty())
+ .limit(1);
+ final List<HuaweiActivitySample> samples = qb.build().list();
+ if (samples.isEmpty())
+ return null;
+ return activityToSpo2Sample(samples.get(0));
+ }
+
+ @Nullable
+ @Override
+ public HuaweiSpo2Sample getFirstSample() {
+ QueryBuilder<HuaweiActivitySample> qb = this.huaweiSampleProvider.getSampleDao().queryBuilder();
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null)
+ return null;
+ final Property deviceProperty = this.huaweiSampleProvider.getDeviceIdentifierSampleProperty();
+ qb
+ .where(deviceProperty.eq(dbDevice.getId()))
+ .where(HuaweiActivitySampleDao.Properties.Spo.notEq(-1))
+ .orderAsc(this.huaweiSampleProvider.getTimestampSampleProperty())
+ .limit(1);
+ final List<HuaweiActivitySample> samples = qb.build().list();
+ if (samples.isEmpty())
+ return null;
+ return activityToSpo2Sample(samples.get(0));
+ }
+
+ @Override
+ protected void detachFromSession() {
+ // Not necessary to do anything here
+ LOG.warn("Huawei Spo2 sample provider detachFromSession called!");
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<HuaweiSpo2Sample, ?> getSampleDao() {
+ // This not existing is not an issue (at the time of writing), as this is only used in
+ // methods that are overwritten by this class itself.
+ LOG.error("Huawei Spo2 sample provider getSampleDao called!");
+ return null;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ LOG.warn("Huawei Spo2 sample provider getTimestampSampleProperty called!");
+ return this.huaweiSampleProvider.getTimestampSampleProperty();
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ LOG.warn("Huawei Spo2 sample provider getDeviceIdentifierSampleProperty called!");
+ return this.huaweiSampleProvider.getDeviceIdentifierSampleProperty();
+ }
+
+ @Override
+ public HuaweiSpo2Sample createSample() {
+ return new HuaweiSpo2Sample();
+ }
+
+ public static class HuaweiSpo2Sample extends AbstractSpo2Sample {
+ private int typeNum;
+ private long timestamp;
+ private long userId;
+ private long deviceId;
+ private int spo2;
+
+ public HuaweiSpo2Sample() { }
+
+ public HuaweiSpo2Sample(int typeNum, long timestamp, long userId, long deviceId, int spo2) {
+ this.typeNum = typeNum;
+ this.timestamp = timestamp;
+ this.userId = userId;
+ this.deviceId = deviceId;
+ this.spo2 = spo2;
+ }
+
+ @Override
+ public int getTypeNum() {
+ return typeNum;
+ }
+
+ @Override
+ public void setTimestamp(long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public long getUserId() {
+ return userId;
+ }
+
+ @Override
+ public void setUserId(long userId) {
+ this.userId = userId;
+ }
+
+ @Override
+ public long getDeviceId() {
+ return deviceId;
+ }
+
+ @Override
+ public void setDeviceId(long deviceId) {
+ this.deviceId = deviceId;
+ }
+
+ @Override
+ public void setDevice(final Device device) {
+
+ }
+
+ @Override
+ public void setUser(final User user) {
+
+ }
+
+ @Override
+ public int getSpo2() {
+ return spo2;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSportHRZones.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSportHRZones.java
new file mode 100644
index 0000000..b3208e4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSportHRZones.java
@@ -0,0 +1,131 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+public class HuaweiSportHRZones {
+ private final HeartRateZonesConfig otherHRZonesConfig;
+ private final HeartRateZonesConfig sittingHRZonesConfig;
+ private final HeartRateZonesConfig uprightHRZonesConfig;
+ private final HeartRateZonesConfig swimmingHRZonesConfig;
+
+ public HuaweiSportHRZones(int age) {
+ this.uprightHRZonesConfig = new HeartRateZonesConfig(HeartRateZonesConfig.TYPE_UPRIGHT, age);
+ this.sittingHRZonesConfig = new HeartRateZonesConfig(HeartRateZonesConfig.TYPE_SITTING, age);
+ this.swimmingHRZonesConfig = new HeartRateZonesConfig(HeartRateZonesConfig.TYPE_SWIMMING, age);
+ this.otherHRZonesConfig = new HeartRateZonesConfig(HeartRateZonesConfig.TYPE_OTHER, age);
+ }
+
+ public HeartRateZonesConfig getHRZonesConfigByType(int type) {
+ if (type == HeartRateZonesConfig.TYPE_SITTING) {
+ return this.sittingHRZonesConfig;
+ } else if (type == HeartRateZonesConfig.TYPE_SWIMMING) {
+ return this.swimmingHRZonesConfig;
+ } else if (type == HeartRateZonesConfig.TYPE_OTHER) {
+ return this.otherHRZonesConfig;
+ }
+ return this.uprightHRZonesConfig;
+ }
+
+ public byte[] getHRZonesData() {
+ HeartRateZonesConfig uprightConfig = getHRZonesConfigByType(HeartRateZonesConfig.TYPE_UPRIGHT);
+ HeartRateZonesConfig sittingConfig = getHRZonesConfigByType(HeartRateZonesConfig.TYPE_SITTING);
+ HeartRateZonesConfig swimmingConfig = getHRZonesConfigByType(HeartRateZonesConfig.TYPE_SWIMMING);
+ HeartRateZonesConfig otherConfig = getHRZonesConfigByType(HeartRateZonesConfig.TYPE_OTHER);
+
+ if (!uprightConfig.isValid() || !sittingConfig.isValid() || !swimmingConfig.isValid() || !otherConfig.isValid()) {
+ return null;
+ }
+ HuaweiTLV tlv = new HuaweiTLV();
+ if (uprightConfig.hasValidMHRData()) {
+ tlv.put(0x2, (byte) uprightConfig.getMHRWarmUp())
+ .put(0x3, (byte) uprightConfig.getMHRFatBurning())
+ .put(0x4, (byte) uprightConfig.getMHRAerobic())
+ .put(0x5, (byte) uprightConfig.getMHRAnaerobic())
+ .put(0x6, (byte) uprightConfig.getMHRExtreme())
+ .put(0x7, (byte) uprightConfig.getMaxHRThreshold())
+ .put(0x8, (byte) (uprightConfig.getWarningEnable() ? 1 : 0))
+ .put(0x9, (byte) uprightConfig.getWarningHRLimit())
+ .put(0xa, (byte) uprightConfig.getCalculateMethod())
+ .put(0xb, (byte) uprightConfig.getMaxHRThreshold());
+ }
+ tlv.put(0xc, (byte) uprightConfig.getRestHeartRate());
+ if (uprightConfig.hasValidHRRData()) {
+ tlv.put(0xd, (byte) uprightConfig.getHRRBasicAerobic())
+ .put(0xe, (byte) uprightConfig.getHRRAdvancedAerobic())
+ .put(0xf, (byte) uprightConfig.getHRRLactate())
+ .put(0x10, (byte) uprightConfig.getHRRBasicAnaerobic())
+ .put(0x11, (byte) uprightConfig.getHRRAdvancedAnaerobic());
+
+ }
+ if (uprightConfig.hasValidLTHRData()) {
+ tlv.put(0x3f, (byte) uprightConfig.getLTHRThresholdHeartRate())
+ .put(0x40, (byte) uprightConfig.getLTHRAnaerobic())
+ .put(0x41, (byte) uprightConfig.getLTHRLactate())
+ .put(0x42, (byte) uprightConfig.getLTHRAdvancedAerobic())
+ .put(0x43, (byte) uprightConfig.getLTHRBasicAerobic())
+ .put(0x44, (byte) uprightConfig.getLTHRWarmUp());
+ }
+
+ if (sittingConfig.hasValidMHRData()) {
+ tlv.put(0x12, (byte) (sittingConfig.getWarningEnable() ? 1 : 0))
+ .put(0x13, (byte) sittingConfig.getCalculateMethod())
+ .put(0x14, (byte) sittingConfig.getWarningHRLimit())
+ .put(0x15, (byte) sittingConfig.getMHRWarmUp())
+ .put(0x16, (byte) sittingConfig.getMHRFatBurning())
+ .put(0x17, (byte) sittingConfig.getMHRAerobic())
+ .put(0x18, (byte) sittingConfig.getMHRAnaerobic())
+ .put(0x19, (byte) sittingConfig.getMHRExtreme())
+ .put(0x1a, (byte) sittingConfig.getMaxHRThreshold());
+ }
+ if (sittingConfig.hasValidHRRData()) {
+ tlv.put(0x1b, (byte) sittingConfig.getRestHeartRate())
+ .put(0x1c, (byte) sittingConfig.getHRRBasicAerobic())
+ .put(0x1d, (byte) sittingConfig.getHRRAdvancedAerobic())
+ .put(0x1e, (byte) sittingConfig.getHRRLactate())
+ .put(0x1f, (byte) sittingConfig.getHRRBasicAnaerobic())
+ .put(0x20, (byte) sittingConfig.getHRRAdvancedAnaerobic());
+ }
+
+ if (swimmingConfig.hasValidMHRData()) {
+ tlv.put(0x21, (byte) (swimmingConfig.getWarningEnable() ? 1 : 0))
+ .put(0x22, (byte) swimmingConfig.getCalculateMethod())
+ .put(0x23, (byte) swimmingConfig.getWarningHRLimit())
+ .put(0x24, (byte) swimmingConfig.getMHRWarmUp())
+ .put(0x25, (byte) swimmingConfig.getMHRFatBurning())
+ .put(0x26, (byte) swimmingConfig.getMHRAerobic())
+ .put(0x27, (byte) swimmingConfig.getMHRAnaerobic())
+ .put(0x28, (byte) swimmingConfig.getMHRExtreme())
+ .put(0x29, (byte) swimmingConfig.getMaxHRThreshold());
+ }
+ if (swimmingConfig.hasValidHRRData()) {
+ tlv.put(0x2a, (byte) swimmingConfig.getRestHeartRate())
+ .put(0x2b, (byte) swimmingConfig.getHRRBasicAerobic())
+ .put(0x2c, (byte) swimmingConfig.getHRRAdvancedAerobic())
+ .put(0x2d, (byte) swimmingConfig.getHRRLactate())
+ .put(0x2e, (byte) swimmingConfig.getHRRBasicAnaerobic())
+ .put(0x2f, (byte) swimmingConfig.getHRRAdvancedAnaerobic());
+ }
+
+ if (otherConfig.hasValidMHRData()) {
+ tlv.put(0x30, (byte) (otherConfig.getWarningEnable() ? 1 : 0))
+ .put(0x31, (byte) otherConfig.getCalculateMethod())
+ .put(0x32, (byte) otherConfig.getWarningHRLimit())
+ .put(0x33, (byte) otherConfig.getMHRWarmUp())
+ .put(0x34, (byte) otherConfig.getMHRFatBurning())
+ .put(0x35, (byte) otherConfig.getMHRAerobic())
+ .put(0x36, (byte) otherConfig.getMHRAnaerobic())
+ .put(0x37, (byte) otherConfig.getMHRExtreme())
+ .put(0x38, (byte) otherConfig.getMaxHRThreshold());
+ }
+ if (otherConfig.hasValidHRRData()) {
+ tlv.put(0x39, (byte) otherConfig.getRestHeartRate())
+ .put(0x3a, (byte) otherConfig.getHRRBasicAerobic())
+ .put(0x3b, (byte) otherConfig.getHRRAdvancedAerobic())
+ .put(0x3c, (byte) otherConfig.getHRRLactate())
+ .put(0x3d, (byte) otherConfig.getHRRBasicAnaerobic())
+ .put(0x3e, (byte) otherConfig.getHRRAdvancedAnaerobic());
+ }
+
+ return tlv.serialize();
+ }
+
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTLV.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTLV.java
new file mode 100644
index 0000000..4f902f6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTLV.java
@@ -0,0 +1,452 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM, Vitalii Tomin
+
+ 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/>. */
+
+/* TLV parsing and serialisation thanks to https://github.com/yihleego/tlv */
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+import static nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants.CryptoTags;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiCrypto.CryptoException;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket.ParamsProvider;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+public class HuaweiTLV {
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ HuaweiTLV huaweiTLV = (HuaweiTLV) o;
+ return Objects.equals(valueMap, huaweiTLV.valueMap);
+ }
+
+ public static class TLV {
+ private final byte tag;
+ private final byte[] value;
+
+ public TLV(byte tag, byte[] value) {
+ this.tag = tag;
+ this.value = value;
+ }
+
+ public byte getTag() {
+ return tag;
+ }
+
+ public byte[] getValue() {
+ return value;
+ }
+
+ public int length() {
+ return 1 + VarInt.getVarIntSize(value.length) + value.length;
+ }
+
+ public byte[] serialize() {
+ return ByteBuffer.allocate(this.length())
+ .put(tag)
+ .put(VarInt.putVarIntValue(value.length))
+ .put(value)
+ .array();
+ }
+
+ public String toString() {
+ return "{tag: " + Integer.toHexString(tag & 0xFF) + " - Value: " + StringUtils.bytesToHex(value) + "} - ";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ TLV tlv = (TLV) o;
+ return tag == tlv.tag && Arrays.equals(value, tlv.value);
+ }
+ }
+
+ private static final Logger LOG = LoggerFactory.getLogger(HuaweiTLV.class);
+
+ protected List<TLV> valueMap;
+
+ public HuaweiTLV() {
+ this.valueMap = new ArrayList<>();
+ }
+
+ public int length() {
+ int length = 0;
+ for (TLV tlv : valueMap)
+ length += tlv.length();
+ return length;
+ }
+
+ /**
+ * Parse byte buffer into this HuaweiTLV
+ * @param buffer The buffer to parse
+ * @param offset The offset to start parsing at
+ * @param length The length to parse
+ * @return The HuaweiTLV object itself
+ * @throws ArrayIndexOutOfBoundsException There are two general cases in which this exception
+ * can be thrown:
+ * 1. offset + length is greater than the buffer length
+ * 2. The buffer is malformed which causes an element size to be larger than the remaining
+ * buffer length
+ */
+ public HuaweiTLV parse(byte[] buffer, int offset, int length) {
+ if (buffer == null)
+ return null;
+ int parsed = 0;
+ while (parsed < length) {
+ // Tag is 1 byte
+ byte tag = buffer[offset + parsed];
+ parsed += 1;
+ // It seems that there can be an extra null byte at the end of something encrypted
+ // If that happens we ignore it
+ if (parsed == length && tag == 0)
+ break;
+ // Size is a VarInt >= 1 byte
+ VarInt varInt = new VarInt(buffer, offset + parsed);
+ int size = varInt.dValue;
+ parsed += varInt.size;
+ byte[] value = new byte[size];
+ System.arraycopy(buffer, offset + parsed, value, 0, size);
+ put(tag, value);
+ parsed += size;
+ }
+ LOG.debug("Parsed TLV: " + this);
+ return this;
+ }
+
+ public HuaweiTLV parse(byte[] buffer) {
+ if (buffer == null) {
+ return null;
+ }
+ return parse(buffer, 0, buffer.length);
+ }
+
+ public byte[] serialize() {
+ int length = this.length();
+ if (length == 0)
+ return new byte[0];
+ ByteBuffer buffer = ByteBuffer.allocate(length);
+ for (TLV entry : valueMap)
+ buffer.put(entry.serialize());
+ LOG.debug("Serialized TLV: " + this);
+ return buffer.array();
+ }
+
+ public HuaweiTLV put(int tag) {
+ byte[] value = new byte[0];
+ valueMap.add(new TLV((byte)tag, value));
+ return this;
+ }
+
+ public HuaweiTLV put(int tag, byte[] value) {
+ if (value == null) {
+ return this;
+ }
+ valueMap.add(new TLV((byte)tag, value));
+ return this;
+ }
+
+ public HuaweiTLV put(int tag, byte value) {
+ return put(tag, new byte[]{value});
+ }
+
+ public HuaweiTLV put(int tag, boolean value) {
+ return put(tag, new byte[]{value ? (byte) 1 : (byte) 0});
+ }
+
+ public HuaweiTLV put(int tag, Long value) {
+ return put(tag, ByteBuffer.allocate(8).putLong(value).array());
+ }
+
+ public HuaweiTLV put(int tag, Double value) {
+ return put(tag, ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putDouble(value).array());
+ }
+
+ public HuaweiTLV put(int tag, int value) {
+ return put(tag, ByteBuffer.allocate(4).putInt(value).array());
+ }
+
+ public HuaweiTLV put(int tag, short value) {
+ return put(tag, ByteBuffer.allocate(2).putShort(value).array());
+ }
+
+ public HuaweiTLV put(int tag, String value) {
+ if (value == null) {
+ return this;
+ }
+ return put(tag, value.getBytes(StandardCharsets.UTF_8));
+ }
+
+ public HuaweiTLV put(int tag, HuaweiTLV value) {
+ if (value == null) {
+ return this;
+ }
+ return put(tag, value.serialize());
+ }
+
+ public List<TLV> get() {
+ return this.valueMap;
+ }
+
+ public byte[] getBytes(int tag) throws HuaweiPacket.MissingTagException {
+ for (TLV item : valueMap)
+ if (item.getTag() == (byte) tag)
+ return item.getValue();
+ throw new HuaweiPacket.MissingTagException(tag);
+ }
+
+ public byte[] getBytes(int tag, byte[] defaultValue) {
+ try {
+ return getBytes(tag);
+ } catch (HuaweiPacket.MissingTagException e) {
+ return defaultValue;
+ }
+ }
+
+ public Byte getByte(int tag) throws HuaweiPacket.MissingTagException {
+ return getBytes(tag)[0];
+ }
+
+ public Byte getByte(int tag, Byte defaultValue) {
+ try {
+ return getByte(tag);
+ } catch (HuaweiPacket.MissingTagException e) {
+ return defaultValue;
+ }
+ }
+
+ public Boolean getBoolean(int tag) throws HuaweiPacket.MissingTagException {
+ return getBytes(tag)[0] == 1;
+ }
+
+ public Boolean getBoolean(int tag, Boolean defaultValue) {
+ try {
+ return getBoolean(tag);
+ } catch (HuaweiPacket.MissingTagException e) {
+ return defaultValue;
+ }
+ }
+
+ public Integer getInteger(int tag) throws HuaweiPacket.MissingTagException {
+ return ByteBuffer.wrap(getBytes(tag)).getInt();
+ }
+
+ public Integer getInteger(int tag, Integer defaultResult) {
+ try {
+ return getInteger(tag);
+ } catch (HuaweiPacket.MissingTagException e) {
+ return defaultResult;
+ }
+ }
+
+ public Short getShort(int tag) throws HuaweiPacket.MissingTagException {
+ return ByteBuffer.wrap(getBytes(tag)).getShort();
+ }
+
+ public Short getShort(int tag, Short defaultValue) {
+ try {
+ return getShort(tag);
+ } catch (HuaweiPacket.MissingTagException e) {
+ return defaultValue;
+ }
+ }
+
+ public Long getLong(int tag) throws HuaweiPacket.MissingTagException {
+ return ByteBuffer.wrap(getBytes(tag)).getLong();
+ }
+
+ public Integer getAsInteger(int tag) throws HuaweiPacket.MissingTagException {
+ byte[] bytes = getBytes(tag);
+ if(bytes.length == 1) {
+ return bytes[0] & 0xFF;
+ } else if(bytes.length == 2) {
+ return ByteBuffer.wrap(getBytes(tag)).getShort() & 0xFFFF;
+ }
+ return ByteBuffer.wrap(getBytes(tag)).getInt();
+ }
+
+ public String getString(int tag) throws HuaweiPacket.MissingTagException {
+ return new String(getBytes(tag), StandardCharsets.UTF_8);
+ }
+
+ public HuaweiTLV getObject(int tag) throws HuaweiPacket.MissingTagException {
+ byte[] bytes = getBytes(tag);
+ return new HuaweiTLV().parse(bytes, 0, bytes.length);
+ }
+
+ public List<HuaweiTLV> getObjects(int tag) {
+ List<HuaweiTLV> returnValue = new ArrayList<>();
+ for (TLV tlv : valueMap) {
+ if (tlv.getTag() == (byte) tag)
+ returnValue.add(new HuaweiTLV().parse(tlv.getValue()));
+ }
+ return returnValue;
+ }
+
+ public boolean contains(int tag) {
+ for (TLV item : valueMap)
+ if (item.getTag() == (byte) tag)
+ return true;
+ return false;
+ }
+
+ /**
+ * Removes the last element that was added with the specified tag
+ * @param tag The tag of the element that should be removed
+ * @return The value contained in the removed tag
+ */
+ public byte[] remove(int tag) {
+ TLV foundItem = null;
+ for (TLV item : valueMap)
+ if (item.getTag() == (byte) tag)
+ foundItem = item;
+ if (foundItem != null) {
+ valueMap.remove(foundItem);
+ return foundItem.getValue();
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Get string representation of HuaweiTLV, "Empty" when no elements are present
+ * @return String
+ */
+ public String toString() {
+ if (valueMap.isEmpty())
+ return "Empty";
+
+ StringBuilder msg = new StringBuilder();
+ for (TLV entry : valueMap)
+ msg.append(entry.toString());
+ return msg.substring(0, msg.length() - 3);
+ }
+
+ public static HuaweiTLV encryptRaw(ParamsProvider paramsProvider, byte[] data) throws CryptoException {
+ byte[] key = paramsProvider.getSecretKey();
+ byte[] nonce = paramsProvider.getIv();
+ byte[] encryptedTLV = HuaweiCrypto.encrypt(
+ paramsProvider.getEncryptMethod() == 0x01 || paramsProvider.getDeviceSupportType() == 0x04,
+ data,
+ key,
+ nonce);
+ return new HuaweiTLV()
+ .put(CryptoTags.encryption, (byte) 0x01)
+ .put(CryptoTags.initVector, nonce)
+ .put(CryptoTags.cipherText, encryptedTLV);
+ }
+
+ public HuaweiTLV encrypt(ParamsProvider paramsProvider) throws CryptoException {
+ byte[] serializedTLV = serialize();
+ return encryptRaw(paramsProvider, serializedTLV);
+ }
+
+ public byte[] decryptRaw(ParamsProvider paramsProvider) throws CryptoException, HuaweiPacket.MissingTagException {
+ byte[] key = paramsProvider.getSecretKey();
+ return HuaweiCrypto.decrypt(
+ paramsProvider.getEncryptMethod() == 0x01 || paramsProvider.getDeviceSupportType() == 0x04,
+ getBytes(CryptoTags.cipherText),
+ key,
+ getBytes(CryptoTags.initVector));
+ }
+
+ public void decrypt(ParamsProvider paramsProvider) throws CryptoException, HuaweiPacket.MissingTagException {
+ byte[] decryptedTLV = decryptRaw(paramsProvider);
+ this.valueMap = new ArrayList<>();
+ parse(decryptedTLV);
+ }
+}
+
+final class VarInt {
+ int dValue; // Decoded value of the VarInt
+ int size; // Size of the encoded value
+ byte[] eValue; // Encoded value of the VarInt
+
+ public VarInt(byte[] src, int offset) {
+ this.dValue = getVarIntValue(src, offset);
+ this.eValue = putVarIntValue(this.dValue);
+ this.size = this.eValue.length;
+ }
+
+ public String toString() {
+ return "VarInt(dValue: " + this.dValue + ", size: " + this.size + ", eValue: " + StringUtils.bytesToHex(this.eValue) + ")";
+ }
+
+ /**
+ * Returns the size of the encoded input value.
+ *
+ * @param value the integer to be measured
+ * @return the encoding size of the input value
+ */
+ public static int getVarIntSize(int value) {
+ int result = 0;
+ do {
+ result++;
+ value >>>= 7;
+ } while (value != 0);
+ return result;
+ }
+
+ /**
+ * Decode a byte array of a variable-length encoding from start,
+ * 7 bits per byte.
+ * Return the decoded value in int.
+ *
+ * @param src the byte array to get the var int from
+ * @return the decoded value in int
+ */
+ public static int getVarIntValue(byte[] src, int offset) {
+ int result = 0;
+ int b;
+ while (true) {
+ b = src[offset];
+ result += (b & 0x7F);
+ if ((b & 0x80) == 0) { break; }
+ result <<= 7;
+ offset++;
+ }
+ return result;
+ }
+
+ /**
+ * Encode an integer in a variable-length encoding, 7 bits per byte.
+ * Return the encoded value in byte[]
+ *
+ * @param value the int value to encode
+ * @return the encoded value in byte[]
+ */
+ public static byte[] putVarIntValue(int value) {
+ int size = getVarIntSize(value);
+ byte[] result = new byte[size];
+ result[size - 1] = (byte)(value & 0x7F);
+ for (int offset = size - 2; offset >= 0; offset--) {
+ value >>>= 7;
+ result[offset] = (byte)((value & 0x7F) | 0x80);
+ }
+ return result;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTemperatureSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTemperatureSampleProvider.java
new file mode 100644
index 0000000..0ee2824
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTemperatureSampleProvider.java
@@ -0,0 +1,213 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiDictData;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiDictDataDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiDictDataValues;
+import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiDictDataValuesDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+
+public class HuaweiTemperatureSampleProvider implements TimeSampleProvider<TemperatureSample> {
+
+ private final Logger LOG = LoggerFactory.getLogger(HuaweiTemperatureSampleProvider.class);
+
+ protected static class HuaweiTemperatureSample implements TemperatureSample {
+ private final long timestamp;
+ private final float temperature;
+
+ public HuaweiTemperatureSample(long timestamp, float temperature) {
+ this.timestamp = timestamp;
+ this.temperature = temperature;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public float getTemperature() {
+ return temperature;
+ }
+
+ @Override
+ public int getTemperatureType() { return 0;}
+ }
+
+ private final GBDevice device;
+ private final DaoSession session;
+
+ public HuaweiTemperatureSampleProvider(GBDevice device, DaoSession session) {
+ this.device = device;
+ this.session = session;
+ }
+
+ private double conv2Double(byte[] b) {
+ return ByteBuffer.wrap(b).getDouble();
+ }
+
+ @NonNull
+ @Override
+ public List<TemperatureSample> getAllSamples(long timestampFrom, long timestampTo) {
+
+ List<TemperatureSample> ret = new ArrayList<>();
+
+ Long userId = DBHelper.getUser(this.session).getId();
+ Long deviceId = DBHelper.getDevice(this.device, this.session).getId();
+
+ if (deviceId == null || userId == null)
+ return ret;
+
+ QueryBuilder<HuaweiDictData> qb = this.session.getHuaweiDictDataDao().queryBuilder();
+ qb.where(HuaweiDictDataDao.Properties.DeviceId.eq(deviceId))
+ .where(HuaweiDictDataDao.Properties.UserId.eq(userId))
+ .where(HuaweiDictDataDao.Properties.DictClass.eq(HuaweiDictTypes.SKIN_TEMPERATURE_CLASS))
+ .where(HuaweiDictDataDao.Properties.StartTimestamp.between(timestampFrom, timestampTo));
+ final List<HuaweiDictData> dictData = qb.build().list();
+
+ if (dictData.isEmpty())
+ return ret;
+
+ List<Long> ids = dictData.stream().map(HuaweiDictData::getDictId).collect(Collectors.toList());
+
+ QueryBuilder<HuaweiDictDataValues> qbv = this.session.getHuaweiDictDataValuesDao().queryBuilder();
+
+ qbv.where(HuaweiDictDataValuesDao.Properties.DictType.eq(HuaweiDictTypes.SKIN_TEMPERATURE_VALUE)).where(HuaweiDictDataValuesDao.Properties.Tag.eq(10)).where(HuaweiDictDataValuesDao.Properties.DictId.in(ids));
+
+ final List<HuaweiDictDataValues> valuesData = qbv.build().list();
+
+ if (valuesData.isEmpty())
+ return ret;
+
+ for(HuaweiDictDataValues vl: valuesData) {
+ double skinTemperature = conv2Double(vl.getValue());
+ if(skinTemperature >= 20 && skinTemperature <= 42) {
+ ret.add(new HuaweiTemperatureSample(vl.getHuaweiDictData().getStartTimestamp(), (float) skinTemperature));
+ }
+ }
+
+ return ret;
+ }
+
+ @Override
+ public void addSample(TemperatureSample timeSample) {
+ throw new UnsupportedOperationException("read-only sample provider");
+
+ }
+
+ @Override
+ public void addSamples(List<TemperatureSample> timeSamples) {
+ throw new UnsupportedOperationException("read-only sample provider");
+
+ }
+
+ @Override
+ public TemperatureSample createSample() {
+ throw new UnsupportedOperationException("read-only sample provider");
+ }
+
+ @Nullable
+ @Override
+ public TemperatureSample getLatestSample() {
+ Long userId = DBHelper.getUser(this.session).getId();
+ Long deviceId = DBHelper.getDevice(this.device, this.session).getId();
+
+ if (deviceId == null || userId == null)
+ return null;
+
+ QueryBuilder<HuaweiDictData> qb = this.session.getHuaweiDictDataDao().queryBuilder();
+ qb.where(HuaweiDictDataDao.Properties.DeviceId.eq(deviceId))
+ .where(HuaweiDictDataDao.Properties.UserId.eq(userId))
+ .where(HuaweiDictDataDao.Properties.DictClass.eq(HuaweiDictTypes.SKIN_TEMPERATURE_CLASS));
+ qb.orderDesc(HuaweiDictDataDao.Properties.StartTimestamp).limit(1);
+
+ final List<HuaweiDictData> data = qb.build().list();
+ if (data.isEmpty())
+ return null;
+
+
+ QueryBuilder<HuaweiDictDataValues> qbv = this.session.getHuaweiDictDataValuesDao().queryBuilder();
+ qbv.where(HuaweiDictDataValuesDao.Properties.DictType.eq(HuaweiDictTypes.SKIN_TEMPERATURE_VALUE)).where(HuaweiDictDataValuesDao.Properties.Tag.eq(10)).where(HuaweiDictDataValuesDao.Properties.DictId.eq(data.get(0).getDictId()));
+ final List<HuaweiDictDataValues> valuesData = qbv.build().list();
+
+ if (valuesData.isEmpty())
+ return null;
+
+ return new HuaweiTemperatureSample(valuesData.get(0).getHuaweiDictData().getStartTimestamp(), (float) conv2Double(valuesData.get(0).getValue()));
+ }
+
+ @Nullable
+ @Override
+ public TemperatureSample getLatestSample(final long until) {
+ Long userId = DBHelper.getUser(this.session).getId();
+ Long deviceId = DBHelper.getDevice(this.device, this.session).getId();
+
+ if (deviceId == null || userId == null)
+ return null;
+
+ QueryBuilder<HuaweiDictData> qb = this.session.getHuaweiDictDataDao().queryBuilder();
+ qb.where(HuaweiDictDataDao.Properties.StartTimestamp.le(until))
+ .where(HuaweiDictDataDao.Properties.DeviceId.eq(deviceId))
+ .where(HuaweiDictDataDao.Properties.UserId.eq(userId))
+ .where(HuaweiDictDataDao.Properties.DictClass.eq(HuaweiDictTypes.SKIN_TEMPERATURE_CLASS));
+ qb.orderDesc(HuaweiDictDataDao.Properties.StartTimestamp).limit(1);
+
+ final List<HuaweiDictData> data = qb.build().list();
+ if (data.isEmpty())
+ return null;
+
+
+ QueryBuilder<HuaweiDictDataValues> qbv = this.session.getHuaweiDictDataValuesDao().queryBuilder();
+ qbv.where(HuaweiDictDataValuesDao.Properties.DictType.eq(HuaweiDictTypes.SKIN_TEMPERATURE_VALUE)).where(HuaweiDictDataValuesDao.Properties.Tag.eq(10)).where(HuaweiDictDataValuesDao.Properties.DictId.eq(data.get(0).getDictId()));
+ final List<HuaweiDictDataValues> valuesData = qbv.build().list();
+
+ if (valuesData.isEmpty())
+ return null;
+
+ return new HuaweiTemperatureSample(valuesData.get(0).getHuaweiDictData().getStartTimestamp(), (float) conv2Double(valuesData.get(0).getValue()));
+ }
+
+ @Nullable
+ @Override
+ public TemperatureSample getFirstSample() {
+ Long userId = DBHelper.getUser(this.session).getId();
+ Long deviceId = DBHelper.getDevice(this.device, this.session).getId();
+
+ if (deviceId == null || userId == null)
+ return null;
+
+ QueryBuilder<HuaweiDictData> qb = this.session.getHuaweiDictDataDao().queryBuilder();
+ qb.where(HuaweiDictDataDao.Properties.DeviceId.eq(deviceId))
+ .where(HuaweiDictDataDao.Properties.UserId.eq(userId))
+ .where(HuaweiDictDataDao.Properties.DictClass.eq(HuaweiDictTypes.SKIN_TEMPERATURE_CLASS));
+ qb.orderAsc(HuaweiDictDataDao.Properties.StartTimestamp).limit(1);
+
+ final List<HuaweiDictData> data = qb.build().list();
+ if (data.isEmpty())
+ return null;
+
+ QueryBuilder<HuaweiDictDataValues> qbv = this.session.getHuaweiDictDataValuesDao().queryBuilder();
+ qbv.where(HuaweiDictDataValuesDao.Properties.DictType.eq(HuaweiDictTypes.SKIN_TEMPERATURE_VALUE)).where(HuaweiDictDataValuesDao.Properties.Tag.eq(10)).where(HuaweiDictDataValuesDao.Properties.DictId.eq(data.get(0).getDictId()));
+ final List<HuaweiDictDataValues> valuesData = qbv.build().list();
+
+ if (valuesData.isEmpty())
+ return null;
+
+ return new HuaweiTemperatureSample(valuesData.get(0).getHuaweiDictData().getStartTimestamp(), (float) conv2Double(valuesData.get(0).getValue()));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTruSleepParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTruSleepParser.java
new file mode 100644
index 0000000..ec24dfa
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiTruSleepParser.java
@@ -0,0 +1,474 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.devices.huawei;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Arrays;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiFileDownloadManager;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiSupportProvider;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class HuaweiTruSleepParser {
+ private static final Logger LOG = LoggerFactory.getLogger(HuaweiTruSleepParser.class);
+ public static final int TAG_ACC = 1;
+ public static final int TAG_PPG = 2;
+
+ public static class TruSleepStatus {
+ public final int startTime;
+ public final int endTime;
+
+ public TruSleepStatus(int startTime, int endTime) {
+ this.startTime = startTime;
+ this.endTime = endTime;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
+ TruSleepStatus that = (TruSleepStatus) o;
+ return startTime == that.startTime && endTime == that.endTime;
+ }
+
+ @Override
+ public String toString() {
+ return "TruSleepStatus{" +
+ "endTime=" + endTime +
+ ", startTime=" + startTime +
+ '}';
+ }
+ }
+ public static class TruSleepDataAcc {
+ public final int startTime;
+ public final short flags;
+
+ /*
+ Accelerometer (ACC):
+ Accelerometer data is sampled every 60 seconds, 24h a day and provides activity data on 3 axis.
+ Each axis activity is represented as byte, where 0x00 is no activity and 0xff is high activity.
+ The 3rd axis is ignored as it's always high (due to gravity??).
+ */
+ public TruSleepDataAcc(int startTime, short flags) {
+ this.startTime = startTime;
+ this.flags = flags;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
+ TruSleepDataAcc that = (TruSleepDataAcc) o;
+ if (startTime != that.startTime)
+ return false;
+ return flags == that.flags;
+ }
+
+ @Override
+ public String toString() {
+ return "TruSleepDataAcc{" +
+ "startTime=" + startTime +
+ ", flags=" + flags +
+ '}';
+ }
+ }
+
+ /*
+ Photoplethysmogram (PPG):
+ Every sample has a millisecond timestamp.
+ At night data is sampled every second, but at day it's sampled less often (about every 5 minutes).
+ */
+ public static class TruSleepDataPpg {
+ public final long startTime;
+ public final short flags;
+
+ TruSleepDataPpg(long startTime, short flags) {
+ this.startTime = startTime;
+ this.flags = flags;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
+ TruSleepDataPpg that = (TruSleepDataPpg) o;
+ if (startTime != that.startTime)
+ return false;
+ return flags == that.flags;
+ }
+
+ @Override
+ public String toString() {
+ return "TruSleepDataPpg{" +
+ "startTime=" + startTime +
+ ", flags=" + flags +
+ '}';
+ }
+ }
+ public static class TruSleepData {
+ public ArrayList<TruSleepDataAcc> dataACCs;
+ public ArrayList<TruSleepDataPpg> dataPPGs;
+
+ public TruSleepData() {
+ this.dataACCs = new ArrayList<>();
+ this.dataPPGs = new ArrayList<>();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
+ TruSleepData that = (TruSleepData) o;
+ if (!dataACCs.equals(that.dataACCs))
+ return false;
+ return dataPPGs.equals(that.dataPPGs);
+ }
+
+ @Override
+ public String toString() {
+ return "TruSleepData{" +
+ "dataPPGs=" + dataPPGs.toString() +
+ ", dataACCs=" + dataACCs.toString() +
+ '}';
+ }
+
+ private static final byte TAG_COMPRESSION_RAW = (byte)0xaa;
+ private static final byte TAG_COMPRESSION_COMP = (byte)0xbb;
+ private static final byte TAG_COMPRESSION_RESTART = (byte)0xff;
+
+ public void decodeAcc(byte[] data) throws IllegalArgumentException {
+ /*
+ Format:
+ - Timestamp (4 byte)
+ - Flags (3 bytes)
+ */
+ ByteBuffer buffer = ByteBuffer.wrap(data);
+ buffer.order(ByteOrder.LITTLE_ENDIAN);
+ if (buffer.remaining() < 4)
+ throw new IllegalArgumentException("Timestamp is missing");
+
+ int startTime = buffer.getInt();
+ if (buffer.remaining() < 2)
+ throw new IllegalArgumentException("Flags are missing");
+
+ short flags = buffer.getShort();
+ dataACCs.add(new TruSleepDataAcc(startTime, flags));
+ }
+
+ public void decodePpg(byte[] data) throws IllegalArgumentException, IllegalStateException {
+ /*
+ Format:
+ - Timestamp (4 byte)
+ - Number of UINT16 (1 byte)
+ - Compression tag (1 byte)
+ - Compressed data
+ - Compression tag (1 byte)
+ - Compressed data
+ */
+ ByteBuffer buffer = ByteBuffer.wrap(data);
+ buffer.order(ByteOrder.LITTLE_ENDIAN);
+
+ if (buffer.remaining() < 4)
+ throw new IllegalArgumentException("Timestamp is missing");
+
+ int startTime = buffer.getInt();
+
+ if (buffer.remaining() < 1)
+ throw new IllegalArgumentException("Count of short is missing");
+
+ byte countShort = buffer.get();
+
+ ArrayList<Short> peak = decodePeak(countShort, buffer);
+ ArrayList<Short> amp = decodeAmp(countShort, buffer);
+
+ // Sanity check
+ if (peak.size() != countShort || amp.size() != countShort)
+ throw new IllegalStateException("Decoded arrays have different length");
+
+ for (int i = 0; i < countShort; i++) {
+ dataPPGs.add(new TruSleepDataPpg((long)startTime * 1000 + peak.get(i) * 10, amp.get(i)));
+ }
+ LOG.debug("Buffer remaining {}", buffer.remaining());
+ }
+
+ private ArrayList<Short> decodePeak(byte countShort, ByteBuffer buffer) throws IllegalArgumentException {
+ if (countShort == 0)
+ throw new IllegalArgumentException("Number of short to generate is invalid");
+
+ if (buffer.remaining() < 1)
+ throw new IllegalArgumentException("Compression tag is missing");
+
+ byte tag = buffer.get();
+
+ ArrayList<Short> al = new ArrayList<Short>();
+ if (tag == TAG_COMPRESSION_RAW) {
+ if (buffer.remaining() < countShort * 2)
+ throw new IllegalArgumentException("Not enough elements in buffer");
+
+ while (countShort > 0) {
+ al.add(buffer.getShort());
+ countShort--;
+ }
+ } else if (tag == TAG_COMPRESSION_COMP) {
+ short working = buffer.getShort();
+ al.add(working);
+ countShort--;
+
+ while (countShort > 0) {
+ byte c = buffer.get();
+ if (c == TAG_COMPRESSION_RESTART) {
+ if (buffer.remaining() < 2)
+ throw new IllegalArgumentException("Not enough elements in buffer");
+ working = buffer.getShort();
+ } else {
+ working += c;
+ }
+ al.add(working);
+ countShort--;
+ }
+ } else {
+ throw new IllegalArgumentException("Compression " + String.format("%02x", tag) + " is unsupported");
+ }
+ return al;
+ }
+
+ public ArrayList<Short> decodeAmp(byte countShort, ByteBuffer buffer) throws IllegalArgumentException {
+ if (countShort == 0)
+ throw new IllegalArgumentException("Number of short to generate is invalid");
+
+ if (buffer.remaining() < 1)
+ throw new IllegalArgumentException("Compression tag is missing");
+
+ byte tag = buffer.get();
+
+ ArrayList<Short> al = new ArrayList<Short>();
+ if (tag == TAG_COMPRESSION_RAW) {
+ if (buffer.remaining() < countShort * 2)
+ throw new IllegalArgumentException("Not enough elements in buffer");
+
+ while (countShort > 0) {
+ al.add(buffer.getShort());
+ countShort--;
+ }
+ } else if (tag == TAG_COMPRESSION_COMP) {
+ if (buffer.remaining() < 2)
+ throw new IllegalArgumentException("Offset buffer is missing");
+
+ short offset = buffer.getShort();
+ short working = 0;
+ while (buffer.remaining() > 0) {
+ byte c = buffer.get();
+ if (c == TAG_COMPRESSION_RESTART) {
+ if (buffer.remaining() < 2)
+ throw new IllegalArgumentException("Raw buffer is missing");
+ working = (short)(offset + buffer.getShort());
+ } else {
+ working = (short)(offset + c);
+ }
+ al.add(working);
+ }
+ } else {
+ throw new IllegalArgumentException("Compression " + String.format("%02x", tag) + " is unsupported");
+ }
+ return al;
+ }
+
+ public void parsePpgData(byte[] data) {
+ LOG.debug("Decoding PPG: len= {}, data = {}", data.length, GB.hexdump(data));
+
+ try {
+ decodePpg(data);
+ } catch (Exception e) {
+ LOG.error("Failed to parse TrueSleep PPG data: {}", e.toString());
+ }
+ }
+ public void parseAccData(byte[] data) {
+ LOG.debug("Decoding ACC: len= {}, data = {}", data.length, GB.hexdump(data));
+
+ try {
+ decodeAcc(data);
+ } catch (Exception e) {
+ LOG.error("Failed to parse TrueSleep ACC data: {}", e.toString());
+ }
+ }
+ }
+
+ public static TruSleepData parseData(byte[] data) {
+ /*
+ Format:
+ - Zero padded
+ - ACC data interleaved with PPG data
+ - PPG data is compressed and has variable length
+ */
+ TruSleepData sleepData = new TruSleepData();
+ ByteBuffer buffer = ByteBuffer.wrap(data);
+ buffer.order(ByteOrder.LITTLE_ENDIAN);
+ byte c;
+ while (buffer.remaining() > 0) {
+ c = buffer.get();
+
+ // Skip over padding
+ if (c != TAG_ACC && c != TAG_PPG)
+ continue;
+
+ // The tag identifies the data packet
+ if (c == TAG_ACC) {
+ if (buffer.remaining() < 1)
+ break;
+
+ // ACC has 1 byte length
+ byte length = buffer.get();
+ if (buffer.remaining() < length)
+ break;
+
+ byte[] accData = new byte[length];
+ buffer.get(accData);
+
+ sleepData.parseAccData(accData);
+ } else if (c == TAG_PPG) {
+ if (buffer.remaining() < 2)
+ break;
+
+ // PPG has 2 byte length
+ short length = buffer.getShort();
+ if (buffer.remaining() < length)
+ break;
+
+ byte[] ppgData = new byte[length];
+ buffer.get(ppgData);
+
+ sleepData.parsePpgData(ppgData);
+ }
+ }
+
+ return sleepData;
+ }
+ public static TruSleepStatus[] parseState(byte[] stateData) {
+ /*
+ Format:
+ - Start time (int)
+ - End time (int)
+ - Unknown (short)
+ - Unknown (byte)
+ - Padding (5 bytes)
+ Could be multiple available
+ */
+ ByteBuffer buffer = ByteBuffer.wrap(stateData);
+ buffer.order(ByteOrder.LITTLE_ENDIAN);
+ TruSleepStatus[] retv = new TruSleepStatus[buffer.remaining() / 0x10];
+ int c = 0;
+ while (stateData.length - buffer.position() >= 0x10) {
+ int startTime = buffer.getInt();
+ int endTime = buffer.getInt();
+
+ // Throw away for now because we don't know what it means, and we don't think we can implement this soon
+ buffer.get(); buffer.get(); buffer.get();
+ buffer.get(); buffer.get(); buffer.get(); buffer.get(); buffer.get();
+
+ retv[c++] = new TruSleepStatus(startTime, endTime);
+ }
+ return retv;
+ }
+
+ private static void analyzeOneTimeframe(HuaweiSupportProvider provider, TruSleepStatus status, ArrayList<TruSleepDataAcc> acc, ArrayList<TruSleepDataPpg> ppg) {
+ java.util.Date rangeStartTime = new java.util.Date((long)status.startTime*1000);
+ java.util.Date rangeEndTime = new java.util.Date((long)status.endTime*1000);
+ LOG.debug("FIXME: Analyse sleep range {} - {} not implemented", rangeStartTime, rangeEndTime);
+ //FIXME: Sleep analysis algorithm implemented here
+ for (HuaweiTruSleepParser.TruSleepDataAcc i : acc) {
+ java.util.Date startTime = new java.util.Date((long)i.startTime*1000);
+ LOG.debug("TruSleepDataAcc {} : {}", startTime, String.format("%04x", i.flags));
+ }
+ for (HuaweiTruSleepParser.TruSleepDataPpg i : ppg) {
+ java.util.Date startTime = new java.util.Date((long) i.startTime);
+ LOG.debug("TruSleepDataPpg {} : {}", startTime, String.format("%04x", i.flags));
+ }
+ }
+
+ public static void analyze(HuaweiSupportProvider provider, TruSleepStatus[] status, TruSleepData data) {
+
+ // Analyse each time range as specified by TruSleepStatus
+ for (TruSleepStatus s : status) {
+
+ ArrayList<TruSleepDataAcc> acc = new ArrayList<>();
+ ArrayList<TruSleepDataPpg> ppg = new ArrayList<>();
+
+ // Collect Accelerometer data for current time frame
+ for (TruSleepDataAcc i : data.dataACCs) {
+ if (i.startTime >= s.startTime && i.startTime <= s.endTime)
+ acc.add(i);
+ }
+
+ // Collect PPG data for current time frame
+ for (TruSleepDataPpg i : data.dataPPGs) {
+ if (i.startTime >= s.startTime && i.startTime <= s.endTime)
+ ppg.add(i);
+ }
+
+ // Analyse time frame
+ analyzeOneTimeframe(provider, s, acc, ppg);
+ }
+ }
+
+ public static class SleepFileDownloadCallback extends HuaweiFileDownloadManager.FileDownloadCallback {
+ private byte[] statusData;
+ private byte[] sleepData;
+
+ private boolean statusSynced;
+ private boolean dataSynced;
+ protected HuaweiSupportProvider provider;
+ public SleepFileDownloadCallback(HuaweiSupportProvider provider) {
+ this.dataSynced = false;
+ this.statusSynced = false;
+ this.provider = provider;
+ }
+
+ public void syncComplete(byte[] statusData, byte[] sleepData) { }
+ @Override
+ public void downloadException(HuaweiFileDownloadManager.HuaweiFileDownloadException e) {
+ if (e.fileRequest == null) {
+ LOG.error("Failed to download TruSleep file: {}", e.toString());
+ syncComplete(statusData, sleepData);
+ return;
+ }
+
+ if (e.fileRequest.getFileType() == HuaweiFileDownloadManager.FileType.SLEEP_STATE) {
+ statusSynced = true;
+ } else if (e.fileRequest.getFileType() == HuaweiFileDownloadManager.FileType.SLEEP_DATA) {
+ dataSynced = true;
+ }
+ if (statusSynced && dataSynced)
+ syncComplete(statusData, sleepData);
+ }
+
+ @Override
+ public void downloadComplete(HuaweiFileDownloadManager.FileRequest fileRequest) {
+ if (fileRequest.getFileType() == HuaweiFileDownloadManager.FileType.SLEEP_STATE) {
+ statusData = fileRequest.getData();
+ statusSynced = true;
+ } else if (fileRequest.getFileType() == HuaweiFileDownloadManager.FileType.SLEEP_DATA) {
+ sleepData = fileRequest.getData();
+ dataSynced = true;
+ }
+ LOG.debug("Downloaded TruSleep file {}", fileRequest.getFileType());
+ if (statusSynced && dataSynced)
+ syncComplete(statusData, sleepData);
+ }
+
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiUtil.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiUtil.java
new file mode 100644
index 0000000..b00f4bf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiUtil.java
@@ -0,0 +1,59 @@
+/* Copyright (C) 2024 Damien Gaignon, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei;
+
+import java.nio.ByteBuffer;
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class HuaweiUtil {
+ private static final Logger LOG = LoggerFactory.getLogger(HuaweiUtil.class);
+
+ public static byte[] timeToByte(String time) {
+ Calendar calendar = Calendar.getInstance();
+ DateFormat df = new SimpleDateFormat("HH:mm", Locale.ENGLISH);
+ try {
+ Date t = df.parse(time);
+ assert t != null;
+ calendar.setTime(t);
+ } catch (ParseException e) {
+ LOG.error("Time conversion error: " + e);
+ return null;
+ }
+ return new byte[]{
+ (byte)calendar.get(Calendar.HOUR_OF_DAY),
+ (byte)calendar.get(Calendar.MINUTE)};
+ }
+
+ public static byte[] getTimeAndZoneId(final Calendar now) {
+ int zoneRawOffset = (now.get(Calendar.ZONE_OFFSET) + now.get(Calendar.DST_OFFSET)) / 1000;
+ byte[] id = now.getTimeZone().getID().getBytes();
+ return ByteBuffer.allocate(6 + id.length)
+ .putInt((int)(now.getTimeInMillis() / 1000))
+ .put((byte)(zoneRawOffset < 0 ? (-zoneRawOffset / 3600 + 128) : zoneRawOffset / 3600) )
+ .put((byte)(zoneRawOffset / 60 % 60))
+ .put(id)
+ .array();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/freebuds5i/HuaweiFreebuds5iCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/freebuds5i/HuaweiFreebuds5iCoordinator.java
new file mode 100644
index 0000000..5dae2fa
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/freebuds5i/HuaweiFreebuds5iCoordinator.java
@@ -0,0 +1,35 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei.freebuds5i;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiFreebudsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiFreebudsSupport;
+
+public class HuaweiFreebuds5iCoordinator extends HuaweiFreebudsCoordinator {
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("huawei freebuds 5i.*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return HuaweiFreebudsSupport.class;
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEI_FREEBUDS5I;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_freebuds_5i;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband3/HonorBand3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband3/HonorBand3Coordinator.java
new file mode 100644
index 0000000..4296ece
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband3/HonorBand3Coordinator.java
@@ -0,0 +1,52 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.honorband3;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HonorBand3Coordinator extends HuaweiLECoordinator {
+ @Override
+ public String getManufacturer() {
+ return "Honor";
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HONORBAND3;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HO_BAND3_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_honor_band3;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband4/HonorBand4Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband4/HonorBand4Coordinator.java
new file mode 100644
index 0000000..8c14725
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband4/HonorBand4Coordinator.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.honorband4;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HonorBand4Coordinator extends HuaweiLECoordinator {
+ @Override
+ public String getManufacturer() {
+ return "Honor";
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HONORBAND4;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HO_BAND4_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_honor_band4;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband5/HonorBand5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband5/HonorBand5Coordinator.java
new file mode 100644
index 0000000..0251850
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband5/HonorBand5Coordinator.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.honorband5;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HonorBand5Coordinator extends HuaweiLECoordinator {
+ @Override
+ public String getManufacturer() {
+ return "Honor";
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HONORBAND5;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HO_BAND5_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_honor_band5;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband6/HonorBand6Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband6/HonorBand6Coordinator.java
new file mode 100644
index 0000000..3a37345
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband6/HonorBand6Coordinator.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.honorband6;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HonorBand6Coordinator extends HuaweiLECoordinator {
+ @Override
+ public String getManufacturer() {
+ return "Honor";
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HONORBAND6;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HO_BAND6_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_honor_band6;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband7/HonorBand7Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband7/HonorBand7Coordinator.java
new file mode 100644
index 0000000..97db95d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorband7/HonorBand7Coordinator.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.honorband7;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HonorBand7Coordinator extends HuaweiLECoordinator {
+ @Override
+ public String getManufacturer() {
+ return "Honor";
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIBAND7;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HO_BAND7_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_honor_band7;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honormagicwatch2/HonorMagicWatch2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honormagicwatch2/HonorMagicWatch2Coordinator.java
new file mode 100644
index 0000000..96fa3ae
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honormagicwatch2/HonorMagicWatch2Coordinator.java
@@ -0,0 +1,51 @@
+/* Copyright (C) 2024 Vitaly Tomin, Gaignon Damien
+
+ 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.devices.huawei.honormagicwatch2;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HonorMagicWatch2Coordinator extends HuaweiBRCoordinator {
+ public HonorMagicWatch2Coordinator() {
+ super();
+ getHuaweiCoordinator().setTransactionCrypted(false);
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Honor";
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HONORMAGICWATCH2;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HO_MAGICWATCH2_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_honor_magicwatch2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorwatchgs3/HonorWatchGS3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorwatchgs3/HonorWatchGS3Coordinator.java
new file mode 100644
index 0000000..0666adf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorwatchgs3/HonorWatchGS3Coordinator.java
@@ -0,0 +1,51 @@
+/* Copyright (C) 2024 Vitaly Tomin
+
+ 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.devices.huawei.honorwatchgs3;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HonorWatchGS3Coordinator extends HuaweiBRCoordinator {
+ public HonorWatchGS3Coordinator() {
+ super();
+ getHuaweiCoordinator().setTransactionCrypted(false);
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Honor";
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HONORWATCHGS3;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HO_WATCHGS3_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_honor_watchgs3;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorwatchgspro/HonorWatchGSProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorwatchgspro/HonorWatchGSProCoordinator.java
new file mode 100644
index 0000000..0d4691e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/honorwatchgspro/HonorWatchGSProCoordinator.java
@@ -0,0 +1,51 @@
+/* Copyright (C) 2024 Vitaly Tomin, Gaignon Damien
+
+ 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.devices.huawei.honorwatchgspro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HonorWatchGSProCoordinator extends HuaweiBRCoordinator {
+ public HonorWatchGSProCoordinator() {
+ super();
+ getHuaweiCoordinator().setTransactionCrypted(false);
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Honor";
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HONORWATCHGSPRO;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HO_WATCHGSPRO_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_honor_watchgspro;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband2pro/HuaweiBand2ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband2pro/HuaweiBand2ProCoordinator.java
new file mode 100644
index 0000000..2ba218e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband2pro/HuaweiBand2ProCoordinator.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2024 Damien Gaignon, Guido J盲kel, Martin.JM
+
+ 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.devices.huawei.huaweiband2pro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiBand2ProCoordinator extends HuaweiLECoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIBAND2PRO;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_BAND2_NAME + "|" + HuaweiConstants.HU_BAND2PRO_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_band2pro;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband3pro/HuaweiBand3ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband3pro/HuaweiBand3ProCoordinator.java
new file mode 100644
index 0000000..ed5d067
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband3pro/HuaweiBand3ProCoordinator.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2024 Damien Gaignon, Guido J盲kel, Martin.JM
+
+ 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.devices.huawei.huaweiband3pro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiBand3ProCoordinator extends HuaweiLECoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIBAND3PRO;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_BAND3_NAME + "|" + HuaweiConstants.HU_BAND3PRO_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_band3pro;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband4pro/HuaweiBand4ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband4pro/HuaweiBand4ProCoordinator.java
new file mode 100644
index 0000000..edfd06b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband4pro/HuaweiBand4ProCoordinator.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweiband4pro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiBand4ProCoordinator extends HuaweiLECoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIBAND4PRO;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_BAND4_NAME + "|" + HuaweiConstants.HU_BAND4PRO_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_band4pro;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband6/HuaweiBand6Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband6/HuaweiBand6Coordinator.java
new file mode 100644
index 0000000..c74acf5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband6/HuaweiBand6Coordinator.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweiband6;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiBand6Coordinator extends HuaweiLECoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIBAND6;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HU_BAND6_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_band6;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband7/HuaweiBand7Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband7/HuaweiBand7Coordinator.java
new file mode 100644
index 0000000..e29347a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband7/HuaweiBand7Coordinator.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweiband7;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiBand7Coordinator extends HuaweiLECoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIBAND7;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HU_BAND7_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_band7;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband8/HuaweiBand8Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband8/HuaweiBand8Coordinator.java
new file mode 100644
index 0000000..6b3a578
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband8/HuaweiBand8Coordinator.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweiband8;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiBand8Coordinator extends HuaweiLECoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIBAND8;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HU_BAND8_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_band8;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband9/HuaweiBand9Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband9/HuaweiBand9Coordinator.java
new file mode 100644
index 0000000..cfab0e3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiband9/HuaweiBand9Coordinator.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweiband9;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiBand9Coordinator extends HuaweiLECoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIBAND9;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HU_BAND9_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_band9;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweibandaw70/HuaweiBandAw70Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweibandaw70/HuaweiBandAw70Coordinator.java
new file mode 100644
index 0000000..f8fb57e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweibandaw70/HuaweiBandAw70Coordinator.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweibandaw70;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiBandAw70Coordinator extends HuaweiLECoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIBANDAW70;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_BAND3E_NAME + "|" + HuaweiConstants.HU_BAND4E_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_band_aw70;
+ }
+
+ @Override
+ public HuaweiDeviceType getHuaweiType() {
+ return HuaweiDeviceType.AW;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweitalkbandb6/HuaweiTalkBandB6Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweitalkbandb6/HuaweiTalkBandB6Coordinator.java
new file mode 100644
index 0000000..dbd3cce
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweitalkbandb6/HuaweiTalkBandB6Coordinator.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweitalkbandb6;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiTalkBandB6Coordinator extends HuaweiBRCoordinator {
+ public HuaweiTalkBandB6Coordinator() {
+ super();
+ getHuaweiCoordinator().setTransactionCrypted(false);
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEITALKBANDB6;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HU_TALKBANDB6_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_talk_band_b6;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatch3/HuaweiWatch3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatch3/HuaweiWatch3Coordinator.java
new file mode 100644
index 0000000..081f574
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatch3/HuaweiWatch3Coordinator.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM, Vitalii Tomin
+
+ 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.devices.huawei.huaweiwatch3;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatch3Coordinator extends HuaweiBRCoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCH3;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCH3_NAME + "|" + HuaweiConstants.HU_WATCH3PRO_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watch3;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ // HarmonyOS watch
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatch4pro/HuaweiWatch4ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatch4pro/HuaweiWatch4ProCoordinator.java
new file mode 100644
index 0000000..e849d50
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatch4pro/HuaweiWatch4ProCoordinator.java
@@ -0,0 +1,52 @@
+/* Copyright (C) 2024 Damien Gaignon
+
+ 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.devices.huawei.huaweiwatch4pro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatch4ProCoordinator extends HuaweiBRCoordinator {
+ public HuaweiWatch4ProCoordinator() {
+ super();
+ getHuaweiCoordinator().setTransactionCrypted(true);
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCH4PRO;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCH4_NAME + "|" + HuaweiConstants.HU_WATCH4PRO_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watch4pro;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ // HarmonyOS watch
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchd2/HuaweiWatchD2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchd2/HuaweiWatchD2Coordinator.java
new file mode 100644
index 0000000..911bb7f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchd2/HuaweiWatchD2Coordinator.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 Vitalii Tomin
+
+ 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.devices.huawei.huaweiwatchd2;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchD2Coordinator extends HuaweiBRCoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHD2;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCHD2_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchd2;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit/HuaweiWatchFitCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit/HuaweiWatchFitCoordinator.java
new file mode 100644
index 0000000..ed727d1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit/HuaweiWatchFitCoordinator.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweiwatchfit;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchFitCoordinator extends HuaweiLECoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHFIT;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HU_WATCHFIT_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchfit;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit2/HuaweiWatchFit2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit2/HuaweiWatchFit2Coordinator.java
new file mode 100644
index 0000000..3c9ab89
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit2/HuaweiWatchFit2Coordinator.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 Damien Gaignon
+
+ 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.devices.huawei.huaweiwatchfit2;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchFit2Coordinator extends HuaweiBRCoordinator {
+ public HuaweiWatchFit2Coordinator() {
+ super();
+ getHuaweiCoordinator().setTransactionCrypted(true);
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHFIT2;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCHFIT2_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchfit2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit3/HuaweiWatchFit3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit3/HuaweiWatchFit3Coordinator.java
new file mode 100644
index 0000000..db44d18
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchfit3/HuaweiWatchFit3Coordinator.java
@@ -0,0 +1,57 @@
+/* Copyright (C) 2024 Damien Gaignon
+
+ 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.devices.huawei.huaweiwatchfit3;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchFit3Coordinator extends HuaweiBRCoordinator {
+ public HuaweiWatchFit3Coordinator() {
+ super();
+ getHuaweiCoordinator().setTransactionCrypted(true);
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHFIT3;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCHFIT3_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchfit3;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ // HarmonyOS watch
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt/HuaweiWatchGTCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt/HuaweiWatchGTCoordinator.java
new file mode 100644
index 0000000..89b9b12
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt/HuaweiWatchGTCoordinator.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweiwatchgt;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchGTCoordinator extends HuaweiLECoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHGT;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HU_WATCHGT_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watch_gt;
+ }
+
+ //@Override
+ //public HuaweiDeviceType getHuaweiType() {
+ // Could be SMART
+ // return HuaweiDeviceType.BLE;
+ //}
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt2/HuaweiWatchGT2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt2/HuaweiWatchGT2Coordinator.java
new file mode 100644
index 0000000..b55b934
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt2/HuaweiWatchGT2Coordinator.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweiwatchgt2;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchGT2Coordinator extends HuaweiBRCoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHGT2;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCHGT2_NAME + "|" + HuaweiConstants.HU_WATCHGT2PRO_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchgt2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt2e/HuaweiWatchGT2eCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt2e/HuaweiWatchGT2eCoordinator.java
new file mode 100644
index 0000000..c064e9f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt2e/HuaweiWatchGT2eCoordinator.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweiwatchgt2e;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiLECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchGT2eCoordinator extends HuaweiLECoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHGT2E;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuaweiConstants.HU_WATCHGT2E_NAME + ".*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchgt2e;
+ }
+
+ //@Override
+ //public HuaweiDeviceType getHuaweiType() {
+ // Could be SMART
+ // return HuaweiDeviceType.BLE;
+ //}
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt3/HuaweiWatchGT3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt3/HuaweiWatchGT3Coordinator.java
new file mode 100644
index 0000000..09ba4e4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt3/HuaweiWatchGT3Coordinator.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweiwatchgt3;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchGT3Coordinator extends HuaweiBRCoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHGT3;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCHGT3_NAME + "|" + HuaweiConstants.HU_WATCHGT3PRO_NAME + "|" + HuaweiConstants.HU_WATCHGT3SE_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchgt3;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ // HarmonyOS watch
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt4/HuaweiWatchGT4Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt4/HuaweiWatchGT4Coordinator.java
new file mode 100644
index 0000000..f0b862c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt4/HuaweiWatchGT4Coordinator.java
@@ -0,0 +1,52 @@
+/* Copyright (C) 2024 Vitalii Tomin
+
+ 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.devices.huawei.huaweiwatchgt4;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchGT4Coordinator extends HuaweiBRCoordinator {
+ public HuaweiWatchGT4Coordinator() {
+ super();
+ getHuaweiCoordinator().setTransactionCrypted(true);
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHGT4;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCHGT4_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchgt4;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ // HarmonyOS watch
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt5/HuaweiWatchGT5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt5/HuaweiWatchGT5Coordinator.java
new file mode 100644
index 0000000..b39cc48
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgt5/HuaweiWatchGT5Coordinator.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.huaweiwatchgt5;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchGT5Coordinator extends HuaweiBRCoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHGT5;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCHGT5_NAME + "|" + HuaweiConstants.HU_WATCHGT5PRO_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchgt5;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ // HarmonyOS watch
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgtcyber/HuaweiWatchGTCyberCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgtcyber/HuaweiWatchGTCyberCoordinator.java
new file mode 100644
index 0000000..538c443
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgtcyber/HuaweiWatchGTCyberCoordinator.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2024 Vitalii Tomin
+
+ 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.devices.huawei.huaweiwatchgtcyber;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchGTCyberCoordinator extends HuaweiBRCoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHGTCYBER;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCHGTCYBER_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchgtcyber;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ // HarmonyOS watch
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgtrunner/HuaweiWatchGTRunnerCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgtrunner/HuaweiWatchGTRunnerCoordinator.java
new file mode 100644
index 0000000..56328dc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchgtrunner/HuaweiWatchGTRunnerCoordinator.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2024 Vitalii Tomin
+
+ 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.devices.huawei.huaweiwatchgtrunner;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchGTRunnerCoordinator extends HuaweiBRCoordinator {
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHGTRUNNER;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCHGTRUNNER_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchgtrunner;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ // HarmonyOS watch
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchultimate/HuaweiWatchUltimateCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchultimate/HuaweiWatchUltimateCoordinator.java
new file mode 100644
index 0000000..6e7831c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/huaweiwatchultimate/HuaweiWatchUltimateCoordinator.java
@@ -0,0 +1,52 @@
+/* Copyright (C) 2024 Damien Gaignon
+
+ 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.devices.huawei.huaweiwatchultimate;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class HuaweiWatchUltimateCoordinator extends HuaweiBRCoordinator {
+ public HuaweiWatchUltimateCoordinator() {
+ super();
+ getHuaweiCoordinator().setTransactionCrypted(true);
+ }
+
+ @Override
+ public DeviceType getDeviceType() {
+ return DeviceType.HUAWEIWATCHULTIMATE;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("(" + HuaweiConstants.HU_WATCHULTIMATE_NAME + ").*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_huawei_watchultimate;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ // HarmonyOS watch
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/AccountRelated.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/AccountRelated.java
new file mode 100644
index 0000000..ecfb88d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/AccountRelated.java
@@ -0,0 +1,82 @@
+/* Copyright (C) 2024 Damien Gaignon, Vitalii Tomin
+
+ 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.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class AccountRelated {
+ public static final byte id = 0x1A;
+
+ public static class SendAccountToDevice {
+ public static final byte id = 0x01;
+
+ public static class Request extends HuaweiPacket {
+ public Request (ParamsProvider paramsProvider, String account) {
+ super(paramsProvider);
+
+ this.serviceId = AccountRelated.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV();
+ if (account.length() > 0) {
+ tlv.put(0x01, account);
+ } else {
+ tlv.put(0x01);
+ }
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+ }
+ }
+
+ public static class SendExtendedAccountToDevice {
+ public static final byte id = 0x05;
+
+ public static class Request extends HuaweiPacket {
+ public Request (ParamsProvider paramsProvider, boolean accountPairingOptimization, String account) {
+ super(paramsProvider);
+
+ this.serviceId = AccountRelated.id;
+ this.commandId = id;
+
+ 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);
+ }
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Alarms.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Alarms.java
new file mode 100644
index 0000000..abb996f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Alarms.java
@@ -0,0 +1,257 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.packets;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket.ParseException;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+// TODO: complete responses
+
+public class Alarms {
+
+ public static class EventAlarm {
+ public byte index;
+ public boolean status = false;
+ public byte startHour = -1;
+ public byte startMinute = -1;
+ public byte repeat = 0;
+ public String name = "Alarm";
+
+ public EventAlarm(HuaweiTLV tlv) throws ParseException {
+ this.index = tlv.getByte(0x03);
+ if (tlv.contains(0x04))
+ this.status = tlv.getBoolean(0x04);
+ if (this.status || tlv.contains(0x05)) {
+ // I think we should refuse to set the alarm to a default time if it's enabled.
+ // If it is enabled, it should have the time set.
+ this.startHour = (byte) ((tlv.getShort(0x05) >> 8) & 0xFF);
+ this.startMinute = (byte) (tlv.getShort(0x05) & 0xFF);
+ }
+ if (tlv.contains(0x06))
+ this.repeat = tlv.getByte(0x06);
+ if (tlv.contains(0x07))
+ this.name = tlv.getString(0x07);
+ }
+
+ public EventAlarm(byte index, boolean status, byte startHour, byte startMinute, byte repeat, String name) {
+ this.index = index;
+ this.status = status;
+ this.startHour = startHour;
+ this.startMinute = startMinute;
+ this.repeat = repeat;
+ this.name = name;
+ }
+
+ public HuaweiTLV asTlv() {
+ return new HuaweiTLV()
+ .put(0x03, index)
+ .put(0x04, status)
+ .put(0x05, (short) ((startHour << 8) | (startMinute & 0xFF)))
+ .put(0x06, repeat)
+ .put(0x07, name);
+ }
+
+ @Override
+ public String toString() {
+ return "EventAlarm{" +
+ "index=" + index +
+ ", status=" + status +
+ ", startHour=" + startHour +
+ ", startMinute=" + startMinute +
+ ", repeat=" + repeat +
+ ", name='" + name + '\'' +
+ '}';
+ }
+ }
+
+ public static class SmartAlarm {
+ public byte index;
+ public boolean status;
+ public byte startHour;
+ public byte startMinute;
+ public byte repeat;
+ public byte aheadTime;
+
+ public SmartAlarm(HuaweiTLV tlv) throws ParseException {
+ this.index = tlv.getByte(0x03);
+ this.status = tlv.getBoolean(0x04, false);
+ this.startHour = (byte) ((tlv.getShort(0x05, (short) 0) >> 8) & 0xFF);
+ this.startMinute = (byte) (tlv.getShort(0x05, (short) 0) & 0xFF);
+ this.repeat = tlv.getByte(0x06, (byte) 0);
+ this.aheadTime = tlv.getByte(0x07, (byte) 0);
+ }
+
+ public SmartAlarm(boolean status, byte startHour, byte startMinute, byte repeat, byte aheadTime) {
+ this.index = 1;
+ this.status = status;
+ this.startHour = startHour;
+ this.startMinute = startMinute;
+ this.repeat = repeat;
+ this.aheadTime = aheadTime;
+ }
+
+ public HuaweiTLV asTlv() {
+ return new HuaweiTLV()
+ .put(0x03, index)
+ .put(0x04, status)
+ .put(0x05, (short) ((startHour << 8) | (startMinute & 0xFF)))
+ .put(0x06, repeat)
+ .put(0x07, aheadTime);
+ }
+
+ @Override
+ public String toString() {
+ return "SmartAlarm{" +
+ "index=" + index +
+ ", status=" + status +
+ ", startHour=" + startHour +
+ ", startMinute=" + startMinute +
+ ", repeat=" + repeat +
+ ", aheadTime=" + aheadTime +
+ '}';
+ }
+ }
+
+ public static final byte id = 0x08;
+
+ public static class EventAlarmsRequest extends HuaweiPacket {
+ public static final byte id = 0x01;
+
+ // TODO: move to list
+ private final HuaweiTLV alarms;
+
+ public EventAlarmsRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Alarms.id;
+ this.commandId = id;
+
+ alarms = new HuaweiTLV();
+ }
+
+ public void addEventAlarm(EventAlarm alarm) {
+ // TODO: 5 is a max and we may need to check for that and throw an exception if passed
+ alarms.put(0x82, alarm.asTlv());
+ }
+
+ @Override
+ public List<byte[]> serialize() throws CryptoException {
+ if (this.alarms.get().size() == 0) {
+ // Empty alarms - this will disable them all
+ this.alarms.put(0x82, new HuaweiTLV().put(0x03, (byte) 0x01));
+ }
+
+ this.tlv = new HuaweiTLV().put(0x81, this.alarms);
+ this.complete = true;
+
+ return super.serialize();
+ }
+ }
+
+ public static class SmartAlarmRequest extends HuaweiPacket {
+ public static final int id = 0x02;
+
+ public SmartAlarmRequest(
+ ParamsProvider paramsProvider,
+ SmartAlarm smartAlarm
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = Alarms.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x81, new HuaweiTLV()
+ .put(0x82, smartAlarm.asTlv())
+ );
+ this.complete = true;
+ }
+ }
+
+ public static class EventAlarmsList {
+ public static final int id = 0x03;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Alarms.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x01);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public List<EventAlarm> eventAlarms;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ eventAlarms = new ArrayList<>();
+
+ HuaweiTLV tlv = this.tlv.getObject(0x81);
+ for (HuaweiTLV subTlv : tlv.getObjects(0x82)) {
+ eventAlarms.add(new EventAlarm(subTlv));
+ }
+ }
+ }
+ }
+
+ public static class SmartAlarmList {
+ public static final int id = 0x04;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Alarms.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x01);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public SmartAlarm smartAlarm;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ HuaweiTLV tlv = this.tlv.getObject(0x81);
+ if (tlv.contains(0x82)) {
+ this.smartAlarm = new SmartAlarm(tlv.getObject(0x82));
+ } else {
+ this.smartAlarm = null;
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/App.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/App.java
new file mode 100644
index 0000000..ff7af2c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/App.java
@@ -0,0 +1,144 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class App {
+ public static final byte id = 0x2a;
+
+ public static class AppDeviceParams {
+ public byte unknown1 = 0;
+ public int unknown2 = 0;
+ public String osVersion = "";
+ public String screenShape = "";
+ public int width = 0;
+ public int height = 0;
+ public int unknown3 = 0;
+ public String buildType = "";
+ }
+
+ public static class InstalledAppInfo {
+ public String packageName;
+ public String version;
+ public int unknown1;
+ public String appName;
+ public int unknown2;
+ public int versionCode;
+ public byte unknown4;
+ public byte unknown6;
+
+ public InstalledAppInfo(HuaweiTLV tlv) throws HuaweiPacket.MissingTagException {
+ this.packageName = tlv.getString(0x03);
+ this.version = tlv.getString(0x04);
+ this.unknown1 = tlv.getInteger(0x05);
+ this.appName = tlv.getString(0x06);
+ this.unknown2 = tlv.getInteger(0x07);
+ this.versionCode = tlv.getInteger(0x09);
+ this.unknown4 = tlv.getByte(0x0a);
+ //{tag: b - Value: } -
+ this.unknown6 = tlv.getByte(0x0d);
+ }
+ }
+
+ public static class AppDelete {
+ public static final byte id = 0x01;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider,
+ String packageName) {
+ super(paramsProvider);
+ this.serviceId = App.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01, (byte)1)
+ .put(0x02, packageName);
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+ }
+ }
+
+ public static class AppNames {
+ public static final byte id = 0x03;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = App.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x81);
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public List<App.InstalledAppInfo> appInfoList;
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ appInfoList = new ArrayList<>();
+ if(this.tlv.contains(0x81)) {
+ for (HuaweiTLV subTlv : this.tlv.getObject(0x81).getObjects(0x82)) {
+ appInfoList.add(new InstalledAppInfo(subTlv));
+ }
+ }
+ }
+ }
+ }
+
+ public static class AppInfoParams {
+ public static final byte id = 0x06;
+ public static class Request extends HuaweiPacket {
+
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = App.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x81);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public AppDeviceParams params = new AppDeviceParams();
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if(this.tlv.contains(0x81)) {
+ HuaweiTLV subTlv = this.tlv.getObject(0x81).getObject(0x82);
+ this.params.unknown1 = subTlv.getByte(0x03);
+ this.params.unknown2 = subTlv.getInteger(0x04);
+ this.params.osVersion = subTlv.getString(0x05);
+ if (subTlv.contains(0x06))
+ this.params.screenShape = subTlv.getString(0x06);
+ if (subTlv.contains(0x07))
+ this.params.width = subTlv.getShort(0x07);
+ if (subTlv.contains(0x08))
+ this.params.height = subTlv.getShort(0x08);
+ if (subTlv.contains(0x09))
+ this.params.unknown3 = subTlv.getInteger(0x09);
+ if(subTlv.contains(0x0a))
+ this.params.buildType = subTlv.getString(0x0a);
+ }
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Calls.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Calls.java
new file mode 100644
index 0000000..bfaf160
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Calls.java
@@ -0,0 +1,60 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+
+public class Calls {
+
+ // This doesn't include the initial calling notification, as that is handled
+ // by the Notifications class.
+
+ public static final byte id = 0x04;
+
+ // TODO: tests
+
+ public static class AnswerCallResponse extends HuaweiPacket {
+ public static final byte id = 0x01;
+
+ public enum Action {
+ CALL_ACCEPT,
+ CALL_REJECT,
+ UNKNOWN
+ }
+
+ public Action action = Action.UNKNOWN;
+
+ public AnswerCallResponse(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Calls.id;
+ this.commandId = id;
+
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws MissingTagException {
+ if (this.tlv.getByte(0x01) == 0x01) {
+ this.action = Action.CALL_REJECT;
+ } else if (this.tlv.getByte(0x01) == 0x02) {
+ this.action = Action.CALL_ACCEPT;
+ }
+ // TODO: find more values, if there are any
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/CameraRemote.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/CameraRemote.java
new file mode 100644
index 0000000..232d939
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/CameraRemote.java
@@ -0,0 +1,110 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class CameraRemote {
+ public static final byte id = 0x01;
+
+ public static class CameraRemoteSetup {
+ public static final byte id = 0x2a;
+
+ public static class Request extends HuaweiPacket {
+ public enum Event {
+ ENABLE_CAMERA,
+ CAMERA_STARTED,
+ CAMERA_STOPPED
+ }
+
+ public Request(ParamsProvider paramsProvider, Event event) {
+ super(paramsProvider);
+
+ this.serviceId = CameraRemote.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV();
+ switch (event) {
+ case ENABLE_CAMERA:
+ this.tlv.put(0x01, (byte) 0x00);
+ break;
+ case CAMERA_STARTED:
+ this.tlv.put(0x01, (byte) 0x01);
+ break;
+ case CAMERA_STOPPED:
+ this.tlv.put(0x01, (byte) 0x02);
+ break;
+ }
+
+ this.complete = true;
+ this.isEncrypted = true;
+ }
+ }
+ }
+
+ public static class CameraRemoteStatus {
+ public static final byte id = 0x29;
+
+ public static class Request extends HuaweiPacket {
+ // All responses are async, and must be ACK-ed
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = CameraRemote.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x7f, 0x186A0);
+
+ this.complete = true;
+ this.isEncrypted = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public enum Event {
+ OPEN_CAMERA,
+ TAKE_PICTURE,
+ CLOSE_CAMERA
+ }
+
+ public Event event;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = CameraRemote.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ switch (this.tlv.getByte(0x01)) {
+ case 1:
+ this.event = Event.OPEN_CAMERA;
+ break;
+ case 2:
+ this.event = Event.TAKE_PICTURE;
+ break;
+ case 3:
+ this.event = Event.CLOSE_CAMERA;
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Contacts.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Contacts.java
new file mode 100644
index 0000000..4de952b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Contacts.java
@@ -0,0 +1,93 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets;
+
+import java.util.ArrayList;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+import nodomain.freeyourgadget.gadgetbridge.model.Contact;
+
+public class Contacts {
+ public static final byte id = 0x03;
+
+ public static class ContactsSet {
+ public static final byte id = 0x01;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, ArrayList<? extends Contact> contacts, int maxCount) {
+ super(paramsProvider);
+
+ this.serviceId = Contacts.id;
+ this.commandId = id;
+
+ HuaweiTLV contacts_tlv = new HuaweiTLV();
+ for(int i = 0; i < maxCount; i++) {
+ HuaweiTLV contact= new HuaweiTLV()
+ .put(0x03, (byte) (i + 1));
+ if(i < contacts.size()) {
+ contact.put(0x4, contacts.get(i).getName())
+ .put(0x85, new HuaweiTLV().put(0x86, new HuaweiTLV().put(0x7, "Mobile").put(0x8, contacts.get(i).getNumber())));
+ }
+ contacts_tlv.put(0x82, contact);
+ }
+
+ this.tlv = new HuaweiTLV()
+ .put(0x81, contacts_tlv);
+
+ this.complete = true;
+ this.isEncrypted = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public boolean isOk;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Contacts.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ isOk = this.tlv.getInteger(0x7f) == 0x000186A0;
+ }
+ }
+ }
+
+ public static class ContactsCount {
+ public static final byte id = 0x02;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Contacts.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x1)
+ .put(0x2);
+
+ this.complete = true;
+ this.isEncrypted = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public int maxCount;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Contacts.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ maxCount = this.tlv.getByte(0x01);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/DeviceConfig.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/DeviceConfig.java
new file mode 100644
index 0000000..5a79c62
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/DeviceConfig.java
@@ -0,0 +1,1756 @@
+/* Copyright (C) 2024 Damien Gaignon, Jos茅 Rebelo, Martin.JM
+
+ 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.devices.huawei.packets;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+import java.util.TreeMap;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiCrypto;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiUtil;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiCoordinatorSupplier.HuaweiDeviceType;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV.TLV;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+// TODO: complete responses
+
+public class DeviceConfig {
+ public static final byte id = 0x01;
+
+ public static class LinkParams {
+ public static final byte id = 0x01;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, HuaweiDeviceType deviceType) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01)
+ .put(0x02)
+ .put(0x03)
+ .put(0x04);
+ if (deviceType == HuaweiDeviceType.AW) {
+ this.tlv.put(0x06);
+ }
+ this.complete = true;
+ this.isEncrypted = false;
+ this.isSliced = false;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte protocolVersion = 0x00;
+ public short mtu = 0x0014;
+ public short sliceSize = 0x00f4;
+ public byte authVersion = 0x00;
+ public byte[] serverNonce = new byte[16];
+ public byte deviceSupportType = 0x00;
+ public byte authAlgo = 0x00;
+ public byte bondState = 0x00;
+ public short interval = 0x0;
+ public byte encryptMethod = 0x00;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (this.tlv.contains(0x01))
+ this.protocolVersion = this.tlv.getByte(0x01);
+
+ if (this.tlv.contains(0x02))
+ this.sliceSize = this.tlv.getShort(0x02);
+
+ if (this.tlv.contains(0x03))
+ this.mtu = this.tlv.getShort(0x03);
+
+ if (this.tlv.contains(0x04))
+ this.interval = this.tlv.getShort(0x04);
+
+ System.arraycopy(this.tlv.getBytes(0x05), 2, this.serverNonce, 0, 16);
+ this.authVersion = (byte)this.tlv.getBytes(0x05)[1];
+
+ if (this.tlv.contains(0x07))
+ this.deviceSupportType = this.tlv.getByte(0x07);
+
+ if (this.tlv.contains(0x08))
+ this.authAlgo = this.tlv.getByte(0x08);
+
+ if (this.tlv.contains(0x09))
+ this.bondState = this.tlv.getByte(0x09);
+
+ if (this.tlv.contains(0x0C))
+ this.encryptMethod = this.tlv.getByte(0x0C);
+ }
+ }
+ }
+
+ public static class SupportedServices {
+ public static final byte id = 0x02;
+
+ // notDeviceCapabilities = 0x1C, 0x1E, 0x1F, 0x28, 0x29, 0x2C, 0x2F, 0x31
+ // but services = 0x1E, 0x28, 0x2C, 0x31
+ // service 0x21 depends on MiddleWear support
+ public static final byte[] knownSupportedServices = new byte[] {
+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
+ 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
+ 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1D, 0x20,
+ 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2A, 0x2B, 0x2D, 0x2E,
+ 0x30, 0x32, 0x33, 0x34, 0x35
+ };
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, byte[] allSupportedServices) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01, allSupportedServices);
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte[] supportedServices;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.supportedServices = this.tlv.getBytes(0x02);
+ }
+ }
+
+ public static class OutgoingRequest extends HuaweiPacket {
+ public byte[] allSupportedServices = null;
+
+ public OutgoingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.complete = false;
+ }
+
+ @Override
+ public List<byte[]> serialize() throws CryptoException {
+ return null;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.allSupportedServices = this.tlv.getBytes(0x01);
+ }
+ }
+ }
+
+ public static class SupportedCommands {
+ public static final byte id = 0x03;
+
+ public static final TreeMap<Integer, byte[]> commandsPerService = new TreeMap<Integer, byte[]>() {{
+ put(0x01, new byte[] {0x04, 0x07, 0x08, 0x09, 0x0A, 0x0D, 0x0E, 0x10, 0x11, 0x12, 0x13, 0x14, 0x1B, 0x1A, 0x1D, 0x21, 0x22, 0x23, 0x24, 0x29, 0x2A, 0x2B, 0x32, 0x2E, 0x31, 0x30, 0x35, 0x36, 0x37, 0x2F});
+ put(0x02, new byte[] {0x01, 0x04, 0x05, 0x06, 0x07, 0x08});
+ put(0x03, new byte[] {0x01, 0x03, 0x04});
+ put(0x04, new byte[] {0x01});
+ put(0x05, new byte[] {0x01});
+ put(0x06, new byte[] {0x01});
+ put(0x07, new byte[] {0x01, 0x03, 0x05, 0x07, 0x08, 0x09, 0x0A, 0x0E, 0x10, 0x13, 0x16, 0x15, 0x17, 0x18, 0x1B, 0x1C, 0x1D, 0x1E, 0x21, 0x22, 0x23, 0x24, 0x25, 0x28, 0x29, 0x06, 0x1F});
+ put(0x08, new byte[] {0x01, 0x02, 0x03});
+ put(0x09, new byte[] {0x01, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F});
+ put(0x0A, new byte[] {0x01, 0x09, 0x0A});
+ put(0x0B, new byte[] {0x01, 0x03});
+ put(0x0C, new byte[] {0x01});
+ put(0x0D, new byte[] {0x01});
+ put(0x0F, new byte[] {0x01, 0x03, 0x05, 0x06, 0x07, 0x08, 0x0A, 0x0B, 0x0C});
+ put(0x10, new byte[] {0x01});
+ put(0x11, new byte[] {0x01});
+ put(0x12, new byte[] {0x01});
+ put(0x13, new byte[] {0x01});
+ put(0x14, new byte[] {0x01});
+ put(0x15, new byte[] {0x01});
+ put(0x16, new byte[] {0x01, 0x03, 0x07});
+ put(0x17, new byte[] {0x01, 0x04, 0x06, 0x07, 0x0B, 0x0C, 0x10, 0x12, 0x15, 0x17});
+ put(0x18, new byte[] {0x01, 0x02, 0x04, 0x05, 0x06, 0x09});
+ put(0x19, new byte[] {0x01, 0x04});
+ put(0x1A, new byte[] {0x01, 0x03, 0x07, 0x05, 0x06});
+ put(0x1B, new byte[] {0x01, 0x0F, 0x19, 0x1A});
+ // No 0x1C
+ put(0x1D, new byte[] {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A});
+ // No 0x1E
+ // No 0x1F
+ put(0x20, new byte[] {0x01, 0x02, 0x03, 0x04, 0x09, 0x0A});
+ //put(0x21, new byte[] {0x01});
+ put(0x22, new byte[] {0x01});
+ put(0x23, new byte[] {0x02, 0x0B});
+ put(0x24, new byte[] {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C});
+ put(0x25, new byte[] {0x02, 0x04, 0x0E});
+ put(0x26, new byte[] {0x02, 0x03});
+ put(0x27, new byte[] {0x01, 0x0E});
+ // No 0x28
+ // No 0x29
+ put(0x2A, new byte[] {0x01, 0x06});
+ put(0x2B, new byte[] {0x12});
+ // No 0x2C
+ put(0x2D, new byte[] {0x01});
+ put(0x2E, new byte[] {0x01, 0x02, 0x03});
+ // No 0x2F
+ put(0x30, new byte[] {0x01});
+ // No 0x31
+ put(0x32, new byte[] {0x01});
+ put(0x33, new byte[] {0x01, 0x2});
+ put(0x34, new byte[] {0x01});
+ put(0x35, new byte[] {0x03, 0x04});
+ }};
+
+
+ public static class Request extends HuaweiPacket {
+ private int maxSize;
+
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV();
+
+ // slice size is the max size
+ // But there is a 4 byte header,
+ // a two byte body header,
+ // a two byte footer,
+ // three bytes get added for the 0x81 tag,
+ // and 21 for the encryption stuff
+ this.maxSize = this.paramsProvider.getSliceSize() - 32;
+ // Now it gets aligned to 16 byte blocks
+ // And it gets a header and two length bytes
+ // For CBC there is also at least one byte padding
+ this.maxSize = this.maxSize - (this.maxSize % 16) - 4;
+ }
+
+ public boolean addCommandsForService(byte service, byte[] commands) {
+ // tlv length is what we have already
+ // commands is what we'll add
+ // together with a 3-byte 0x02 tag and two bytes for length
+ if (this.tlv.length() + commands.length + 5 > this.maxSize) { //this.paramsProvider.mtu - 5 - 2 - 2 - 2 - 21) {
+ return false;
+ }
+ this.tlv.put(0x02, service).put(0x03, commands);
+ return true;
+ }
+
+ @Override
+ public List<byte[]> serialize() throws CryptoException {
+ this.tlv = new HuaweiTLV()
+ .put(0x81, this.tlv);
+ this.complete = true;
+ return super.serialize();
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public static class CommandsList {
+ public int service;
+ public byte[] commands;
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("CommandsList{service=");
+ sb.append(Integer.toHexString(service));
+ sb.append(", commands=[");
+ for (byte b : commands) {
+ sb.append(Integer.toHexString(b));
+ if (b != commands[commands.length - 1]) // Elements should be unique
+ sb.append(", ");
+ }
+ sb.append("]}");
+ return sb.toString();
+ }
+ }
+
+ public List<CommandsList> commandsLists;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.commandsLists = new ArrayList<>();
+ CommandsList commandsList = null;
+ HuaweiTLV containerTLV = this.tlv.getObject(0x81);
+
+ for (HuaweiTLV.TLV tlv : containerTLV.get()) {
+ if ((int) tlv.getTag() == 0x02) {
+ commandsList = new CommandsList();
+ commandsList.service = ByteBuffer.wrap(tlv.getValue()).get();
+ } else if ((int) tlv.getTag() == 0x04) {
+ if (commandsList == null)
+ throw new SupportedCommandsListException("Commandslist is not yet set");
+ ByteBuffer buffer = ByteBuffer.allocate(tlv.getValue().length);
+ for (int i = 0; i < tlv.getValue().length; i++) {
+ if ((int) tlv.getValue()[i] == 1)
+ buffer.put((byte) (commandsPerService.get(commandsList.service)[i]));
+ }
+ commandsList.commands = new byte[buffer.position()];
+ ((ByteBuffer) buffer.rewind()).get(commandsList.commands);
+ this.commandsLists.add(commandsList);
+ } else
+ throw new SupportedCommandsListException("Unknown tag encountered");
+ }
+ if (this.commandsLists.isEmpty())
+ throw new SupportedCommandsListException("CommandLists is empty");
+ }
+ }
+
+ // TODO: LogRequest
+ }
+
+ public static class DateFormat {
+ public static final byte id = 0x04;
+
+ public static class Request extends HuaweiPacket {
+ public Request(
+ ParamsProvider paramsProvider,
+ byte dateFormat,
+ byte timeFormat
+ ) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x81, new HuaweiTLV()
+ .put(0x02, dateFormat)
+ .put(0x03, timeFormat)
+ );
+ this.complete = true;
+ }
+ }
+
+ public static class OutgoingRequest extends HuaweiPacket {
+ public byte dateFormat;
+ public byte timeFormat;
+
+ public OutgoingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.complete = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (this.tlv.contains(0x02))
+ this.dateFormat = this.tlv.getByte(0x02);
+
+ if (this.tlv.contains(0x03))
+ this.timeFormat = this.tlv.getByte(0x03);
+ }
+ }
+ }
+
+ public static class TimeRequest extends HuaweiPacket {
+ public static final byte id = 0x05;
+
+ public TimeRequest(ParamsProvider paramsProvider, final Calendar now) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ ByteBuffer timeAndZoneId = ByteBuffer.wrap(HuaweiUtil.getTimeAndZoneId(now));
+ this.tlv = new HuaweiTLV()
+ .put(0x01, timeAndZoneId.getInt(0))
+ .put(0x02, timeAndZoneId.getShort(4));
+ this.complete = true;
+ }
+
+ public TimeRequest(ParamsProvider paramsProvider) {
+ this(paramsProvider, Calendar.getInstance());
+ }
+
+ public static class Response extends HuaweiPacket {
+ public int deviceTime=0;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (this.tlv.contains(0x01)) {
+ this.deviceTime = this.tlv.getInteger(0x01);
+ }
+ }
+ }
+
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class ProductInfo {
+ public static final byte id = 0x07;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request(ParamsProvider paramsProvider, HuaweiDeviceType deviceType) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV();
+ if (deviceType == HuaweiDeviceType.AW) {
+ int[] tags = {0x01, 0x02, 0x03, 0x07, 0x09, 0x0A, 0x0c, 0x11};
+ for (int i: tags) {
+ this.tlv.put(i);
+ }
+ } else {
+ int[] tags = {0x01, 0x02, 0x07, 0x09, 0x0A, 0x11, 0x12, 0x16, 0x1A, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23};
+ for (int i : tags) {
+ this.tlv.put(i);
+ }
+ } // else if (AW Compatible)
+ // this.tlv).put(0x01).put(0x02).put(0x07).put(0x09);
+ // }
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ // TODO: extend:
+ // public static final int BTVersion = 0x01;
+ // public static final int productType = 0x02;
+ // public static final int phoneNumber = 0x04;
+ // public static final int macAddress = 0x05;
+ // public static final int IMEI = 0x06;
+ // public static final int openSourceVersion = 0x08;
+ // public static final int serialNumber = 0x09;
+ // public static final int eMMCId = 0x0B;
+ // public static final int healthAppSupport = 0x0D;
+
+ public String hardwareVersion;
+ public String softwareVersion;
+ public String productModel;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (this.tlv.contains(0x03))
+ this.hardwareVersion = this.tlv.getString(0x03);
+ this.softwareVersion = this.tlv.getString(0x07);
+ this.productModel = this.tlv.getString(0x0A).trim();
+ }
+ }
+
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class Bond {
+ public static final byte id = 0x0E;
+
+ public static class Request extends HuaweiPacket {
+ public Request(
+ ParamsProvider paramsProvider,
+ byte[] clientSerial,
+ byte[] key,
+ byte[] iv
+ ) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01)
+ .put(0x03, (byte) 0x00)
+ .put(0x05, clientSerial)
+ .put(0x06, key)
+ .put(0x07, iv);
+ this.isEncrypted = false;
+ this.complete = true;
+ }
+ }
+
+ public static class OutgoingRequest extends HuaweiPacket {
+ public byte[] clientSerial;
+ public byte[] bondingKey;
+ public byte[] iv;
+
+ public OutgoingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.complete = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.clientSerial = this.tlv.getBytes(0x05);
+ this.bondingKey = this.tlv.getBytes(0x06);
+ this.iv = this.tlv.getBytes(0x07);
+ }
+ }
+ }
+
+ public static class BondParams {
+ public static final byte id = 0x0F;
+
+ public static class Request extends HuaweiPacket {
+ public Request(
+ ParamsProvider paramsProvider,
+ byte[] clientSerial,
+ byte[] mac
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01)
+ .put(0x03, clientSerial)
+ .put(0x04, (byte) 0x02)
+ .put(0x05)
+ .put(0x07, mac)
+ .put(0x09);
+ this.isEncrypted = false;
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte status;
+ public long encryptionCounter;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.status = this.tlv.getByte(0x01);
+ this.encryptionCounter = this.tlv.getInteger(0x09) & 0xFFFFFFFFL;
+ }
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class ActivityType {
+ public static final int id = 0x12;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ }
+ }
+
+ }
+
+ public static class Auth {
+ public static final byte id = 0x13;
+
+ public static class Request extends HuaweiPacket {
+ public Request(
+ ParamsProvider paramsProvider,
+ byte[] challenge,
+ byte[] nonce
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, challenge)
+ .put(0x02, nonce);
+ if (paramsProvider.getAuthMode() == 0x02)
+ this.tlv.put(0x03, paramsProvider.getAuthAlgo());
+ this.isEncrypted = false;
+ this.complete = true;
+ }
+
+ }
+
+ public static class OutgoingRequest extends HuaweiPacket {
+ public byte[] challenge;
+ public byte[] nonce;
+
+ public OutgoingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.complete = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.challenge = this.tlv.getBytes(0x01);
+ this.nonce = this.tlv.getBytes(0x02);
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte[] challengeResponse;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.challengeResponse = this.tlv.getBytes(0x01);
+ }
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class BatteryLevel {
+ public static final byte id = 0x08;
+ public static final byte id_change = 0x27; // Same format, async (receive) only
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01);
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte level;
+
+ public byte[] multi_level;
+ public byte[] status; // TODO: enum
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ // This differs per watch, so we handle it ourselves in parseTlv
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.level = this.tlv.getByte(0x01);
+ this.multi_level = this.tlv.getBytes(0x02, null);
+ this.status = this.tlv.getBytes(0x03, null);
+ }
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class ActivateOnLiftRequest extends HuaweiPacket {
+ public static final byte id = 0x09;
+
+ public ActivateOnLiftRequest(ParamsProvider paramsProvider, boolean activate) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, activate);
+
+ this.complete = true;
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class DndDeleteRequest extends HuaweiPacket {
+ public static final int id = 0x0B;
+
+ public DndDeleteRequest(HuaweiPacket.ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x81, new HuaweiTLV()
+ .put(0x02, (byte) 0x01)
+ );
+ this.complete = true;
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class DndAddRequest extends HuaweiPacket {
+ public static final int id = 0x0C;
+
+ public DndAddRequest(
+ HuaweiPacket.ParamsProvider paramsProvider,
+ boolean dndEnable,
+ byte[] start,
+ byte[] end,
+ int cycle,
+ int dndLiftWristType,
+ boolean allowDndLiftWrist
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ HuaweiTLV dndPacket = new HuaweiTLV()
+ .put(0x02, (byte) 0x01)
+ .put(0x03, dndEnable)
+ .put(0x04, (byte) 0x00)
+ .put(0x05, start)
+ .put(0x06, end)
+ .put(0x07, (byte) cycle);
+
+ if (allowDndLiftWrist) {
+ dndPacket.put(0x08, (short) (dndLiftWristType));
+ }
+ this.tlv = new HuaweiTLV()
+ .put(0x81, dndPacket);
+ this.complete = true;
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class FactoryResetRequest extends HuaweiPacket {
+ public static final byte id = 0x0D;
+
+ public FactoryResetRequest(HuaweiPacket.ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, (byte) 0x01);
+
+ this.complete = true;
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class PhoneInfo {
+ public static final byte id = 0x10;
+
+ public static class Request extends HuaweiPacket {
+ public Request(HuaweiPacket.ParamsProvider paramsProvider, byte[] phoneInfo) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV();
+ for (byte b : phoneInfo) {
+ switch (b) {
+ case 0x0:
+ break;
+ case 0xf:
+ break;
+ case 0x11:
+ this.tlv.put(b, 1400106310); // Force AppVersion to 14.1.6.310
+ break;
+ case 0x15:
+ this.tlv.put(b); // Force buildOSPlatformVersion to ""
+ break;
+ case 0x10: // Force EmuiBuildVersion to 0x00
+ case 0x13: // Force buildOsEnable to 0x00
+ case 0x14: // Force buildOSApiVersion to 0x00
+ default:
+ this.tlv.put(b, (byte)00);
+ }
+ }
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public boolean isAck = false;
+ public byte[] info;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws MissingTagException {
+ if (this.tlv.get().size() == 1 && this.tlv.contains(0x7f) && this.tlv.getInteger(0x7f) == 0x186A0) {
+ this.isAck = true;
+ return;
+ }
+
+ info = new byte[this.tlv.length()];
+ int i = 0;
+ for (TLV tlv : this.tlv.get()) {
+ info[i] = tlv.getTag();
+ i += 1;
+ }
+ }
+ }
+
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class DeviceStatus {
+ public static final byte id = 0x16;
+
+ public static class Request extends HuaweiPacket {
+ public Request(HuaweiPacket.ParamsProvider paramsProvider, boolean askStatus) { // status or notify
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV();
+ if (askStatus) {
+ this.tlv.put(0x01);
+ } else {
+ this.tlv.put(0x02, (byte)0x00);
+ }
+ this.isEncrypted = false;
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte status = -1;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ // AW70 doesn't seem to have this
+ if (this.tlv.contains(0x01))
+ this.status = this.tlv.getByte(0x01);
+ }
+ }
+
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class NavigateOnRotateRequest extends HuaweiPacket {
+ public static final byte id = 0x1B;
+
+ public NavigateOnRotateRequest(HuaweiPacket.ParamsProvider paramsProvider, boolean navigate) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, navigate);
+
+ this.complete = true;
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class WearLocationRequest extends HuaweiPacket {
+ public static final byte id = 0x1A;
+
+ public WearLocationRequest(HuaweiPacket.ParamsProvider paramsProvider, byte location) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, location);
+
+ this.complete = true;
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class DndLiftWristType {
+ public static final int id = 0x1D;
+
+ public static class Request extends HuaweiPacket {
+ public Request(HuaweiPacket.ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01);
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public int dndLiftWristType;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.dndLiftWristType = (int) this.tlv.getShort(0x01);
+ }
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ // TODO: set (earphone) double tap action 0x1f
+ // TODO: get (earphone) double tap action 0x20
+
+ public static class HiChain {
+ public static final int id = 0x28;
+
+ public static class Request {
+ private final int operationCode;
+ private final long requestId;
+ private final byte[] selfAuthId;
+ private final String groupId;
+ private JSONObject version = null;
+ private JSONObject jsonPayload = null;
+ private JSONObject value = null;
+
+ public Request (int operationCode, long requestId, byte[] selfAuthId, String groupId) {
+ this.operationCode = operationCode;
+ this.requestId = requestId;
+ this.selfAuthId = selfAuthId;
+ this.groupId = groupId;
+ }
+
+ public class BaseStep extends HuaweiPacket {
+ public BaseStep (HuaweiPacket.ParamsProvider paramsProvider, int messageId) throws SerializeException {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = HiChain.id;
+ this.isSliced = true;
+ this.isEncrypted = false;
+ this.complete = true;
+ version = new JSONObject();
+ jsonPayload = new JSONObject();
+ value = new JSONObject();
+ createJson(messageId);
+ }
+ }
+
+ public class StepOne extends BaseStep {
+
+ public StepOne (
+ HuaweiPacket.ParamsProvider paramsProvider,
+ int messageId,
+ byte[] isoSalt,
+ byte[] seed
+ ) throws SerializeException {
+ super(paramsProvider, messageId);
+ // createJson(1); //messageId);
+ try {
+ jsonPayload
+ .put("isoSalt", StringUtils.bytesToHex(isoSalt))
+ .put("peerAuthId", StringUtils.bytesToHex(selfAuthId))
+ .put("operationCode", operationCode)
+ .put("seed", StringUtils.bytesToHex(seed))
+ .put("peerUserType", 0x00);
+ if (operationCode == 0x02) {
+ jsonPayload
+ .put("pkgName", "com.huawei.devicegroupmanage")
+ .put("serviceType", groupId)
+ .put("keyLength", 0x20);
+ value.put("isDeviceLevel", false);
+ }
+ this.tlv = new HuaweiTLV()
+ .put(0x01, value.toString())
+ .put(0x02, (byte)operationCode)
+ .put(0x03, ByteBuffer.allocate(8).putLong(requestId).array());
+ //.put(0x04, 0x00)
+ //.put(0x05, 0x00);
+ } catch (JSONException e) {
+ throw new SerializeException("HiChain Step1 JSON exception", e);
+ }
+ }
+ }
+
+ public class StepTwo extends BaseStep {
+ public StepTwo (
+ HuaweiPacket.ParamsProvider paramsProvider,
+ int messageId,
+ byte[] token
+ ) throws SerializeException {
+ super(paramsProvider, messageId);
+ // createJson(2); //messageId);
+ try {
+ jsonPayload
+ .put("peerAuthId", StringUtils.bytesToHex(selfAuthId))
+ .put("token", StringUtils.bytesToHex(token));
+ if (operationCode == 0x02) value.put("isDeviceLevel", false);
+ this.tlv = new HuaweiTLV()
+ .put(0x01, value.toString())
+ .put(0x02, (byte)operationCode)
+ .put(0x03, ByteBuffer.allocate(8).putLong(requestId).array());
+ } catch (JSONException e) {
+ throw new SerializeException("HiChain Step 2 JSON exception", e);
+ }
+ }
+ }
+
+ public class StepThree extends BaseStep {
+ public StepThree (
+ HuaweiPacket.ParamsProvider paramsProvider,
+ int messageId,
+ byte[] nonce,
+ byte[] encData
+ ) throws SerializeException {
+ super(paramsProvider, messageId);
+ // createJson(3);
+ try {
+ jsonPayload
+ .put("nonce", StringUtils.bytesToHex(nonce))
+ .put("encData", StringUtils.bytesToHex(encData));
+ this.tlv = new HuaweiTLV()
+ .put(0x01, value.toString())
+ .put(0x02, (byte)operationCode)
+ .put(0x03, ByteBuffer.allocate(8).putLong(requestId).array());
+ } catch (JSONException e) {
+ throw new SerializeException("HiChain Step 3 JSON exception", e);
+ }
+ }
+ }
+
+ public class StepFour extends BaseStep {
+ public StepFour (
+ HuaweiPacket.ParamsProvider paramsProvider,
+ int messageId,
+ byte[] nonce,
+ byte[] encResult
+ ) throws SerializeException {
+ super(paramsProvider, messageId);
+ // if (operationCode == 0x01) {
+ // createJson(4); //messageId);
+ // } else {
+ // createJson(3);
+ // }
+ try {
+ jsonPayload
+ .put("nonce", StringUtils.bytesToHex(nonce)) //generateRandom
+ .put("encResult", StringUtils.bytesToHex(encResult))
+ .put("operationCode", operationCode);
+ this.tlv = new HuaweiTLV()
+ .put(0x01, value.toString())
+ .put(0x02, (byte)operationCode)
+ .put(0x03, ByteBuffer.allocate(8).putLong(requestId).array());
+ } catch (JSONException e) {
+ throw new SerializeException("HiChain Step 4 JSON exception", e);
+ }
+ }
+ }
+
+ private void createJson(int messageId) throws HuaweiPacket.SerializeException {
+ if (operationCode == 0x02) {
+ messageId |= 0x10;
+ }
+ try {
+ version
+ .put("minVersion", "1.0.0")
+ .put("currentVersion", "2.0.16");
+ jsonPayload
+ .put("version", version);
+ value
+ .put("authForm", 0x00)
+ .put("payload", jsonPayload)
+ .put("groupAndModuleVersion", "2.0.1")
+ .put("message", messageId);
+ if (operationCode == 0x01) {
+ value
+ .put("requestId", Long.toString(requestId))
+ .put("groupId", groupId)
+ .put("groupName", "health_group_name")
+ .put("groupOp", 2)
+ .put("groupType", 256)
+ .put("peerDeviceId", new String(selfAuthId, StandardCharsets.UTF_8))
+ .put("connDeviceId", new String(selfAuthId, StandardCharsets.UTF_8))
+ .put("appId", "com.huawei.health")
+ .put("ownerName", "");
+ }
+ } catch (JSONException e) {
+ throw new HuaweiPacket.SerializeException("Create json Exception", e);
+ }
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ // TODO: get rid of GB import...
+ // TODO: add operation code
+
+ public static class Step1Data {
+ public byte[] isoSalt;
+ public byte[] peerAuthId;
+ public int peerUserType;
+ public byte[] token;
+
+ public Step1Data(JSONObject payload) throws JSONException {
+ this.isoSalt = GB.hexStringToByteArray(payload.getString("isoSalt"));
+ this.peerAuthId = GB.hexStringToByteArray(payload.getString("peerAuthId"));
+ this.peerUserType = payload.getInt("peerUserType");
+ this.token = GB.hexStringToByteArray(payload.getString("token"));
+ }
+
+ @Override
+ public String toString() {
+ return "Step1Data{" +
+ "isoSalt=" + StringUtils.bytesToHex(isoSalt) +
+ ", peerAuthId=" + StringUtils.bytesToHex(peerAuthId) +
+ ", peerUserType=" + peerUserType +
+ ", token=" + StringUtils.bytesToHex(token) +
+ '}';
+ }
+ }
+
+ public static class Step2Data {
+ public byte[] returnCodeMac;
+
+ public Step2Data(JSONObject payload) throws JSONException {
+ this.returnCodeMac = GB.hexStringToByteArray(payload.getString("returnCodeMac"));
+ }
+
+ @Override
+ public String toString() {
+ return "Step2Data{" +
+ "returnCodeMac=" + StringUtils.bytesToHex(returnCodeMac) +
+ '}';
+ }
+ }
+
+ public static class Step3Data {
+ public byte[] nonce;
+ public byte[] encAuthToken;
+
+ public Step3Data(JSONObject payload) throws JSONException {
+ this.nonce = GB.hexStringToByteArray(payload.getString("nonce"));
+ this.encAuthToken = GB.hexStringToByteArray(payload.getString("encAuthToken"));
+ }
+
+ @Override
+ public String toString() {
+ return "Step3Data{" +
+ "nonce=" + StringUtils.bytesToHex(nonce) +
+ ", encAuthToken=" + StringUtils.bytesToHex(encAuthToken) +
+ '}';
+ }
+ }
+
+ public static class Step4Data {
+ public String data;
+
+ public Step4Data(HuaweiTLV tlv) throws ParseException {
+ if (tlv.contains(0x01))
+ this.data = tlv.getString(0x01);
+ }
+
+ @Override
+ public String toString() {
+ return "Step4Data{" +
+ "data='" + data + '\'' +
+ '}';
+ }
+ }
+
+ // TODO: enum?
+ // 0 is json?
+ // 2 is raw string?
+ public byte type;
+
+ public JSONObject value;
+ public JSONObject jsonPayload;
+
+ public byte step;
+ // public int operationCode; // TODO
+
+ public Step1Data step1Data;
+ public Step2Data step2Data;
+ public Step3Data step3Data;
+ public Step4Data step4Data;
+ public int errorCode = 0;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = HiChain.id;
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.type = this.tlv.getByte(0x04);
+
+ if (this.type == 0x00) {
+ try {
+ this.value = new JSONObject(this.tlv.getString(0x01));
+ this.jsonPayload = value.getJSONObject("payload");
+
+ // Ugly, but should work
+ if (jsonPayload.has("isoSalt")) {
+ this.step = 0x01;
+ this.step1Data = new Step1Data(jsonPayload);
+ } else if (jsonPayload.has("returnCodeMac")) {
+ this.step = 0x02;
+ this.step2Data = new Step2Data(jsonPayload);
+ } else if (jsonPayload.has("encAuthToken")) {
+ this.step = 0x03;
+ this.step3Data = new Step3Data(jsonPayload);
+ }
+ if (jsonPayload.has("errorCode")) {
+ this.errorCode = jsonPayload.getInt("errorCode");
+ }
+ } catch (JSONException e) {
+ throw new JsonException("", e);
+ }
+ } else {
+ this.step = 0x04;
+ this.step4Data = new Step4Data(this.tlv);
+ }
+ }
+ }
+
+ public static class OutgoingRequest extends HuaweiPacket {
+ public static class Step1Data {
+ public byte[] isoSalt;
+ public byte[] peerAuthId;
+ public byte[] seed;
+ public int peerUserType;
+
+ String serviceType; // Optional
+
+ public Step1Data(JSONObject payload) throws JSONException {
+ this.isoSalt = GB.hexStringToByteArray(payload.getString("isoSalt"));
+ this.peerAuthId = GB.hexStringToByteArray(payload.getString("peerAuthId"));
+ this.seed = GB.hexStringToByteArray(payload.getString("seed"));
+ this.peerUserType = payload.getInt("peerUserType");
+
+ if (payload.has("serviceType"))
+ this.serviceType = payload.getString("serviceType");
+ }
+
+ @Override
+ public String toString() {
+ return "Step1Data{" +
+ "isoSalt=" + StringUtils.bytesToHex(isoSalt) +
+ ", peerAuthId=" + StringUtils.bytesToHex(peerAuthId) +
+ ", seed=" + StringUtils.bytesToHex(seed) +
+ ", peerUserType=" + peerUserType +
+ ", serviceType='" + serviceType + '\'' +
+ '}';
+ }
+ }
+
+ public static class Step2Data {
+ public byte[] peerAuthId;
+ public byte[] token;
+
+ public boolean isDeviceLevel = false; // Optional
+
+ public Step2Data(JSONObject payload) throws JSONException {
+ this.peerAuthId = GB.hexStringToByteArray(payload.getString("peerAuthId"));
+ this.token = GB.hexStringToByteArray(payload.getString("token"));
+
+ if (payload.has("isDeviceLevel"))
+ this.isDeviceLevel = payload.getBoolean("isDeviceLevel");
+ }
+
+ @Override
+ public String toString() {
+ return "Step2Data{" +
+ "peerAuthId=" + StringUtils.bytesToHex(peerAuthId) +
+ ", token=" + StringUtils.bytesToHex(token) +
+ ", isDeviceLevel=" + isDeviceLevel +
+ '}';
+ }
+ }
+
+ public static class Step3Data {
+ public byte[] nonce;
+ public byte[] encData;
+
+ public Step3Data(JSONObject payload) throws JSONException {
+ this.nonce = GB.hexStringToByteArray(payload.getString("nonce"));
+ this.encData = GB.hexStringToByteArray(payload.getString("encData"));
+ }
+
+ @Override
+ public String toString() {
+ return "Step3Data{" +
+ "nonce=" + StringUtils.bytesToHex(nonce) +
+ ", encData=" + StringUtils.bytesToHex(encData) +
+ '}';
+ }
+ }
+
+ public static class Step4Data {
+ public byte[] nonce;
+ public byte[] encResult;
+
+ public Step4Data(JSONObject payload) throws JSONException {
+ this.nonce = GB.hexStringToByteArray(payload.getString("nonce"));
+ this.encResult = GB.hexStringToByteArray(payload.getString("encResult"));
+ }
+
+ @Override
+ public String toString() {
+ return "Step4Data{" +
+ "nonce=" + StringUtils.bytesToHex(nonce) +
+ ", encResult=" + StringUtils.bytesToHex(encResult) +
+ '}';
+ }
+ }
+
+ public int step;
+
+ public long requestId;
+ public byte[] selfAuthId;
+ public String groupId;
+ public JSONObject jsonPayload = null;
+ public JSONObject value = null;
+
+ public Step1Data step1Data;
+ public Step2Data step2Data;
+ public Step3Data step3Data;
+ public Step4Data step4Data;
+
+ public OutgoingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.complete = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ try {
+ value = new JSONObject(this.tlv.getString(0x01));
+ jsonPayload = value.getJSONObject("payload");
+
+ if (jsonPayload.has("isoSalt")) {
+ this.step = 1;
+ this.step1Data = new Step1Data(jsonPayload);
+ } else if (jsonPayload.has("token")) {
+ this.step = 2;
+ this.step2Data = new Step2Data(jsonPayload);
+ } else if (jsonPayload.has("encData")) {
+ this.step = 3;
+ this.step3Data = new Step3Data(jsonPayload);
+ } else if (jsonPayload.has("encResult")) {
+ this.step = 4;
+ this.step4Data = new Step4Data(jsonPayload);
+ }
+ } catch (JSONException e) {
+ throw new JsonException("Cannot parse JSON", e);
+ }
+
+ if (this.tlv.contains(0x03))
+ this.requestId = ByteBuffer.wrap(this.tlv.getBytes(0x03)).getLong();
+ }
+ }
+ }
+
+ public static class PinCode {
+ public static final int id = 0x2C;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request(HuaweiPacket.ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01);
+ this.complete = true;
+ this.isEncrypted = false;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte[] pinCode;
+
+ public Response(HuaweiPacket.ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ byte[] message = this.tlv.getBytes(0x01);
+ byte[] iv = this.tlv.getBytes(0x02);
+
+ HuaweiCrypto huaweiCrypto = new HuaweiCrypto(paramsProvider.getAuthVersion(), paramsProvider.getAuthAlgo(), paramsProvider.getDeviceSupportType(), paramsProvider.getAuthMode());
+ try {
+ pinCode = huaweiCrypto.decryptPinCode(paramsProvider.getEncryptMethod(), message, iv);
+ } catch (HuaweiCrypto.CryptoException e) {
+ throw new CryptoException("Could not decrypt pinCode", e);
+ }
+ }
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class AcceptAgreement {
+ public static final int id = 0x30;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ int timestamp = (int) (System.currentTimeMillis() / 1000);
+
+ HuaweiTLV software = new HuaweiTLV()
+ .put(0x03, "software_update_service_statement")
+ .put(0x04, 0x01)
+ .put(0x05, "20230508-20230508-0-0")
+ .put(0x06, timestamp);
+ HuaweiTLV device_information = new HuaweiTLV()
+ .put(0x03, "device_information_management")
+ .put(0x04,0x01)
+ .put(0x05, "20230508-20230508-0-0")
+ .put(0x06,timestamp);
+
+ HuaweiTLV user_license = new HuaweiTLV()
+ .put(0x03, "user_license_agreement")
+ .put(0x04,0x01)
+ .put(0x05, "20230508-20230508-0-0")
+ .put(0x06,timestamp);
+ HuaweiTLV tlvList = new HuaweiTLV()
+ .put(0x82, software)
+ .put(0x82,device_information)
+ .put(0x82,user_license);
+ this.tlv = new HuaweiTLV()
+ .put(0x81, tlvList);
+ }
+ }
+
+ }
+
+
+ public static class SettingRelated {
+ public static final int id = 0x31;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01)
+ .put(0x02)
+ .put(0x03)
+ .put(0x04)
+ .put(0x05)
+ .put(0x06);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public boolean truSleepNewSync = false;
+ public boolean gpsNewSync = false;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ // Works with bitmaps
+
+ // Tag 1 -> LegalStuff
+
+ if (this.tlv.contains(0x02)) {
+ // Tag 2 -> File support
+ byte value = this.tlv.getByte(0x02);
+ truSleepNewSync = (value & 2) != 0;
+ gpsNewSync = (value & 8) != 0;
+ }
+
+ // Tag 3 -> SmartWatchVersion
+ // Tag 4 to 6 are HMS related
+ }
+ }
+ }
+
+ public static class TimeZoneIdRequest extends HuaweiPacket {
+ public static final byte id = 0x32;
+
+ public TimeZoneIdRequest(
+ ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ ByteBuffer timeAndZoneId = ByteBuffer.wrap(HuaweiUtil.getTimeAndZoneId(Calendar.getInstance()));
+ this.tlv = new HuaweiTLV()
+ .put(0x01, timeAndZoneId.getInt())
+ .put(0x02, timeAndZoneId.getShort());
+ byte[] zoneId = new byte[timeAndZoneId.remaining()];
+ timeAndZoneId.get(zoneId, 0, timeAndZoneId.remaining());
+ this.tlv.put(0x03, zoneId);
+ this.complete = true;
+ }
+
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class SecurityNegotiation {
+ public static final int id = 0x33;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request (
+ HuaweiPacket.ParamsProvider paramsProvider,
+ byte authMode,
+ byte[] deviceUUID,
+ String phoneModel) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, authMode);
+ if (authMode == 0x02 || authMode == 0x04)
+ this.tlv.put(0x02, (byte)0x01); //force to not reconnected else 0x02
+ this.tlv.put(0x05, deviceUUID)
+ .put(0x03, (byte)0x01)
+ .put(0x04, (byte)0x00);
+ if (authMode == 0x04)
+ this.tlv.put(0x06)
+ .put(0x07, phoneModel);
+ if (paramsProvider.getEncryptMethod() == 1 )
+ this.tlv.put(0xd, (byte)0x1);
+
+
+ this.complete = true;
+ this.isEncrypted = false;
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+ public static class Response extends HuaweiPacket {
+ public int authType;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.authType = -0x1;
+ int pw = -0x1;
+ if (this.tlv.contains(0x01)) {
+ if (this.tlv.getByte(0x01) == 0x01)
+ this.authType = 0x0186A0;
+ if (this.tlv.getByte(0x01) == 0x04)
+ pw = 4;
+ }
+ if (this.tlv.contains(0x02)) {
+ this.authType = (int)this.tlv.getByte(0x02);
+ if (pw != -0x1)
+ this.authType ^= pw;
+ }
+ if (this.tlv.contains(0x7F))
+ this.authType = (int)this.tlv.getByte(0x7F);
+ }
+ }
+ }
+
+ public static class ConnectStatusRequest extends HuaweiPacket {
+ public static final int id = 0x35;
+
+ public ConnectStatusRequest (HuaweiPacket.ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01,(byte)0x01);
+ this.complete = true;
+ }
+ // TODO: implement parsing this request for the log parser support
+ }
+
+
+ public static class ExpandCapability {
+ public static final int id = 0x37;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte[] expandCapabilities;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.expandCapabilities = this.tlv.getBytes(0x01);
+ }
+ }
+
+ }
+
+ public static class WearStatus {
+ public static final int id = 0x3D;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ }
+ }
+ }
+
+ public static class SetUpDeviceStatusRequest extends HuaweiPacket {
+ public static final int id = 0x3E;
+
+ public SetUpDeviceStatusRequest(ParamsProvider paramsProvider, int relationShip, String deviceName) {
+ super(paramsProvider);
+
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, (byte) relationShip)
+ .put(0x02, deviceName)
+ .put(0x03, (byte)0x00);
+
+ this.complete = true;
+ }
+ }
+
+ // TODO: wear location enum?
+
+ public static class Date {
+ // TODO: enum?
+
+ public static final int yearFirst = 0x01;
+ public static final int monthFirst = 0x02;
+ public static final int dayFirst = 0x03;
+ }
+
+ public static class Time {
+ // TODO: enum?
+
+ public static final int hours12 = 0x01;
+ public static final int hours24 = 0x02;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/DisconnectNotification.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/DisconnectNotification.java
new file mode 100644
index 0000000..847814b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/DisconnectNotification.java
@@ -0,0 +1,45 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.packets;
+
+/*
+ * TODO: It isn't clear if this class should handle more at this point, and thus might need a
+ * different name later
+ */
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class DisconnectNotification {
+ public static final byte id = 0x0b;
+
+ public static class DisconnectNotificationSetting {
+ public static final byte id = 0x03;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, boolean enable) {
+ super(paramsProvider);
+
+ this.serviceId = DisconnectNotification.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01, enable);
+ this.complete = true;
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Earphones.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Earphones.java
new file mode 100644
index 0000000..188ba14
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Earphones.java
@@ -0,0 +1,119 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+// Information from:
+// https://mmk.pw/en/posts/freebuds-4i-proto/ and
+// https://github.com/TheLastGimbus/FreeBuddy/blob/master/notes/mbb-protocol-wiki.md and
+// https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/4325/
+
+public class Earphones {
+ public static final byte id = 0x2b;
+
+ public static class InEarStateResponse extends HuaweiPacket {
+ public static final byte id = 0x03;
+
+ public byte leftState;
+ public byte rightState;
+
+ public InEarStateResponse(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Earphones.id;
+ this.commandId = id;
+ this.complete = true;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.leftState = this.tlv.getByte(0x08, (byte) -1);
+ this.rightState = this.tlv.getByte(0x09, (byte) -1);
+ }
+ }
+
+ public static class SetAudioModeRequest extends HuaweiPacket {
+ public static final byte id = 0x04;
+
+ // TODO: enum for new state
+ public SetAudioModeRequest(ParamsProvider paramsProvider, byte newState) {
+ super(paramsProvider);
+ this.serviceId = Earphones.id;
+ this.commandId = id;
+
+ byte[] data = {newState, newState == 0 ? 0x00 : (byte) 0xff};
+
+ this.tlv = new HuaweiTLV().put(0x01, data);
+
+ this.complete = true;
+ }
+ }
+
+ public static class SetPauseWhenRemovedFromEar extends HuaweiPacket {
+ public static final byte id = 0x10;
+
+ // TODO: enum for new state
+ public SetPauseWhenRemovedFromEar(ParamsProvider paramsProvider, boolean newState) {
+ super(paramsProvider);
+ this.serviceId = Earphones.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x01, newState);
+
+ this.complete = true;
+ }
+ }
+
+ // TODO: get pause when removed from ear 0x11
+ // TODO: set long tap action 0x16
+ // TODO: get long tap action 0x17
+ // TODO: Audio mode cycle 0x19
+
+ public static class GetAudioModeRequest {
+ public static final byte id = 0x2a;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Earphones.id;
+ this.commandId = id;
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public short fullState;
+
+ public byte state; // TODO: enum
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Earphones.id;
+ this.commandId = id;
+ this.complete = true;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.fullState = this.tlv.getShort(0x01);
+ this.state = (byte) this.fullState;
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Ephemeris.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Ephemeris.java
new file mode 100644
index 0000000..96757ba
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Ephemeris.java
@@ -0,0 +1,114 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class Ephemeris {
+ public static final int id = 0x1f;
+
+ public static class OperatorData {
+ public static final int id = 0x01;
+
+ public static class OperatorIncomingRequest extends HuaweiPacket {
+ public byte operationInfo;
+ public int operationTime;
+
+ public OperatorIncomingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+
+ //TODO: can contain many elements.
+ //List<HuaweiTLV> subContainers = container.getObjects(0x81);
+ HuaweiTLV subTlv = this.tlv.getObject(0x81);
+
+ this.operationInfo = subTlv.getByte(0x02);
+ this.operationTime = subTlv.getInteger(0x03);
+ }
+ }
+
+ public static class OperatorResponse extends HuaweiPacket {
+
+ public OperatorResponse(ParamsProvider paramsProvider, int responseCode) {
+ super(paramsProvider);
+
+ this.serviceId = Ephemeris.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x7f, responseCode);
+
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class ParameterConsult {
+ public static final byte id = 0x02;
+
+ public static class Request extends HuaweiPacket {
+
+
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Ephemeris.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV().put(0x81);
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public int consultDeviceTime;
+ public byte downloadVersion;
+ public String downloadTag;
+
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+
+ //TODO: can contain many elements.
+ //List<HuaweiTLV> subContainers = container.getObjects(0x81);
+ HuaweiTLV subTlv = this.tlv.getObject(0x81);
+
+ this.consultDeviceTime = subTlv.getByte(0x04) * 1000;
+ this.downloadVersion = subTlv.getByte(0x05);
+ this.downloadTag = subTlv.getString(0x06);
+ }
+
+ }
+ }
+
+ public static class FileStatus {
+ public static final byte id = 0x03;
+
+ public static class Request extends HuaweiPacket {
+
+
+ public Request(ParamsProvider paramsProvider, byte status) {
+ super(paramsProvider);
+ this.serviceId = Ephemeris.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV().put(0x1, status);
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public int responseCode;
+
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ this.responseCode = this.tlv.getInteger(0x7f);
+ }
+
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/EphemerisFileUpload.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/EphemerisFileUpload.java
new file mode 100644
index 0000000..4420e1e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/EphemerisFileUpload.java
@@ -0,0 +1,269 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class EphemerisFileUpload {
+ public static final int id = 0x1c;
+
+ public static class FileList {
+ public static final int id = 0x01;
+
+ public static class FileListIncomingRequest extends HuaweiPacket {
+ public byte fileType;
+ public String productId = "";
+
+ public FileListIncomingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+
+ // 0x1 - filenames
+ // 0x2 - fileType
+ // 0x3 - productId
+ // 0x4 - issuerId
+ // 0x5 - cardType
+ this.fileType = this.tlv.getByte(0x02);
+ if (this.tlv.contains(0x3))
+ productId = this.tlv.getString(0x3);
+
+ }
+ }
+
+ public static class FileListResponse extends HuaweiPacket {
+
+ public FileListResponse(ParamsProvider paramsProvider, int responseCode, String files) {
+ super(paramsProvider);
+
+ this.serviceId = EphemerisFileUpload.id;
+ this.commandId = id;
+
+ if (responseCode != 0) {
+ this.tlv = new HuaweiTLV().put(0x7f, responseCode);
+ } else {
+ this.tlv = new HuaweiTLV().put(0x1, files);
+ }
+
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class FileConsult {
+ public static final int id = 0x02;
+
+ public static class FileConsultIncomingRequest extends HuaweiPacket {
+ public int responseCode = 0;
+ public String protocolVersion = null;
+ public byte bitmapEnable = 0;
+ public short transferSize = 0;
+ public int maxDataSize = 0;
+ public short timeOut = 0;
+ public byte fileType = 0;
+
+ public FileConsultIncomingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+
+ if (this.tlv.contains(0x7f))
+ responseCode = this.tlv.getInteger(0x7f);
+
+ // 0x1 - version
+ // 0x2 - bitmapEnable
+ // 0x3 - transferSize
+ // 0x4 - maxDataSize
+ // 0x5 - timeOut
+ // 0x6 - fileType
+
+ if (this.tlv.contains(0x1))
+ this.protocolVersion = this.tlv.getString(0x1);
+ if (this.tlv.contains(0x2))
+ this.bitmapEnable = this.tlv.getByte(0x2);
+ if (this.tlv.contains(0x3))
+ this.transferSize = this.tlv.getShort(0x3);
+ if (this.tlv.contains(0x4))
+ this.maxDataSize = this.tlv.getInteger(0x4);
+ if (this.tlv.contains(0x5))
+ this.timeOut = this.tlv.getShort(0x5);
+ if (this.tlv.contains(0x6))
+ this.fileType = this.tlv.getByte(0x6);
+ }
+ }
+
+ public static class FileConsultResponse extends HuaweiPacket {
+
+ public FileConsultResponse(ParamsProvider paramsProvider, int responseCode) {
+ super(paramsProvider);
+
+ this.serviceId = EphemerisFileUpload.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x7f, responseCode);
+
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class QuerySingleFileInfo {
+ public static final int id = 0x03;
+
+ public static class QuerySingleFileInfoIncomingRequest extends HuaweiPacket {
+ public int responseCode = 0;
+ public String fileName = null;
+
+ public QuerySingleFileInfoIncomingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+
+ if (this.tlv.contains(0x7f))
+ responseCode = this.tlv.getInteger(0x7f);
+
+ if (this.tlv.contains(0x1))
+ this.fileName = this.tlv.getString(0x1);
+
+ }
+ }
+
+ public static class QuerySingleFileInfoResponse extends HuaweiPacket {
+
+ public QuerySingleFileInfoResponse(ParamsProvider paramsProvider, int responseCode, int fileSize, short crc) {
+ super(paramsProvider);
+
+ this.serviceId = EphemerisFileUpload.id;
+ this.commandId = id;
+
+ if(responseCode != 0) {
+ this.tlv = new HuaweiTLV().put(0x7f, responseCode);
+ } else {
+ this.tlv = new HuaweiTLV().put(0x2, fileSize).put(0x3, crc);
+ }
+
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class DataRequest {
+ public static final int id = 0x04;
+
+ public static class DataRequestIncomingRequest extends HuaweiPacket {
+ public int responseCode = 0;
+ public String fileName = null;
+ public int offset = -1;
+ public int len = -1;
+ public byte bitmap = (byte) 0xff;
+
+
+ public DataRequestIncomingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ // 1 - fileName
+ // 2 - offset
+ // 3 - length
+ // 4 - fileBitmap
+ // 7f - error
+
+ if (this.tlv.contains(0x7f))
+ responseCode = this.tlv.getInteger(0x7f);
+
+ if (this.tlv.contains(0x1))
+ this.fileName = this.tlv.getString(0x1);
+ if (this.tlv.contains(0x2))
+ this.offset = this.tlv.getInteger(0x2);
+ if (this.tlv.contains(0x3))
+ this.len = this.tlv.getInteger(0x3);
+ if (this.tlv.contains(0x4))
+ this.bitmap = this.tlv.getByte(0x4);
+ }
+ }
+
+ public static class DataRequestResponse extends HuaweiPacket {
+ public DataRequestResponse(ParamsProvider paramsProvider, int responseCode, String filename, int offset) {
+ super(paramsProvider);
+
+ this.serviceId = EphemerisFileUpload.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x7f, responseCode);
+ if(responseCode == 100000) {
+ this.tlv.put(0x2, filename).put(0x3, offset);
+ }
+
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class UploadData {
+ public static final int id = 0x05;
+
+ public static class FileNextChunkSend extends HuaweiPacket {
+ public FileNextChunkSend(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = EphemerisFileUpload.id;
+ this.commandId = id;
+ this.complete = true;
+ }
+ }
+
+ public static class UploadDataResponse extends HuaweiPacket {
+ public int responseCode = 0;
+
+ public UploadDataResponse(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.tlv = new HuaweiTLV();
+ this.tlv.parse(payload, 2, payload.length - 2);
+ this.responseCode = this.tlv.getInteger(0x7f);
+ }
+ }
+ }
+
+
+ public static class UploadDone {
+ public static final int id = 0x06;
+
+ public static class UploadDoneIncomingRequest extends HuaweiPacket {
+ public byte uploadResult = 0;
+
+ public UploadDoneIncomingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.uploadResult = this.tlv.getByte(0x1);
+ }
+ }
+
+ public static class UploadDoneResponse extends HuaweiPacket {
+
+ public UploadDoneResponse(ParamsProvider paramsProvider, int responseCode) {
+ super(paramsProvider);
+
+ this.serviceId = EphemerisFileUpload.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x7f, responseCode);
+
+ this.complete = true;
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileDownloadService0A.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileDownloadService0A.java
new file mode 100644
index 0000000..29d5516
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileDownloadService0A.java
@@ -0,0 +1,279 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiCrypto;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+/**
+ * File downloading for "older" devices/implementations
+ * Newer ones might be using service id 0x2c
+ * Which one is used is reported by the band in 0x01 0x31
+ */
+public class FileDownloadService0A {
+ public static final int id = 0x0a;
+
+ /*
+ Type of files that can be downloaded through here:
+ - debug files
+ - sleep files
+ - gps files
+ - rrisqi file
+ */
+
+ public static class FileDownloadInit {
+ public static final int id = 0x01;
+
+ public static class DebugFilesRequest extends HuaweiPacket {
+ public DebugFilesRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = FileDownloadService0A.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV(); // Empty TLV
+
+ this.complete = true;
+ }
+ }
+
+ public static class SleepFilesRequest extends HuaweiPacket {
+ public SleepFilesRequest(ParamsProvider paramsProvider, int startTime, int endTime) {
+ super(paramsProvider);
+
+ this.serviceId = FileDownloadService0A.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x02, (byte) 0x01)
+ .put(0x83, new HuaweiTLV()
+ .put(0x04, startTime)
+ .put(0x05, endTime)
+ );
+
+ this.complete = true;
+ }
+ }
+
+ public static class GpsFileRequest extends HuaweiPacket {
+ public GpsFileRequest(ParamsProvider paramsProvider, short workoutId) {
+ super(paramsProvider);
+
+ this.serviceId = FileDownloadService0A.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x02, (byte) 0x02)
+ .put(0x86, new HuaweiTLV()
+ .put(0x07, (byte) 0x01) // Might be type?
+ .put(0x88, new HuaweiTLV()
+ .put(0x09, workoutId)
+ )
+ );
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public String[] fileNames;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ String possibleNames;
+ if (this.tlv.contains(0x01))
+ possibleNames = this.tlv.getString(0x01);
+ else // For GPS, also has workoutId (0x09) and another tag (0x0e), not sure of the meaning
+ possibleNames = this.tlv.getObject(0x8b).getObject(0x8c).getString(0x0d);
+ fileNames = possibleNames.split(";");
+ }
+ }
+ }
+
+ public static class FileParameters {
+ public static final int id = 0x02;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, boolean truSleep) {
+ super(paramsProvider);
+
+ this.serviceId = FileDownloadService0A.id;
+ this.commandId = id;
+
+ if (truSleep)
+ this.tlv = new HuaweiTLV().put(0x06, (byte) 0x01);
+ else
+ this.tlv = new HuaweiTLV().put(0x06, (byte) 0x02).put(0x07, (byte) 0x03);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public String version;
+ public boolean unknown;
+ public short packetSize;
+ public short maxBlockSize;
+ public short timeout;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ // TODO: below could be different for AW70?
+
+ this.version = this.tlv.getString(0x01);
+ this.unknown = this.tlv.getBoolean(0x02);
+ this.packetSize = this.tlv.getShort(0x03);
+ this.maxBlockSize = this.tlv.getShort(0x04);
+ this.timeout = this.tlv.getShort(0x05);
+ }
+ }
+ }
+
+ public static class FileInfo {
+ public static final int id = 0x03;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, String fileName) {
+ super(paramsProvider);
+
+ this.serviceId = FileDownloadService0A.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x01, fileName);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public int fileLength;
+ public byte transferType = -1;
+ public int fileCreateTime = -1;
+ public byte unknown = -1;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (this.tlv.contains(0x02))
+ this.fileLength = this.tlv.getInteger(0x02);
+ if (this.tlv.contains(0x04))
+ this.transferType = this.tlv.getByte(0x04);
+ if (this.tlv.contains(0x05))
+ this.fileCreateTime = this.tlv.getInteger(0x05);
+ if (this.tlv.contains(0x06))
+ this.unknown = this.tlv.getByte(0x06);
+ }
+ }
+ }
+
+ public static class RequestBlock {
+ public static final int id = 0x04;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, String fileName, int offset, int size) {
+ super(paramsProvider);
+
+ this.serviceId = FileDownloadService0A.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, fileName)
+ .put(0x02, offset) // TODO: not for AW70
+ .put(0x03, size); // TODO: not for AW70
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public boolean isOk;
+ public String filename;
+ public int offset;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ public void parseTlv() throws ParseException {
+ isOk = this.tlv.getInteger(0x7f) == 0x000186A0;
+ if (isOk) {
+ if (this.tlv.contains(0x01))
+ filename = this.tlv.getString(0x01);
+ offset = this.tlv.getInteger(0x02);
+ }
+ }
+ }
+ }
+
+ public static class BlockResponse extends HuaweiPacket {
+ public static final int id = 0x05;
+
+ public byte number;
+ public byte[] data;
+
+ public Exception e = null;
+
+ public BlockResponse(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ int offset;
+ // Try to decrypt TLV, and otherwise assume the data is not encrypted
+ try {
+ HuaweiTLV tlv = new HuaweiTLV().parse(this.payload, 2 ,this.payload.length - 2);
+ this.payload = tlv.decryptRaw(paramsProvider);
+ offset = 0;
+ } catch (ArrayIndexOutOfBoundsException | HuaweiCrypto.CryptoException e) {
+ this.e = e;
+ offset = 2;
+ }
+
+ this.number = this.payload[offset];
+ this.data = new byte[this.payload.length - offset - 1];
+ System.arraycopy(this.payload, offset + 1, this.data, 0, this.payload.length - offset - 1);
+ }
+ }
+
+ public static class FileDownloadCompleteRequest extends HuaweiPacket {
+ public static final int id = 0x06;
+
+ public FileDownloadCompleteRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = FileDownloadService0A.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x06, true);
+
+ this.complete = true;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileDownloadService2C.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileDownloadService2C.java
new file mode 100644
index 0000000..bbbea53
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileDownloadService2C.java
@@ -0,0 +1,223 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.devices.huawei.packets;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiCrypto;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+/**
+ * File downloading for "older" devices/implementations
+ * Newer ones might be using service id 0x2c
+ * Which one is used is reported by the band in 0x01 0x31
+ */
+public class FileDownloadService2C {
+ public static final int id = 0x2c;
+
+ public enum FileType {
+ SLEEP_STATE,
+ SLEEP_DATA,
+ GPS,
+ UNKNOWN; // Never use this as input
+
+ static byte fileTypeToByte(FileType fileType) {
+ switch (fileType) {
+ case SLEEP_STATE:
+ return (byte) 0x0e;
+ case SLEEP_DATA:
+ return (byte) 0x0f;
+ case GPS:
+ return (byte) 0x11;
+ default:
+ throw new RuntimeException();
+ }
+ }
+
+ static FileType byteToFileType(byte b) {
+ switch (b) {
+ case 0x0e:
+ return FileType.SLEEP_STATE;
+ case 0x0f:
+ return FileType.SLEEP_DATA;
+ case 0x11:
+ return FileType.GPS;
+ default:
+ return FileType.UNKNOWN;
+ }
+ }
+ }
+
+ public static class FileDownloadInit {
+ public static final int id = 0x01;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, String filename, FileType filetype, int startTime, int endTime) {
+ super(paramsProvider);
+
+ this.serviceId = FileDownloadService2C.id;
+ this.commandId = id;
+
+ // TODO: start and end time might be optional?
+ this.tlv = new HuaweiTLV()
+ .put(0x01, filename)
+ .put(0x02, FileType.fileTypeToByte(filetype))
+ .put(0x05, startTime)
+ .put(0x06, endTime);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public String fileName;
+ public FileType fileType;
+ public byte fileId;
+ public int fileSize;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ fileName = this.tlv.getString(0x01);
+ fileType = FileType.byteToFileType(this.tlv.getByte(0x02));
+ fileId = this.tlv.getByte(0x03);
+ fileSize = this.tlv.getInteger(0x04);
+ }
+ }
+ }
+
+ public static class FileInfo {
+ public static final int id = 0x03;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, byte fileId) {
+ super(paramsProvider);
+
+ this.serviceId = FileDownloadService2C.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, fileId)
+ .put(0x02)
+ .put(0x03)
+ .put(0x04)
+ .put(0x05);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte fileId;
+ public byte timeout; // TODO: not sure about unit here - maybe seconds?
+ // TODO: following two might not have the best names...
+ public short burstSize; // How large each 0x2c 0x05 will be
+ public int maxBlockSize; // How much we can ask for before needing another 0x2c 0x04
+ public boolean noEncrypt;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ fileId = this.tlv.getByte(0x01);
+ timeout = this.tlv.getByte(0x02);
+ burstSize = this.tlv.getShort(0x03);
+ maxBlockSize = this.tlv.getInteger(0x04);
+ noEncrypt = this.tlv.getBoolean(0x05); // True if command 0x04 cannot be encrypted
+ }
+ }
+ }
+
+ public static class RequestBlock extends HuaweiPacket {
+ public static final int id = 0x04;
+
+ public RequestBlock(ParamsProvider paramsProvider, byte fileId, int offset, int size, boolean noEncrypt) {
+ super(paramsProvider);
+
+ this.serviceId = FileDownloadService2C.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, fileId)
+ .put(0x02, offset)
+ .put(0x03, size);
+
+ this.complete = true;
+ this.isEncrypted = !noEncrypt;
+ }
+ }
+
+ public static class BlockResponse extends HuaweiPacket {
+ public static final int id = 0x05;
+
+ public byte fileId;
+ public int offset;
+ public byte unknown;
+ public byte[] data;
+
+ public BlockResponse(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ ByteBuffer byteBuffer;
+ if (this.payload[2] == 0x7c && this.payload[3] == 0x01 && this.payload[4] == 0x01) {
+ // Encrypted TLV, so we decrypt first
+ this.tlv = new HuaweiTLV();
+ this.tlv.parse(this.payload, 2, this.payload.length - 2);
+ try {
+ byteBuffer = ByteBuffer.wrap(this.tlv.decryptRaw(paramsProvider));
+ } catch (HuaweiCrypto.CryptoException e) {
+ throw new CryptoException("File download decryption exception", e);
+ }
+ this.tlv = null; // Prevent using it accidentally
+ } else {
+ byteBuffer = ByteBuffer.wrap(this.payload, 2, this.payload.length - 2);
+ }
+
+ fileId = byteBuffer.get();
+ offset = byteBuffer.getInt();
+ unknown = byteBuffer.get();
+ data = new byte[byteBuffer.remaining()];
+ System.arraycopy(byteBuffer.array(), byteBuffer.position(), data, 0, byteBuffer.remaining());
+ }
+ }
+
+ public static class FileDownloadCompleteRequest extends HuaweiPacket {
+ public static final int id = 0x06;
+
+ public FileDownloadCompleteRequest(ParamsProvider paramsProvider, byte fileId) {
+ super(paramsProvider);
+
+ this.serviceId = FileDownloadService2C.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, fileId)
+ .put(0x02, (byte) 1);
+
+ this.complete = true;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileUpload.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileUpload.java
new file mode 100644
index 0000000..b8eae70
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FileUpload.java
@@ -0,0 +1,239 @@
+/* Copyright (C) 2024 Vitalii Tomin
+
+ 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.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class FileUpload {
+ public static final byte id = 0x28;
+
+ public static class FileUploadParams {
+ public byte file_id = 0;
+ public String protocolVersion = "";
+ public int app_wait_time = 0;
+ public int bitmap_enable = 0;
+ public int unit_size = 0;
+ public int max_apply_data_size = 0;
+ public int interval = 0;
+ public int received_file_size = 0;
+ public byte no_encrypt = 0;
+ }
+
+ public static class Filetype {
+ public static final byte watchface = 1;
+ public static final byte music = 2;
+ public static final byte backgroundImage = 3;
+ public static final byte app = 6;
+ public static final byte firmware = 7;
+ }
+
+
+ public static class FileInfoSend {
+ public static final byte id = 0x02;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request(ParamsProvider paramsProvider,
+ int fileSize,
+ String fileName,
+ byte fileType,
+ String srcPackage,
+ String dstPackage,
+ String srcFingerprint,
+ String dstFingerprint
+ ) {
+ super(paramsProvider);
+ this.serviceId = FileUpload.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, fileName)
+ .put(0x02, fileSize)
+ .put(0x03, fileType);
+
+ if (fileType == Filetype.watchface) {
+ String watchfaceName = fileName.split("_")[0];
+ String watchfaceVersion = fileName.split("_")[1];
+ this.tlv.put(0x05, watchfaceName)
+ .put(0x06, watchfaceVersion);
+ }
+
+ if (srcPackage != null && dstPackage != null) {
+ this.tlv.put(0x08, srcPackage)
+ .put(0x09, dstPackage)
+ .put(0x0a)
+ .put(0x0b, srcFingerprint)
+ .put(0x0c, dstFingerprint);
+ }
+
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public int result = 0;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ this.result = this.tlv.getInteger(0x7f);
+ }
+ }
+ }
+
+ public static class FileHashSend {
+ public static final byte id = 0x03;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request(ParamsProvider paramsProvider,
+ byte[] hash,
+ byte fileId) {
+ super(paramsProvider);
+ this.serviceId = FileUpload.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, fileId)
+ .put(0x03, hash);
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte fileId = 0;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ this.fileId = this.tlv.getByte(0x01);
+ }
+
+ }
+ }
+
+ public static class FileUploadConsultAck {
+ public static final byte id = 0x04;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, byte noEncryption, byte fileId) {
+ super(paramsProvider);
+ this.serviceId = FileUpload.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x7f, 0x000186A0) //ok
+ .put(0x01, fileId);
+ if (noEncryption == 1)
+ this.tlv.put(0x09, (byte) 0x01); // need on devices which generally encrypted, but files
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public FileUploadParams fileUploadParams = new FileUploadParams();
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ this.fileUploadParams.file_id = this.tlv.getByte(0x01);
+ this.fileUploadParams.protocolVersion = this.tlv.getString(0x02);
+ this.fileUploadParams.app_wait_time = this.tlv.getAsInteger(0x03);
+ this.fileUploadParams.bitmap_enable = this.tlv.getAsInteger(0x04);
+ this.fileUploadParams.unit_size = this.tlv.getAsInteger(0x05);
+ this.fileUploadParams.max_apply_data_size = this.tlv.getAsInteger(0x06);
+ this.fileUploadParams.interval = this.tlv.getAsInteger(0x07);
+ this.fileUploadParams.received_file_size = this.tlv.getInteger(0x08);
+ if (this.tlv.contains(0x09)) // optional for older devices
+ this.fileUploadParams.no_encrypt = this.tlv.getByte(0x09);
+ }
+ }
+ }
+
+ public static class FileNextChunkParams extends HuaweiPacket {
+ public static final byte id = 0x05;
+
+ public int bytesUploaded = 0;
+ public int nextchunkSize = 0;
+
+ public FileNextChunkParams(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = FileUpload.id;
+ this.commandId = id;
+ this.complete = true;
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ this.bytesUploaded = this.tlv.getInteger(0x02);
+ this.nextchunkSize = this.tlv.getInteger(0x03);
+ }
+ }
+
+ public static class FileNextChunkSend extends HuaweiPacket {
+ public static final byte id = 0x06;
+
+ public FileNextChunkSend(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = FileUpload.id;
+ this.commandId = id;
+ this.complete = true;
+ }
+ }
+
+ public static class FileUploadResult {
+ public static final byte id = 0x07;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, byte fileId) {
+ super(paramsProvider);
+ this.serviceId = FileUpload.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x7f, 0x000186A0) //ok
+ .put(0x01, fileId);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ byte status = 0;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ this.status = this.tlv.getByte(0x02);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FindPhone.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FindPhone.java
new file mode 100644
index 0000000..826be51
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FindPhone.java
@@ -0,0 +1,62 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class FindPhone {
+ public static final byte id = 0x0b;
+
+ public static class Response extends HuaweiPacket {
+ public static final byte id = 0x01;
+
+ public boolean start = false;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = FindPhone.id;
+ this.commandId = id;
+
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (this.tlv.contains(0x01))
+ this.start = this.tlv.getBoolean(0x01);
+ // No missing tag exception so it will stop by default
+ }
+ }
+
+ public static class StopRequest extends HuaweiPacket {
+ public static final byte id = 0x02;
+
+ public StopRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = FindPhone.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, (byte) 2);
+
+ this.complete = true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FitnessData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FitnessData.java
new file mode 100644
index 0000000..e01db68
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/FitnessData.java
@@ -0,0 +1,850 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM, Vitalii Tomin
+
+ 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.devices.huawei.packets;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HeartRateZonesConfig;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiReportThreshold;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiRunPaceConfig;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class FitnessData {
+
+ public static final byte id = 0x07;
+
+ public static class MotionGoal {
+ public static final byte id = 0x01;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider,
+ byte goalType,
+ byte frameType,
+ int stepGoal,
+ int calorieGoal,
+ short durationGoal) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ frameType = (frameType == 0x01) ? 0x01 : Type.motion;
+ HuaweiTLV subTlv = new HuaweiTLV()
+ .put(0x03, goalType)
+ .put(0x04, frameType);
+ stepGoal = ((Type.data & 0x01) != 0x00) ? stepGoal : 0xffffffff;
+ if (stepGoal != 0xffffffff)
+ subTlv.put(0x05, stepGoal);
+ int calorieGoalFinal = ((Type.data & 0x02) != 0x00) ? calorieGoal : 0xffffffff;
+ if (calorieGoalFinal != 0xffffffff) {
+ subTlv.put(0x06, calorieGoalFinal);
+ } else if (frameType == 0x01) {
+ subTlv.put(0x06, stepGoal / 0x1e);
+ }
+ int distanceGoal = ((Type.data & 0x04) != 0x00) ? durationGoal : 0xffffffff;
+ if (distanceGoal != 0xffffffff) {
+ subTlv.put(0x07, distanceGoal);
+ } else if (frameType == 0x01) {
+ subTlv.put(0x06, stepGoal);
+ }
+ short durationGoalFinal = ((Type.data & 0x08) != 0x00) ? durationGoal : 0xffffffff;
+ if (durationGoalFinal != 0xffffffff) {
+ subTlv.put(0x08, durationGoalFinal);
+ }
+ HuaweiTLV containerTlv = new HuaweiTLV().put(0x82, subTlv);
+ this.tlv = new HuaweiTLV()
+ .put(0x81, containerTlv);
+ }
+ }
+ }
+
+ public static class UserInfo {
+ public static final byte id = 0x02;
+
+ public static class UserInfoData {
+ public static final int GENDER_MALE = 1;
+ public static final int GENDER_FEMALE = 2;
+ public static final int GENDER_UNKNOWN = 3;
+
+ private static final float RUN_CF = 0.83f;
+ private static final float WALK_CF = 0.42f;
+
+ public static final int DEFAULT_HEIGHT = 175;
+ public static final int DEFAULT_WEIGHT = 65;
+
+ private final int height;
+ private final float weight;
+ private final int age;
+ private final int birthday;
+ private final byte gender;
+
+ private final boolean unknownGenderSupported;
+
+ private final boolean vo2Supported;
+ private final int vo2Max;
+ private final int vo2Time;
+
+ private final boolean precisionWeightSupported;
+
+ public UserInfoData(int height, float weight, int age, int birthday, byte gender, boolean unknownGenderSupported, boolean vo2Supported, int vo2Max, int vo2Time, boolean precisionWeightSupported) {
+ this.height = height;
+ this.weight = weight;
+ this.age = age;
+ this.birthday = birthday;
+ this.gender = gender;
+ this.unknownGenderSupported = unknownGenderSupported;
+ this.vo2Supported = vo2Supported;
+ this.vo2Max = vo2Max;
+ this.vo2Time = vo2Time;
+ this.precisionWeightSupported = precisionWeightSupported;
+ }
+
+ public int getHeight() {
+ if (this.height > 0)
+ return this.height;
+ return DEFAULT_HEIGHT;
+ }
+
+ public float getWeight() {
+ if (this.weight > 0)
+ return this.weight;
+ return DEFAULT_WEIGHT;
+ }
+
+ public int getAge() {
+ return age;
+ }
+
+ public int getBirthday() {
+ return birthday;
+ }
+
+ public byte getGender() {
+ return gender;
+ }
+
+ public boolean isUnknownGenderSupported() {
+ return unknownGenderSupported;
+ }
+
+ public boolean isVo2Supported() {
+ return vo2Supported;
+ }
+
+ public int getVo2Max() {
+ return vo2Max;
+ }
+
+ public int getVo2Time() {
+ return vo2Time;
+ }
+
+ public boolean isPrecisionWeightSupported() {
+ return precisionWeightSupported;
+ }
+
+ public boolean isGenderValid() {
+ return this.gender == GENDER_MALE || this.gender == GENDER_FEMALE || this.gender == GENDER_UNKNOWN;
+ }
+
+ public boolean isBirthdayValid() {
+ return this.birthday > 0;
+ }
+
+ }
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, UserInfoData info) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ int height = info.getHeight();
+ float weight = info.getWeight();
+
+ byte bmiWalk = (byte) Math.round(UserInfoData.WALK_CF * height);
+ byte bmiRun = (byte) Math.round(UserInfoData.RUN_CF * height);
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, (byte) height)
+ .put(0x02, (byte) weight)
+ .put(0x03, (byte) info.getAge());
+ if (info.isBirthdayValid())
+ this.tlv.put(0x04, info.getBirthday());
+ if (info.isGenderValid()) {
+ if (info.unknownGenderSupported || info.gender != UserInfoData.GENDER_UNKNOWN)
+ this.tlv.put(0x05, info.gender);
+ }
+ this.tlv.put(0x06, bmiWalk);
+ this.tlv.put(0x07, bmiRun);
+
+ if (info.vo2Supported) {
+ this.tlv.put(0x08, info.getVo2Max());
+ this.tlv.put(0x09, info.getVo2Time());
+ }
+
+ if (info.precisionWeightSupported) {
+ int precisionWeight = Math.round(weight * 100f);
+ this.tlv.put(0x0b, precisionWeight);
+ }
+
+ }
+ }
+ }
+
+ public static class MessageCount {
+ public static final byte sleepId = 0x0C;
+ public static final byte stepId = 0x0A;
+
+ public static class Request extends HuaweiPacket {
+ public Request(
+ ParamsProvider paramsProvider,
+ byte commandId,
+ int start,
+ int end
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = commandId;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x81)
+ .put(0x03, start)
+ .put(0x04, end);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public short count;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.count = this.tlv.getObject(0x81).getShort(0x02);
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class MessageData {
+ public static final byte sleepId = 0x0D;
+ public static final byte stepId = 0x0B;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, byte commandId, short count) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = commandId;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x81, new HuaweiTLV()
+ .put(0x02, count)
+ );
+
+ this.complete = true;
+ }
+ }
+
+ public static class SleepResponse extends HuaweiPacket {
+ public static class SubContainer {
+ public byte type;
+ public byte[] timestamp;
+
+ @Override
+ public String toString() {
+ return "SubContainer{" +
+ "type=" + type +
+ ", timestamp=" + Arrays.toString(timestamp) +
+ '}';
+ }
+ }
+
+ public short number;
+ public List<SubContainer> containers;
+
+ public SleepResponse(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = sleepId;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ HuaweiTLV container = this.tlv.getObject(0x81);
+ List<HuaweiTLV> subContainers = container.getObjects(0x83);
+
+ this.number = container.getShort(0x02);
+ this.containers = new ArrayList<>();
+ for (HuaweiTLV subContainerTlv : subContainers) {
+ SubContainer subContainer = new SubContainer();
+ subContainer.type = subContainerTlv.getByte(0x04);
+ subContainer.timestamp = subContainerTlv.getBytes(0x05);
+ this.containers.add(subContainer);
+ }
+ }
+ }
+
+ public static class StepResponse extends HuaweiPacket {
+ public static class SubContainer {
+ public static class TV {
+ public final byte bitmap;
+ public final byte tag;
+ public final short value;
+
+ public TV(byte bitmap, byte tag, short value) {
+ this.bitmap = bitmap;
+ this.tag = tag;
+ this.value = value;
+ }
+
+ @Override
+ public String toString() {
+ return "TV{" +
+ "bitmap=" + bitmap +
+ ", tag=" + tag +
+ ", value=" + value +
+ '}';
+ }
+ }
+
+ /*
+ * Data directly from packet
+ */
+ public byte timestampOffset;
+ public byte[] data;
+
+ /*
+ * Inferred data
+ */
+ public int timestamp;
+
+ public List<TV> parsedData = null;
+ public String parsedDataError = "";
+
+ public int steps = -1;
+ public int calories = -1;
+ public int distance = -1;
+ public int heartrate = -1;
+
+ public int spo = -1;
+
+ public List<TV> unknownTVs = null;
+
+ @Override
+ public String toString() {
+ return "SubContainer{" +
+ "timestampOffset=" + timestampOffset +
+ ", data=" + Arrays.toString(data) +
+ ", timestamp=" + timestamp +
+ ", parsedData=" + parsedData +
+ ", parsedDataError='" + parsedDataError + '\'' +
+ ", steps=" + steps +
+ ", calories=" + calories +
+ ", distance=" + distance +
+ ", spo=" + spo +
+ ", unknownTVs=" + unknownTVs +
+ '}';
+ }
+ }
+
+ public short number;
+ public int timestamp;
+ public List<SubContainer> containers;
+
+ private static final List<Byte> singleByteTagListBitmap1 = new ArrayList<>();
+
+ static {
+ singleByteTagListBitmap1.add((byte) 0x20);
+ singleByteTagListBitmap1.add((byte) 0x40);
+ }
+
+ public StepResponse(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = stepId;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ HuaweiTLV container = this.tlv.getObject(0x81);
+ List<HuaweiTLV> subContainers = container.getObjects(0x84);
+
+ this.number = container.getShort(0x02);
+ this.timestamp = container.getInteger(0x03);
+ this.containers = new ArrayList<>();
+ for (HuaweiTLV subContainerTlv : subContainers) {
+ SubContainer subContainer = new SubContainer();
+ subContainer.timestampOffset = subContainerTlv.getByte(0x05);
+ subContainer.timestamp = this.timestamp + 60 * subContainer.timestampOffset;
+ subContainer.data = subContainerTlv.getBytes(0x06);
+ parseData(subContainer, subContainer.data);
+ this.containers.add(subContainer);
+ }
+ }
+
+ private static void parseData(SubContainer returnValue, byte[] data) {
+ int i = 0;
+
+ if (data.length <= 0) {
+ returnValue.parsedData = null;
+ returnValue.parsedDataError = "Data is missing feature bitmap.";
+ return;
+ }
+ byte featureBitmap1 = data[i++];
+
+ byte featureBitmap2 = 0;
+ if ((featureBitmap1 & 128) != 0) {
+ if (data.length <= i) {
+ returnValue.parsedData = null;
+ returnValue.parsedDataError = "Data is missing second feature bitmap.";
+ return;
+ }
+ featureBitmap2 = data[i++];
+ }
+
+ returnValue.parsedData = new ArrayList<>();
+ returnValue.unknownTVs = new ArrayList<>();
+
+ // The greater than zero check is because Java is always signed, so we only check 7 bits
+ for (byte bitToCheck = 1; bitToCheck > 0; bitToCheck <<= 1) {
+ if ((featureBitmap1 & bitToCheck) != 0) {
+ short value;
+
+ if (singleByteTagListBitmap1.contains(bitToCheck)) {
+ if (data.length - 1 < i) {
+ returnValue.parsedData = null;
+ returnValue.parsedDataError = "Data is too short for selected features.";
+ return;
+ }
+
+ value = data[i++];
+ } else {
+ if (data.length - 2 < i) {
+ returnValue.parsedData = null;
+ returnValue.parsedDataError = "Data is too short for selected features.";
+ return;
+ }
+
+ value = (short) ((data[i++] & 0xFF) << 8 | (data[i++] & 0xFF));
+ }
+
+ // The bitToCheck is used as tag, which may not be optimal, but works
+ SubContainer.TV tv = new SubContainer.TV((byte) 1, bitToCheck, value);
+ returnValue.parsedData.add(tv);
+
+ if (bitToCheck == 0x02)
+ returnValue.steps = value;
+ else if (bitToCheck == 0x04)
+ returnValue.calories = value;
+ else if (bitToCheck == 0x08)
+ returnValue.distance = value;
+ else if (bitToCheck == 0x40)
+ returnValue.heartrate = value;
+ else
+ returnValue.unknownTVs.add(tv);
+ }
+ }
+
+ if (featureBitmap2 != 0) {
+ // We want to check 8 bits here, and java is java, so we use a short
+ for (short bitToCheck = 1; bitToCheck < 0x0100; bitToCheck <<= 1) {
+ if ((featureBitmap2 & bitToCheck) != 0) {
+ if (data.length - 1 < i) {
+ returnValue.parsedData = null;
+ returnValue.parsedDataError = "Data is too short for selected features.";
+ return;
+ }
+
+ byte value = data[i++];
+
+ SubContainer.TV tv = new SubContainer.TV((byte) 2, (byte) bitToCheck, value);
+ returnValue.parsedData.add(tv);
+
+ if (bitToCheck == 0x01)
+ returnValue.spo = value;
+ else
+ returnValue.unknownTVs.add(tv);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public static class FitnessTotals {
+ public static final byte id = 0x03;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public int totalSteps = 0;
+ public int totalCalories = 0;
+ public int totalDistance = 0;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ HuaweiTLV container = this.tlv.getObject(0x81);
+ List<HuaweiTLV> containers = container.getObjects(0x83);
+
+ for (HuaweiTLV tlv : containers) {
+ if (tlv.contains(0x05))
+ totalSteps += tlv.getInteger(0x05);
+ if (tlv.contains(0x06))
+ totalCalories += tlv.getShort(0x06);
+ if (tlv.contains(0x07))
+ totalDistance += tlv.getInteger(0x07);
+ }
+
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class ActivityReminder {
+ public static final byte id = 0x07;
+
+ public static class Request extends HuaweiPacket {
+ public Request(
+ ParamsProvider paramsProvider,
+ boolean longSitSwitch,
+ byte longSitInterval,
+ byte[] longSitStart,
+ byte[] longSitEnd,
+ byte cycle
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x81, new HuaweiTLV()
+ .put(0x02, longSitSwitch)
+ .put(0x03, longSitInterval)
+ .put(0x04, longSitStart)
+ .put(0x05, longSitEnd)
+ .put(0x06, cycle)
+ );
+
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class DeviceReportThreshold {
+ public static final byte id = 0xe;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, List<HuaweiReportThreshold> thresholds) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+
+ HuaweiTLV subTlv = new HuaweiTLV();
+ for (HuaweiReportThreshold th : thresholds) {
+ subTlv.put(0x02, th.getBytes());
+ }
+ this.tlv = new HuaweiTLV().put(0x81, subTlv);
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class HeartRateZoneConfigPacket {
+ // It can use two IDs with basically the same format.
+ public static final byte id_simple = 0x13;
+ public static final byte id_extended = 0x21;
+
+ public static class Request extends HuaweiPacket {
+ private Request(
+ ParamsProvider paramsProvider,
+ byte id,
+ HeartRateZonesConfig heartRateZonesConfig
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ HuaweiTLV subTlv = new HuaweiTLV().
+ put(0x08, heartRateZonesConfig.getWarningEnable());
+
+ if (
+ heartRateZonesConfig.hasValidMHRData() &&
+ heartRateZonesConfig.getWarningHRLimit() > 0 &&
+ heartRateZonesConfig.getMaxHRThreshold() > 0
+ ) {
+ subTlv
+ .put(0x09, (byte) heartRateZonesConfig.getWarningHRLimit())
+ .put(0x02, (byte) heartRateZonesConfig.getMHRWarmUp())
+ .put(0x03, (byte) heartRateZonesConfig.getMHRFatBurning())
+ .put(0x04, (byte) heartRateZonesConfig.getMHRAerobic())
+ .put(0x05, (byte) heartRateZonesConfig.getMHRAnaerobic())
+ .put(0x06, (byte) heartRateZonesConfig.getMHRExtreme())
+ .put(0x07, (byte) heartRateZonesConfig.getMaxHRThreshold())
+ .put(0x0b, (byte) heartRateZonesConfig.getMaxHRThreshold());
+ }
+
+ if (id == id_extended && heartRateZonesConfig.hasValidHRRData()) {
+ subTlv
+ .put(0x0d, (byte) heartRateZonesConfig.getHRRBasicAerobic())
+ .put(0x0e, (byte) heartRateZonesConfig.getHRRAdvancedAerobic())
+ .put(0x0f, (byte) heartRateZonesConfig.getHRRLactate())
+ .put(0x10, (byte) heartRateZonesConfig.getHRRBasicAnaerobic())
+ .put(0x11, (byte) heartRateZonesConfig.getHRRAdvancedAnaerobic());
+ }
+
+ if (id == id_extended && heartRateZonesConfig.getRestHeartRate() > 0) {
+ subTlv
+ .put(0x0a, (byte) heartRateZonesConfig.getCalculateMethod())
+ .put(0x0c, (byte) heartRateZonesConfig.getRestHeartRate());
+ }
+
+ this.tlv = new HuaweiTLV().put(0x81, subTlv);
+
+ this.complete = true;
+ }
+
+ public static Request requestSimple(ParamsProvider paramsProvider, HeartRateZonesConfig heartRateZonesConfig) {
+ return new Request(paramsProvider, id_simple, heartRateZonesConfig);
+ }
+
+ public static Request requestExtended(ParamsProvider paramsProvider, HeartRateZonesConfig heartRateZonesConfig) {
+ return new Request(paramsProvider, id_extended, heartRateZonesConfig);
+ }
+ }
+ }
+
+ public static class TruSleep {
+ public static final byte id = 0x16;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, boolean truSleepSwitch) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, truSleepSwitch);
+
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class EnableAutomaticHeartrate {
+ public static final byte id = 0x17;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, boolean enableAutomaticHeartrate) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, enableAutomaticHeartrate);
+
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class NotifyRestHeartRate {
+ public static final byte id = 0x23;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, 0x01);
+
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class EnableAutomaticSpo {
+ public static final byte id = 0x24;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, boolean enableAutomaticSpo) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, enableAutomaticSpo);
+
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class RunPaceConfig {
+ public static final byte id = 0x28;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, final HuaweiRunPaceConfig runPaceConfig) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, (short) runPaceConfig.getZone1JogMin())
+ .put(0x02, (short) runPaceConfig.getZone2MarathonMin())
+ .put(0x03, (short) runPaceConfig.getZone3LactateThresholdMin())
+ .put(0x04, (short) runPaceConfig.getZone4AnaerobicMin())
+ .put(0x05, (short) runPaceConfig.getZone5HIITRunMin())
+ .put(0x06, (short) runPaceConfig.getZone5HIITRunMax());
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public boolean isOk;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ isOk = this.tlv.getInteger(0x7f) == 0x000186A0;
+ }
+ }
+
+ }
+
+ public static class MediumToStrengthThreshold {
+ public static final byte id = 0x29;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider,
+ byte walkRun,
+ byte climb,
+ byte heartRate,
+ byte cycleSpeed,
+ byte sample,
+ byte countLength,
+ int walkRunSpeed,
+ int walkRunWithHeartRate) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ if (walkRun < 0x00 || walkRun > 0xc8) walkRun = 0x6E;
+ if (climb < 0x0 || climb > 0xc8) climb = 0x3c;
+ if (heartRate < 0x0 || heartRate > 0x64) heartRate = 0x40;
+ if (cycleSpeed < 0x0 || cycleSpeed > 0xff) cycleSpeed = 0x50;
+ if (sample < 0x1 || sample > 0xa) sample = 0x3;
+ if (countLength < 0x1 || countLength > 0xa) countLength = 0x5;
+ if (countLength < sample) countLength = sample;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, walkRun)
+ .put(0x02, climb)
+ .put(0x03, heartRate)
+ .put(0x04, cycleSpeed)
+ .put(0x05, sample)
+ .put(0x06, countLength);
+ if (walkRunSpeed != -1) {
+ this.tlv.put(0x07, (byte) walkRunSpeed);
+ }
+ if (walkRunWithHeartRate != -1) {
+ this.tlv.put(0x08, (byte) walkRunWithHeartRate);
+ }
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class SkinTemperatureMeasurement {
+ public static final byte id = 0x2a;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, boolean temperatureSwitch) {
+ super(paramsProvider);
+
+ this.serviceId = FitnessData.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, (byte) 0x01)
+ .put(0x02, temperatureSwitch);
+
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class Type {
+ // TODO: enum?
+
+ public static final byte goal = 0x01;
+ public static final byte motion = 0x00;
+ public static final byte data = 0x01;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/GpsAndTime.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/GpsAndTime.java
new file mode 100644
index 0000000..d2b1c16
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/GpsAndTime.java
@@ -0,0 +1,238 @@
+/* Copyright (C) 2024 Vitalii Tomin, Martin.JM
+
+ 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.devices.huawei.packets;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class GpsAndTime {
+ public static final byte id = 0x18;
+
+ public static class GpsParameters {
+ public static final byte id = 0x01;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = GpsAndTime.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x81);
+
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte supportBitmap; // Supported bitmap, parsed into the booleans below
+ public byte format = 0; // 1 to send a single location per message (id 3), 2 for multiple
+ public byte locationsPerMessage = 0; // Only for format 2, the number of locations to send per message
+ public byte threshold = 0;
+
+ public boolean supportsSpeed;
+ public boolean supportsDistance;
+ public boolean supportsAltitude;
+ public boolean supportsTotalDistance;
+ public boolean supportsLatLon;
+ public boolean supportsMarsLatLon;
+ public boolean supportsDirection;
+ public boolean supportsPrecision;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ HuaweiTLV subTlv = this.tlv.getObject(0x81);
+
+ supportBitmap = subTlv.getByte(0x02);
+ supportsSpeed = (supportBitmap & 0x01) != 0;
+ supportsDistance = (supportBitmap & 0x02) != 0;
+ supportsAltitude = (supportBitmap & 0x04) != 0;
+ supportsTotalDistance = (supportBitmap & 0x08) != 0;
+ supportsLatLon = (supportBitmap & 0x10) != 0;
+ supportsMarsLatLon = (supportBitmap & 0x20) != 0;
+ supportsDirection = (supportBitmap & 0x40) != 0;
+ supportsPrecision = (supportBitmap & 0x80) != 0;
+
+ if (subTlv.contains(0x03))
+ format = subTlv.getByte(0x03);
+ if (subTlv.contains(0x04))
+ locationsPerMessage = subTlv.getByte(0x04);
+ if (subTlv.contains(0x05))
+ threshold = subTlv.getByte(0x05);
+ }
+ }
+ }
+
+ public static class GpsStatus {
+ public static final byte id = 0x02;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = GpsAndTime.id;
+ this.commandId = id;
+
+ // Currently only sends "OK" value
+ this.tlv = new HuaweiTLV().put(0x7f, 0x186A0);
+
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public boolean enableGps;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ enableGps = this.tlv.getByte(0x01) == 0x01;
+ }
+ }
+ }
+
+ public static class GpsData {
+ public static final byte id = 0x03;
+
+ public static class Request extends HuaweiPacket {
+ public static class GpsDataContainer {
+ public boolean hasSpeed = false;
+ public short speed;
+
+ public boolean hasDistance = false;
+ public int distance;
+
+ public boolean hasAltitude = false;
+ public short altitude;
+
+ public boolean hasTotalDistance = false;
+ public int totalDistance;
+
+ public boolean hasStartTime = false;
+ public int startTime;
+
+ public boolean hasEndTime = false;
+ public int endTime;
+
+ public boolean hasLatLon = false;
+ public double lat;
+ public double lon;
+
+ public boolean hasMarsLatLon = false;
+ public double marsLat;
+ public double marsLon;
+
+ public boolean hasBearing = false;
+ public double bearing;
+
+ public boolean hasAccuracy = false;
+ public double accuracy;
+ }
+
+ public Request(
+ ParamsProvider paramsProvider,
+ List<GpsDataContainer> gpsDataList
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = GpsAndTime.id;
+ this.commandId = id;
+
+ HuaweiTLV subTlv = new HuaweiTLV();
+ for (GpsDataContainer gpsData : gpsDataList) {
+ HuaweiTLV gpsTlv = new HuaweiTLV();
+ if (gpsData.hasSpeed)
+ gpsTlv.put(0x03, gpsData.speed);
+ if (gpsData.hasDistance)
+ gpsTlv.put(0x04, gpsData.distance);
+ if (gpsData.hasAltitude)
+ gpsTlv.put(0x05, gpsData.altitude);
+ if (gpsData.hasTotalDistance)
+ gpsTlv.put(0x06, gpsData.totalDistance);
+ if (gpsData.hasStartTime)
+ gpsTlv.put(0x07, gpsData.startTime);
+ if (gpsData.hasEndTime)
+ gpsTlv.put(0x08, gpsData.endTime);
+ if (gpsData.hasLatLon) {
+ gpsTlv.put(0x09, gpsData.lat);
+ gpsTlv.put(0x0a, gpsData.lon);
+ }
+ if (gpsData.hasMarsLatLon) {
+ gpsTlv.put(0x0b, gpsData.marsLat);
+ gpsTlv.put(0x0c, gpsData.marsLon);
+ }
+ if (gpsData.hasBearing)
+ gpsTlv.put(0x0d, gpsData.bearing);
+ if (gpsData.hasAccuracy)
+ gpsTlv.put(0x0e, gpsData.accuracy);
+ subTlv.put(0x82, gpsTlv);
+ }
+ this.tlv = new HuaweiTLV().put(0x81, subTlv);
+
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ boolean shouldStop;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ // If the response is not 0x000186A0, sending should be stopped
+ shouldStop = this.tlv.getInteger(0x7f) != 0x000186A0;
+ }
+ }
+ }
+
+ public static class CurrentGPSRequest extends HuaweiPacket {
+ public static final byte id = 0x07;
+ public CurrentGPSRequest (
+ ParamsProvider paramsProvider,
+ int timestamp,
+ double lat,
+ double lon
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = GpsAndTime.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01, timestamp)
+ .put(0x02, lon)
+ .put(0x03, lat);
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/LocaleConfig.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/LocaleConfig.java
new file mode 100644
index 0000000..cd6e0bb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/LocaleConfig.java
@@ -0,0 +1,72 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class LocaleConfig {
+ public static final byte id = 0x0C;
+
+ public static class SetLanguageSetting extends HuaweiPacket {
+ public static final byte id = 0x01;
+
+ public SetLanguageSetting(
+ ParamsProvider paramsProvider,
+ byte[] locale,
+ byte measurement
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = LocaleConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, locale)
+ .put(0x02, measurement);
+
+ this.complete = true;
+ }
+ }
+
+ public static class MeasurementSystem {
+ // TODO: enum?
+
+ public static final byte metric = 0x00;
+ public static final byte imperial = 0x01;
+ }
+
+ public static class SetTemperatureUnitSetting extends HuaweiPacket {
+ public static final byte id = 0x05;
+
+ public SetTemperatureUnitSetting(
+ ParamsProvider paramsProvider,
+ byte isFahrenheit
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = LocaleConfig.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, isFahrenheit);
+
+ this.complete = true;
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Menstrual.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Menstrual.java
new file mode 100644
index 0000000..a1210e3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Menstrual.java
@@ -0,0 +1,75 @@
+/* Copyright (C) 2024 Damien Gaignon
+
+ 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.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class Menstrual {
+ public static final byte id = 0x32;
+
+ public static class ModifyTime {
+ public static final byte id = 0x02;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, int errorCode, long time) {
+ super(paramsProvider);
+
+ this.serviceId = Menstrual.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV();
+ if (errorCode == 0) {
+ this.tlv.put(0x01, time);
+ } else {
+ this.tlv.put(0x7f, (int)0x249F1);
+ }
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Menstrual.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ // Do not know data yet
+ }
+ }
+ }
+
+ public static class CapabilityRequest extends HuaweiPacket {
+ public static final byte id = 0x05;
+
+ public CapabilityRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Menstrual.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, (byte)0x02);
+
+ this.complete = true;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/MusicControl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/MusicControl.java
new file mode 100644
index 0000000..d2d054b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/MusicControl.java
@@ -0,0 +1,555 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.packets;
+
+import static nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiMusicUtils.parseFormatBits;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiMusicUtils;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceMusic;
+
+public class MusicControl {
+ public static final byte id = 0x25;
+
+
+ // TODO: should this be in HuaweiConstants?
+ public static final int successValue = 0x000186A0;
+
+ public static class MusicStatusRequest extends HuaweiPacket {
+ public MusicStatusRequest(ParamsProvider paramsProvider, byte commandId, int returnValue) {
+ super(paramsProvider);
+
+ this.serviceId = MusicControl.id;
+ this.commandId = commandId;
+ this.tlv = new HuaweiTLV()
+ .put(0x7F, returnValue);
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+
+ public static class MusicStatusResponse extends HuaweiPacket {
+ public static final byte id = 0x01;
+
+ public int status = -1;
+
+ public MusicStatusResponse(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = MusicControl.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (this.tlv.contains(0x7F) && this.tlv.getBytes(0x7F).length == 4)
+ this.status = this.tlv.getInteger(0x7F);
+ }
+ }
+
+ public static class MusicInfo {
+ public static final byte id = 0x02;
+
+ public static class Request extends HuaweiPacket {
+ public Request(
+ ParamsProvider paramsProvider,
+ String artistName,
+ String songName,
+ byte playState,
+ byte maxVolume,
+ byte currentVolume
+ ) {
+ super(paramsProvider);
+ this.serviceId = MusicControl.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01, artistName)
+ .put(0x02, songName)
+ .put(0x03, playState)
+ .put(0x04, maxVolume)
+ .put(0x05, currentVolume);
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public boolean ok = false;
+ public String error = "No input has been parsed yet";
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = MusicControl.id;
+ this.commandId = id;
+
+ this.isEncrypted = true;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (this.tlv.contains(0x7F)) {
+ if (this.tlv.getInteger(0x7F) == successValue) {
+ this.ok = true;
+ this.error = "";
+ } else {
+ this.ok = false;
+ this.error = "Music information error code: " + Integer.toHexString(this.tlv.getInteger(0x7F));
+ }
+ } else {
+ this.ok = false;
+ this.error = "Music information response no status tag";
+ }
+ }
+ }
+ }
+
+ public static class Control {
+ public static final byte id = 0x03;
+
+ public static class Response extends HuaweiPacket {
+ public enum Button {
+ Unknown,
+ Play,
+ Pause,
+ Previous,
+ Next,
+ Volume_up,
+ Volume_down
+ }
+
+ public boolean buttonPresent = false;
+ public byte rawButton = 0x00;
+ public boolean volumePresent = false;
+ public byte volume = 0x00;
+
+ public Button button = null;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = MusicControl.id;
+ this.commandId = id;
+
+ this.isEncrypted = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (this.tlv.contains(0x01)) {
+ this.buttonPresent = true;
+ // Only grab the lowest byte
+ byte[] bytes = this.tlv.getBytes(0x01);
+ this.rawButton = bytes[bytes.length - 1];
+ switch (this.rawButton) {
+ case 1:
+ this.button = Button.Play;
+ break;
+ case 2:
+ this.button = Button.Pause;
+ break;
+ case 3:
+ this.button = Button.Previous;
+ break;
+ case 4:
+ this.button = Button.Next;
+ break;
+ case 5:
+ this.button = Button.Volume_up;
+ break;
+ case 6:
+ this.button = Button.Volume_down;
+ break;
+ case 64:
+ // Unknown button on Huawei Band 4
+
+ case 100:
+ // Seems like exit from music control screen to other screen
+ this.buttonPresent = false;
+ default:
+ this.button = Button.Unknown;
+ }
+ }
+
+ if (this.tlv.contains(0x02)) {
+ this.volumePresent = true;
+ // Only grab the lowest byte
+ byte[] bytes = this.tlv.getBytes(0x02);
+ this.volume = bytes[bytes.length - 1];
+ }
+ }
+ }
+ }
+
+ public static class UploadMusicFileInfo {
+ public static final int id = 0x09;
+
+ public static class UploadMusicFileInfoRequest extends HuaweiPacket {
+ public short songIndex;
+ public String songFileName;
+
+ public UploadMusicFileInfoRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.songIndex = this.tlv.getShort(0x01);
+ this.songFileName = this.tlv.getString(0x02);
+ }
+ }
+
+ public static class UploadMusicFileInfoResponse extends HuaweiPacket {
+ public UploadMusicFileInfoResponse(ParamsProvider paramsProvider, short songIndex, String songName, String songArtist) {
+ super(paramsProvider);
+
+ this.serviceId = MusicControl.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x01, songIndex).put(0x03, songName).put(0x04, songArtist);
+
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class MusicInfoParams {
+ public static final byte id = 0x04;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = MusicControl.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01)
+ .put(0x02)
+ .put(0x03);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public HuaweiMusicUtils.MusicCapabilities params = new HuaweiMusicUtils.MusicCapabilities();
+
+ public int frameCount = 0;
+ public List<HuaweiMusicUtils.PageStruct> pageStruct = null;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.frameCount = this.tlv.getAsInteger(0x01);
+
+ if (this.tlv.contains(0x02))
+ params.availableSpace = this.tlv.getAsInteger(0x02);
+ if (this.tlv.contains(0x03)) {
+ byte[] formatBits = this.tlv.getBytes(0x03);
+ params.supportedFormats = parseFormatBits(formatBits);
+ }
+ if (this.tlv.contains(0x04))
+ params.maxMusicCount = this.tlv.getAsInteger(0x04);
+ if (this.tlv.contains(0x05))
+ params.currentMusicCount = this.tlv.getAsInteger(0x05);
+
+ if (this.tlv.contains(0x86)) {
+ this.pageStruct = new ArrayList<>();
+ List<HuaweiTLV> subTlvs = this.tlv.getObject(0x86).getObjects(0x87);
+ for (HuaweiTLV subTlv : subTlvs) {
+ HuaweiMusicUtils.PageStruct pageStruct = new HuaweiMusicUtils.PageStruct();
+ if (subTlv.contains(0x08))
+ pageStruct.startFrame = subTlv.getShort(0x08);
+ if (subTlv.contains(0x09))
+ pageStruct.endFrame = subTlv.getShort(0x09);
+ if (subTlv.contains(0x0a))
+ pageStruct.count = subTlv.getShort(0x0a);
+ if (subTlv.contains(0x0b))
+ pageStruct.hashCode = subTlv.getBytes(0x0b);
+ this.pageStruct.add(pageStruct);
+ }
+ }
+ }
+ }
+ }
+
+ public static class MusicList {
+ public static final byte id = 0x05;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, short startFrame, short endIndex) {
+ super(paramsProvider);
+ this.serviceId = MusicControl.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01, startFrame)
+ .put(0x04, endIndex);
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public short startFrame = 0;
+ public short endIndex = 0;
+
+ public List<GBDeviceMusic> musicList;
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ if(tlv.contains(0x1))
+ startFrame = tlv.getShort(0x1);
+ if(tlv.contains(0x4))
+ endIndex = tlv.getShort(0x4);
+ musicList = new ArrayList<>();
+ if(this.tlv.contains(0x82)) {
+ for (HuaweiTLV subTlv : this.tlv.getObject(0x82).getObjects(0x83)) {
+ int index = subTlv.getAsInteger(0x4);
+ String title = subTlv.getString(0x5);
+ String artist = subTlv.getString(0x6);
+ String fileName = subTlv.getString(0x7);
+ musicList.add(new GBDeviceMusic(index, title, artist, fileName));
+ }
+ }
+ }
+ }
+ }
+
+ public static class MusicPlaylists {
+ public static final byte id = 0x06;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = MusicControl.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01);
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public static class PlaylistData {
+ public int id;
+ public String name;
+ public int frameCount;
+ }
+
+ public List<PlaylistData> playlists = new ArrayList<>();
+
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ if(this.tlv.contains(0x81)) {
+ for (HuaweiTLV subTlv : this.tlv.getObject(0x81).getObjects(0x82)) {
+ PlaylistData data = new PlaylistData();
+ data.id = subTlv.getAsInteger(0x3);
+ data.name = subTlv.getString(0x4);
+ data.frameCount = subTlv.getAsInteger(0x5);
+ playlists.add(data);
+ }
+ }
+ }
+ }
+ }
+
+ public static class MusicPlaylistMusics {
+ public static final byte id = 0x07;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, short playlist, short index) {
+ super(paramsProvider);
+ this.serviceId = MusicControl.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01, playlist)
+ .put(0x02, index);
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public int id = -1;
+ public int index = -1;
+ public ArrayList<Integer> musicIds = null;
+
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ if(this.tlv.contains(0x1))
+ id = tlv.getAsInteger(0x1);
+ if(this.tlv.contains(0x2))
+ index = tlv.getAsInteger(0x2);
+
+ if(this.tlv.contains(0x3)) {
+ musicIds = new ArrayList<>();
+ ByteBuffer dt = ByteBuffer.wrap(this.tlv.getBytes(0x3));
+ while (dt.hasRemaining())
+ musicIds.add((int) dt.getShort());
+ }
+ }
+ }
+ }
+
+ public static class MusicOperation {
+ public static final byte id = 0x08;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, int operation, int playlistIndex, String playlistName, ArrayList<Integer> musicIds) {
+ super(paramsProvider);
+ this.serviceId = MusicControl.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01, (byte)operation);
+
+ if(operation == 1 || operation == 2 || operation == 3 || operation == 4) {
+ this.tlv.put(0x02, (short)playlistIndex);
+ }
+ if (operation == 0 || operation == 2) {
+ this.tlv.put(0x03, playlistName);
+ }
+
+ if (operation == 3 || operation == 4) {
+ ByteBuffer ids = ByteBuffer.allocate(musicIds.size() * 2);
+ for (Integer id : musicIds) {
+ ids.putShort(id.shortValue());
+ }
+ this.tlv.put(0x04, ids.array());
+ }
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public int operation = -1;
+ public int playlistIndex = -1;
+ public String playlistName;
+ public ArrayList<Integer> musicIds = null;
+ public int resultCode = -1;
+
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ if(this.tlv.contains(0x7f))
+ resultCode = tlv.getInteger(0x7f);
+ if(this.tlv.contains(0x1))
+ operation = tlv.getByte(0x1);
+ if(this.tlv.contains(0x2))
+ playlistIndex = tlv.getAsInteger(0x2);
+ if(this.tlv.contains(0x3))
+ playlistName = tlv.getString(0x3);
+
+ if(this.tlv.contains(0x4)) {
+ musicIds = new ArrayList<>();
+ ByteBuffer dt = ByteBuffer.wrap(this.tlv.getBytes(0x4));
+ while (dt.hasRemaining())
+ musicIds.add((int) dt.getShort());
+ }
+ }
+ }
+ }
+
+
+ public static class ExtendedMusicInfoParams {
+ public static final byte id = 0x0d;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = MusicControl.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01)
+ .put(0x02)
+ .put(0x03)
+ .put(0x04)
+ .put(0x05);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public HuaweiMusicUtils.MusicCapabilities params = new HuaweiMusicUtils.MusicCapabilities();
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (this.tlv.contains(0x01))
+ params.availableSpace = this.tlv.getAsInteger(0x01);
+ if (this.tlv.contains(0x02)) {
+ byte[] formatBits = this.tlv.getBytes(0x02);
+ params.supportedFormats = parseFormatBits(formatBits);
+ }
+ if (this.tlv.contains(0x03))
+ params.maxMusicCount = this.tlv.getAsInteger(0x03);
+ if (this.tlv.contains(0x04))
+ params.maxPlaylistCount = this.tlv.getAsInteger(0x04);
+ if (this.tlv.contains(0x05))
+ params.unknown = this.tlv.getByte(0x05);
+
+ if (this.tlv.contains(0x86)) {
+ params.formatsRestrictions = new ArrayList<>();
+ List<HuaweiTLV> subTlvs = this.tlv.getObject(0x86).getObjects(0x87);
+ for (HuaweiTLV subTlv : subTlvs) {
+ HuaweiMusicUtils.FormatRestrictions restriction = new HuaweiMusicUtils.FormatRestrictions();
+ if (subTlv.contains(0x08))
+ restriction.formatIdx = subTlv.getByte(0x08);
+ if (subTlv.contains(0x09)) {
+ String sampleRates = subTlv.getString(0x09);
+ restriction.sampleRates = sampleRates.split(",");
+ }
+ if (subTlv.contains(0x0a))
+ restriction.musicEncode = subTlv.getByte(0x0a);
+ if (subTlv.contains(0x0b))
+ restriction.bitrate = subTlv.getShort(0x0b);
+ if (subTlv.contains(0x0c))
+ restriction.channels = subTlv.getByte(0x0c);
+ if (subTlv.contains(0x0d))
+ restriction.unknownBitrate = subTlv.getShort(0x0b);
+ params.formatsRestrictions.add(restriction);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Notifications.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Notifications.java
new file mode 100644
index 0000000..22a1b8f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Notifications.java
@@ -0,0 +1,300 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.packets;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class Notifications {
+ public static final byte id = 0x02;
+ public static final byte[] defaultConstraints = new byte[]{
+ 0x00, 0x02, 0x00, 0x0F,
+ 0x00, 0x00, 0x00, 0x02, 0x00, 0x1E,
+ 0x00, 0x00, 0x00, 0x02, 0x00, 0x1E,
+ 0x00, 0x00, 0x00, 0x02, 0x00, 0x1E
+ };
+
+ public static class NotificationActionRequest extends HuaweiPacket {
+ public static final byte id = 0x01;
+
+ // TODO: support other types of notifications
+ // public static final int send = 0x01;
+ // public static final int notificationId = 0x01;
+ // public static final int notificationType = 0x02;
+ // public static final int vibrate = 0x03;
+ // public static final int payloadEmpty = 0x04;
+ // public static final int imageHeight = 0x08;
+ // public static final int imageWidth = 0x09;
+ // public static final int imageColor = 0x0A;
+ // public static final int imageData = 0x0B;
+ // public static final int textType = 0x0E;
+ // public static final int textEncoding = 0x0F;
+ // public static final int textContent = 0x10;
+ // public static final int sourceAppId = 0x11;
+ // public static final int payloadText = 0x84;
+ // public static final int payloadImage = 0x86;
+ // public static final int textList = 0x8C;
+ // public static final int textItem = 0x8D;
+
+ public NotificationActionRequest(
+ ParamsProvider paramsProvider,
+ short notificationId,
+ byte notificationType,
+ int encoding,
+ String titleContent,
+ String senderContent,
+ String bodyContent,
+ String sourceAppId
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = Notifications.id;
+ this.commandId = id;
+
+ // TODO: Add notification information per type if necessary
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, notificationId)
+ .put(0x02, notificationType)
+ .put(0x03, true); // This used to be vibrate, but doesn't work
+
+ HuaweiTLV subTlv = new HuaweiTLV();
+ if (titleContent != null && !titleContent.isEmpty())
+ subTlv.put(0x8D, new HuaweiTLV()
+ .put(0x0E, (byte) TextType.title)
+ .put(0x0F, (byte) encoding)
+ .put(0x10, titleContent)
+ );
+
+ if (senderContent != null && !senderContent.isEmpty())
+ subTlv.put(0x8D, new HuaweiTLV()
+ .put(0x0E, (byte) TextType.sender)
+ .put(0x0F, (byte) encoding)
+ .put(0x10, senderContent)
+ );
+
+ if (bodyContent != null && !bodyContent.isEmpty())
+ subTlv.put(0x8D, new HuaweiTLV()
+ .put(0x0E, (byte) TextType.text)
+ .put(0x0F, (byte) encoding)
+ .put(0x10, bodyContent)
+ );
+
+ if (subTlv.length() != 0) {
+ this.tlv.put(0x84, new HuaweiTLV().put(0x8C, subTlv));
+ } else {
+ this.tlv.put(0x04);
+ }
+
+ if (sourceAppId != null)
+ this.tlv.put(0x11, sourceAppId);
+
+ this.complete = true;
+ }
+ }
+
+ public static class NotificationConstraints {
+ public static final byte id = 0x02;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Notifications.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01);
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public ByteBuffer constraints;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Notifications.id;
+ this.commandId = id;
+ this.complete = true;
+ }
+
+ private void putByteBuffer(ByteBuffer bBuffer, byte position, byte[] value) {
+ ByteBuffer bValue = ByteBuffer.wrap(value);
+ if (bValue.capacity() == 4) {
+ short highbytes = bValue.getShort();
+ if (highbytes != 0) {
+ throw new RuntimeException("This should not happen until very large messages allowed");
+ }
+ bBuffer.putShort(position, bValue.getShort());
+ } else if (bValue.capacity() == 2) {
+ bBuffer.putShort(position, bValue.getShort());
+ } else {
+ bBuffer.put(position, (byte) 0x00);
+ bBuffer.put(position + 1, bValue.get());
+ }
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.constraints = ByteBuffer.allocate(22);
+ HuaweiTLV container = this.tlv
+ .getObject(0x81)
+ .getObject(0x82)
+ .getObject(0x90);
+ for (HuaweiTLV subContainer : container.getObjects(0x91)) {
+ if (subContainer.getByte(0x12) == 0x01) {
+ putByteBuffer(constraints, NotificationConstraintsType.contentFormat, new byte[] {0x02}); //Always 0x02 even if gadget report 0x03
+ putByteBuffer(constraints, NotificationConstraintsType.contentLength, subContainer.getBytes(0x14));
+ }
+ if (subContainer.getByte(0x12) == 0x05) {
+ constraints.putShort(NotificationConstraintsType.yellowPagesSupport,(short)0x01);
+ putByteBuffer(constraints, NotificationConstraintsType.yellowPagesFormat,subContainer.getBytes(0x13));
+ putByteBuffer(constraints, NotificationConstraintsType.yellowPagesLength,subContainer.getBytes(0x14));
+ }
+ if (subContainer.getByte(0x12) == 0x06) {
+ constraints.putShort(NotificationConstraintsType.contentSignSupport,(short)0x01);
+ putByteBuffer(constraints, NotificationConstraintsType.contentSignFormat,subContainer.getBytes(0x13));
+ putByteBuffer(constraints, NotificationConstraintsType.contentSignLength,subContainer.getBytes(0x14));
+ }
+ if (subContainer.getByte(0x12) == 0x07 ) {
+ constraints.putShort(NotificationConstraintsType.incomingNumberSupport,(short)0x01);
+ putByteBuffer(constraints, NotificationConstraintsType.incomingNumberFormat,subContainer.getBytes(0x13));
+ putByteBuffer(constraints, NotificationConstraintsType.incomingNumberLength,subContainer.getBytes(0x14));
+ }
+ }
+ constraints.rewind();
+ }
+ }
+ }
+
+ public static class NotificationConstraintsType {
+ // TODO: enum?
+ public static final byte contentFormat = 0x00;
+ public static final byte contentLength = 0x02;
+ public static final byte yellowPagesSupport = 0x04;
+ public static final byte yellowPagesFormat = 0x06;
+ public static final byte yellowPagesLength = 0x08;
+ public static final byte contentSignSupport = 0x0A;
+ public static final byte contentSignFormat = 0x0C;
+ public static final byte contentSignLength = 0x0E;
+ public static final byte incomingNumberSupport = 0x10;
+ public static final byte incomingNumberFormat = 0x12;
+ public static final byte incomingNumberLength = 0x14;
+ }
+
+ public static class NotificationType {
+ // TODO: enum?
+ public static final byte call = 0x01;
+ public static final byte sms = 0x02;
+ public static final byte weChat = 0x03;
+ public static final byte qq = 0x0B;
+ public static final byte stopNotification = 0x0C; // To stop showing a (call) notification
+ public static final byte missedCall = 0x0E;
+ public static final byte email = 0x0F;
+ public static final byte generic = 0x7F;
+ }
+
+ public static class TextType {
+ // TODO: enum?
+ public static final int text = 0x01;
+ public static final int sender = 0x02;
+ public static final int title = 0x03;
+ public static final int yellowPage = 0x05;
+ public static final int contentSign = 0x06;
+ public static final int flight = 0x07;
+ public static final int train = 0x08;
+ public static final int warmRemind = 0x09;
+ public static final int weather = 0x0A;
+ }
+
+ public static class NotificationStateRequest extends HuaweiPacket {
+ public static final byte id = 0x04;
+
+ public NotificationStateRequest(
+ ParamsProvider paramsProvider,
+ boolean status
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = Notifications.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x81, new HuaweiTLV()
+ .put(0x02, status)
+ .put(0x03, status)
+ );
+
+ this.complete = true;
+ }
+ }
+
+ public static class NotificationCapabilities {
+ public static final byte id = 0x05;
+
+ public static class Request extends HuaweiPacket {
+ public Request(
+ ParamsProvider paramsProvider
+ ){
+ super(paramsProvider);
+ this.serviceId = Notifications.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte capabilities = 0x00;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = DeviceConfig.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (this.tlv.contains(0x01))
+ this.capabilities = this.tlv.getByte(0x01);
+ }
+ }
+ }
+
+ public static class WearMessagePushRequest extends HuaweiPacket {
+ public static final byte id = 0x08;
+
+ public WearMessagePushRequest(
+ ParamsProvider paramsProvider,
+ boolean status
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = Notifications.id;
+ this.commandId = id;
+
+ /* Value sent is the opposite of the switch status */
+ this.tlv = new HuaweiTLV()
+ .put(0x01, !status);
+
+ this.complete = true;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/P2P.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/P2P.java
new file mode 100644
index 0000000..30eabb5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/P2P.java
@@ -0,0 +1,80 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class P2P {
+ public static final byte id = 0x34;
+
+ public static class P2PCommand {
+ public static final byte id = 0x01;
+
+ public static class Request extends HuaweiPacket {
+
+
+ public Request(ParamsProvider paramsProvider,
+ byte cmdId,
+ short sequenceId,
+ String srcPackage,
+ String dstPackage,
+ String srcFingerprint,
+ String dstFingerprint,
+ byte[] sendData,
+ int sendCode) {
+ super(paramsProvider);
+ this.serviceId = P2P.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, cmdId)
+ .put(0x02, sequenceId)
+ .put(0x03, srcPackage)
+ .put(0x04, dstPackage);
+ if(cmdId == 0x2) {
+ this.tlv.put(0x05, srcFingerprint);
+ this.tlv.put(0x06, dstFingerprint);
+ }
+ if(sendData != null && sendData.length > 0)
+ this.tlv.put(0x07, sendData);
+ if(cmdId == 0x3) {
+ this.tlv.put(0x08, sendCode);
+ }
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte cmdId;
+ public short sequenceId;
+ public String srcPackage;
+ public String dstPackage;
+ public String srcFingerprint = null;
+ public String dstFingerprint = null;
+ public byte[] respData = null;
+ public int respCode = 0;
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ cmdId = this.tlv.getByte(0x01);
+ sequenceId = this.tlv.getShort(0x02);
+ srcPackage = this.tlv.getString(0x03);
+ dstPackage = this.tlv.getString(0x04);
+ if(this.tlv.contains(0x05))
+ srcFingerprint = this.tlv.getString(0x05);
+ if(this.tlv.contains(0x06))
+ dstFingerprint = this.tlv.getString(0x06);
+ if(this.tlv.contains(0x07))
+ respData = this.tlv.getBytes(0x07);
+ // NOTE: P2P service uses different data types in responseCode(TLV 0x08).
+ // It sends byte from wearable but send integer to wearable
+ // So we have a change that other device can send different type.
+ if(this.tlv.contains(0x08))
+ respCode = this.tlv.getByte(0x08);
+ }
+
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Watchface.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Watchface.java
new file mode 100644
index 0000000..674aa7b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Watchface.java
@@ -0,0 +1,274 @@
+/* Copyright (C) 2024 Vitalii Tomin
+
+ 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.devices.huawei.packets;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class Watchface {
+ public static final byte id = 0x27;
+
+ public static class WatchfaceDeviceParams {
+ public String maxVersion = "";
+ public int width = 0;
+ public int height = 0;
+ public byte supportFileType = 1;
+ public byte sort = 1;
+ public String otherWatchfaceVersions = "";
+ }
+
+ public static class InstalledWatchfaceInfo {
+ public String fileName = "";
+ public String version = "";
+ public byte type = 0;
+ // bit 0 - is current
+ // bit 1 - is factory preset
+ // bit 2 - ???
+ // bit 3 - editable
+ // bit 4 - video
+ // bit 5 - photo
+ // bit 6 - tryout (trial version)
+ // bit 7 - kaleidoskop
+ public byte expandedtype = 0;
+
+ public InstalledWatchfaceInfo(HuaweiTLV tlv) throws HuaweiPacket.MissingTagException {
+ this.fileName = tlv.getString(0x03);
+ this.version = tlv.getString(0x04);
+ this.type = tlv.getByte(0x05);
+ if (tlv.contains(0x07)) // optional
+ this.expandedtype = tlv.getByte(0x07);
+ }
+
+ public boolean isCurrent() {
+ return (this.type & 1) == 1;
+ }
+ public boolean isFactory() {
+ return ((this.type >> 1 )& 1) == 1;
+ }
+ public boolean isEditable() {
+ return ((this.type >> 3 )& 1) == 1;
+ }
+ public boolean isVideo() {
+ return ((this.type >> 4 )& 1) == 1;
+ }
+ public boolean isPhoto() {
+ return ((this.type >> 5 )& 1) == 1;
+ }
+ public boolean isTryout() {
+ return ((this.type >> 6 )& 1) == 1;
+ }
+ public boolean isKaleidoskop() {
+ return ((this.type >> 7 )& 1) == 1;
+ }
+ }
+
+ public static class WatchfaceParams {
+ public static final byte id = 0x01;
+ public static class Request extends HuaweiPacket {
+
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Watchface.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01)
+ .put(0x02)
+ .put(0x03)
+ .put(0x04)
+ .put(0x05)
+ .put(0x0e)
+ .put(0x0f);
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public WatchfaceDeviceParams params = new WatchfaceDeviceParams();
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.params.maxVersion = this.tlv.getString(0x01);
+ if (this.tlv.getBytes(0x02).length == 4)
+ this.params.width = this.tlv.getInteger(0x02);
+ else
+ this.params.width = this.tlv.getShort(0x02);
+ if (this.tlv.getBytes(0x03).length == 4)
+ this.params.height = this.tlv.getInteger(0x03);
+ else
+ this.params.height = this.tlv.getShort(0x03);
+ this.params.supportFileType = this.tlv.getByte(0x04);
+ this.params.sort = this.tlv.getByte(0x05);
+ if (this.tlv.contains(0x06))
+ this.params.otherWatchfaceVersions = this.tlv.getString(0x06);
+ }
+ }
+ }
+
+ public static class DeviceWatchInfo {
+ public static final byte id = 0x02;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Watchface.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01)
+ .put(0x06, (byte) 0x03); //3 -overseas non-test, 2 - test, 1 -null?
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+
+ public List<InstalledWatchfaceInfo> watchfaceInfoList;
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ watchfaceInfoList = new ArrayList<>();
+ if(this.tlv.contains(0x81)) {
+ for (HuaweiTLV subTlv : this.tlv.getObject(0x81).getObjects(0x82)) {
+ watchfaceInfoList.add(new Watchface.InstalledWatchfaceInfo(subTlv));
+ }
+ }
+ }
+ }
+ }
+
+ public static class WatchfaceOperation {
+ public static final byte id = 0x03;
+
+ public static final byte operationActive = 1;
+ public static final byte operationDelete = 2;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider,
+ String fileName, byte operation) {
+ super(paramsProvider);
+ this.serviceId = Watchface.id;
+ this.tlv = new HuaweiTLV()
+ .put(0x01, fileName.split("_")[0])
+ .put(0x02, fileName.split("_")[1])
+ .put(0x03, operation);
+
+ this.commandId = id;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+ }
+ }
+
+ public static class WatchfaceConfirm {
+ public static final byte id = 0x05;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider,
+ String fileName) {
+ super(paramsProvider);
+ this.serviceId = Watchface.id;
+ this.commandId = id;
+ String file = fileName.split("_")[0];
+ String version = "";
+ try {
+ version = fileName.split("_")[1];
+ } catch (ArrayIndexOutOfBoundsException e) {
+
+ }
+ this.tlv = new HuaweiTLV()
+ .put(0x01, file)
+ .put(0x02, version)
+ .put(0x7f, 0x000186A0);
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public static byte reportType = 0;
+ public static String fileName;
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ String name = this.tlv.getString(0x01);
+ String version = this.tlv.getString(0x02);
+ if(this.tlv.contains(0x03)) {
+ this.reportType = this.tlv.getByte(0x03);
+ }
+ this.fileName = name + "_" + version;
+ }
+
+ }
+ }
+
+ public static class WatchfaceNameInfo {
+ public static final byte id = 0x06;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider,
+ List<InstalledWatchfaceInfo> watchfaceList) {
+ super(paramsProvider);
+ this.serviceId = Watchface.id;
+ this.commandId = id;
+
+ HuaweiTLV tlvList = new HuaweiTLV();
+ for (InstalledWatchfaceInfo watchface : watchfaceList) {
+ //TODO: ask name only for custom watchfaces
+ HuaweiTLV wfTlv = new HuaweiTLV().put(0x04, watchface.fileName);
+ tlvList.put(0x83, wfTlv);
+ }
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, (byte) 0x01)
+ .put(0x82, tlvList);
+
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public HashMap<String, String> watchFaceNames = new HashMap<String, String>();
+ public Response (ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws HuaweiPacket.ParseException {
+ if(this.tlv.contains(0x82)) {
+ for (HuaweiTLV subTlv : this.tlv.getObject(0x82).getObjects(0x83)) {
+ watchFaceNames.put(subTlv.getString(0x04), subTlv.getString(0x05));
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Weather.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Weather.java
new file mode 100644
index 0000000..8f51423
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Weather.java
@@ -0,0 +1,829 @@
+/* Copyright (C) 2024 Martin.JM
+
+ 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.devices.huawei.packets;
+
+import androidx.annotation.NonNull;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class Weather {
+ public static final byte id = 0x0f;
+
+ public static class Settings {
+ // WeatherSupport
+ public boolean weatherSupported = false;
+ public boolean windSupported = false;
+ public boolean pm25Supported = false;
+ public boolean temperatureSupported = false;
+ public boolean locationNameSupported = false;
+ public boolean currentTemperatureSupported = false;
+ public boolean unitSupported = false;
+ public boolean airQualityIndexSupported = false;
+
+ // WeatherExtendedSupport
+ public boolean timeSupported = false;
+ public boolean sourceSupported = false;
+ public boolean weatherIconSupported = false;
+
+ // WeatherSunMoonSupport
+ public boolean sunRiseSetSupported = false;
+ public boolean moonPhaseSupported = false;
+
+ // ExpandCapabilityRequest
+ public boolean uvIndexSupported = false;
+ }
+
+ public enum WeatherIcon {
+ // Also used for the text on the watch
+ SUNNY,
+ CLOUDY,
+ OVERCAST,
+ SHOWERS,
+ THUNDERSTORMS,
+ THUNDER_AND_HAIL,
+ SLEET,
+ LIGHT_RAIN,
+ RAIN,
+ HEAVY_RAIN,
+ RAIN_STORM,
+ HEAVY_RAIN_STORMS,
+ SEVERE_RAIN_STORMS,
+ SNOW_FLURRIES,
+ LIGHT_SNOW,
+ SNOW,
+ HEAVY_SNOW,
+ SNOWSTORMS,
+ FOG,
+ FREEZING_RAIN,
+ DUST_STORM,
+ LIGHT_TO_MODERATE_RAIN,
+ MODERATE_TO_HEAVY_RAIN,
+ HEAVY_TO_SEVERE_RAIN,
+ HEAVY_TO_TORRENTIAL_RAIN,
+ SEVERE_TO_TORRENTIAL_RAIN,
+ LIGHT_TO_MODERATE_SNOW,
+ MODERATE_TO_HEAVY_SNOW,
+ HEAVY_SNOW_TO_BLIZZARD,
+ DUST,
+ SAND,
+ SANDSTORMS,
+ FREEZING, // misses small/non-moving icon
+ HOT, // misses small/non-moving icon
+ COLD, // misses small/non-moving icon
+ WINDY,
+ HAZY,
+ UNKNOWN // Good to have probably
+ }
+
+ private static byte iconToByte(WeatherIcon weatherIcon) {
+ switch (weatherIcon) {
+ case SUNNY:
+ return 0x00;
+ case CLOUDY:
+ return 0x01;
+ case OVERCAST:
+ return 0x02;
+ case SHOWERS:
+ return 0x03;
+ case THUNDERSTORMS:
+ return 0x04;
+ case THUNDER_AND_HAIL:
+ return 0x05;
+ case SLEET:
+ return 0x06;
+ case LIGHT_RAIN:
+ return 0x07;
+ case RAIN:
+ return 0x08;
+ case HEAVY_RAIN:
+ return 0x09;
+ case RAIN_STORM:
+ return 0x0a;
+ case HEAVY_RAIN_STORMS:
+ return 0x0b;
+ case SEVERE_RAIN_STORMS:
+ return 0x0c;
+ case SNOW_FLURRIES:
+ return 0x0d;
+ case LIGHT_SNOW:
+ return 0x0e;
+ case SNOW:
+ return 0x0f;
+ case HEAVY_SNOW:
+ return 0x10;
+ case SNOWSTORMS:
+ return 0x11;
+ case FOG:
+ return 0x12;
+ case FREEZING_RAIN:
+ return 0x13;
+ case DUST_STORM:
+ return 0x14;
+ case LIGHT_TO_MODERATE_RAIN:
+ return 0x15;
+ case MODERATE_TO_HEAVY_RAIN:
+ return 0x16;
+ case HEAVY_TO_SEVERE_RAIN:
+ return 0x17;
+ case HEAVY_TO_TORRENTIAL_RAIN:
+ return 0x18;
+ case SEVERE_TO_TORRENTIAL_RAIN:
+ return 0x19;
+ case LIGHT_TO_MODERATE_SNOW:
+ return 0x1a;
+ case MODERATE_TO_HEAVY_SNOW:
+ return 0x1b;
+ case HEAVY_SNOW_TO_BLIZZARD:
+ return 0x1c;
+ case DUST:
+ return 0x1d;
+ case SAND:
+ return 0x1e;
+ case SANDSTORMS:
+ return 0x1f;
+ case FREEZING:
+ return 0x20;
+ case HOT:
+ return 0x21;
+ case COLD:
+ return 0x22;
+ case WINDY:
+ return 0x23;
+ case HAZY:
+ return 0x35;
+ default:
+ return 0x63; // Any higher and the current weather breaks
+ }
+ }
+
+ private static WeatherIcon byteToIcon(byte weatherIcon) {
+ switch (weatherIcon) {
+ case 0x00:
+ return WeatherIcon.SUNNY;
+ case 0x01:
+ return WeatherIcon.CLOUDY;
+ case 0x02:
+ return WeatherIcon.OVERCAST;
+ case 0x03:
+ return WeatherIcon.SHOWERS;
+ case 0x04:
+ return WeatherIcon.THUNDERSTORMS;
+ case 0x05:
+ return WeatherIcon.THUNDER_AND_HAIL;
+ case 0x06:
+ return WeatherIcon.SLEET;
+ case 0x07:
+ return WeatherIcon.LIGHT_RAIN;
+ case 0x08:
+ return WeatherIcon.RAIN;
+ case 0x09:
+ return WeatherIcon.HEAVY_RAIN;
+ case 0x0a:
+ return WeatherIcon.RAIN_STORM;
+ case 0x0b:
+ return WeatherIcon.HEAVY_RAIN_STORMS;
+ case 0x0c:
+ return WeatherIcon.SEVERE_RAIN_STORMS;
+ case 0x0d:
+ return WeatherIcon.SNOW_FLURRIES;
+ case 0x0e:
+ return WeatherIcon.LIGHT_SNOW;
+ case 0x0f:
+ return WeatherIcon.SNOW;
+ case 0x10:
+ return WeatherIcon.HEAVY_SNOW;
+ case 0x11:
+ return WeatherIcon.SNOWSTORMS;
+ case 0x12:
+ return WeatherIcon.FOG;
+ case 0x13:
+ return WeatherIcon.FREEZING_RAIN;
+ case 0x14:
+ return WeatherIcon.DUST_STORM;
+ case 0x15:
+ return WeatherIcon.LIGHT_TO_MODERATE_RAIN;
+ case 0x16:
+ return WeatherIcon.MODERATE_TO_HEAVY_RAIN;
+ case 0x17:
+ return WeatherIcon.HEAVY_TO_SEVERE_RAIN;
+ case 0x18:
+ return WeatherIcon.HEAVY_TO_TORRENTIAL_RAIN;
+ case 0x19:
+ return WeatherIcon.SEVERE_TO_TORRENTIAL_RAIN;
+ case 0x1a:
+ return WeatherIcon.LIGHT_TO_MODERATE_SNOW;
+ case 0x1b:
+ return WeatherIcon.MODERATE_TO_HEAVY_SNOW;
+ case 0x1c:
+ return WeatherIcon.HEAVY_SNOW_TO_BLIZZARD;
+ case 0x1d:
+ return WeatherIcon.DUST;
+ case 0x1e:
+ return WeatherIcon.SAND;
+ case 0x1f:
+ return WeatherIcon.SANDSTORMS;
+ case 0x20:
+ return WeatherIcon.FREEZING;
+ case 0x21:
+ return WeatherIcon.HOT;
+ case 0x22:
+ return WeatherIcon.COLD;
+ case 0x23:
+ return WeatherIcon.WINDY;
+ case 0x35:
+ return WeatherIcon.HAZY;
+ default:
+ return WeatherIcon.UNKNOWN;
+ }
+ }
+
+ public enum HuaweiTemperatureFormat {
+ CELSIUS,
+ FAHRENHEIT
+ }
+
+ private static byte temperatureFormatToByte(HuaweiTemperatureFormat temperatureFormat) {
+ if (temperatureFormat == HuaweiTemperatureFormat.FAHRENHEIT)
+ return 1;
+ return 0;
+ }
+
+ public enum MoonPhase {
+ NEW_MOON,
+ WAXING_CRESCENT,
+ FIRST_QUARTER,
+ WAXING_GIBBOUS,
+ FULL_MOON,
+ WANING_GIBBOUS,
+ THIRD_QUARTER,
+ WANING_CRESCENT
+ }
+
+ public static MoonPhase degreesToMoonPhase(int degrees) {
+ final int leeway = 6; // Give some leeway for the new moon, first quarter, full moon, and third quarter
+ if (degrees < 0 || degrees > 360)
+ return null;
+ else if (degrees >= 360 - leeway || degrees <= leeway)
+ return MoonPhase.NEW_MOON;
+ else if (degrees < 90)
+ return MoonPhase.WAXING_CRESCENT;
+ else if (degrees <= 90 + leeway)
+ return MoonPhase.FIRST_QUARTER;
+ else if (degrees < 180 - leeway)
+ return MoonPhase.WAXING_GIBBOUS;
+ else if (degrees <= 180 + leeway)
+ return MoonPhase.FULL_MOON;
+ else if (degrees < 270 - leeway)
+ return MoonPhase.WANING_GIBBOUS;
+ else if (degrees <= 270 + leeway)
+ return MoonPhase.THIRD_QUARTER;
+ else
+ return MoonPhase.WANING_CRESCENT;
+ }
+
+ private static byte moonPhaseToByte (MoonPhase moonPhase) {
+ switch (moonPhase) {
+ case NEW_MOON:
+ return 1;
+ case WAXING_CRESCENT:
+ return 2;
+ case FIRST_QUARTER:
+ return 3;
+ case WAXING_GIBBOUS:
+ return 4;
+ case FULL_MOON:
+ return 5;
+ case WANING_GIBBOUS:
+ return 6;
+ case THIRD_QUARTER:
+ return 7;
+ case WANING_CRESCENT:
+ return 8;
+ default:
+ return -1;
+ }
+ }
+
+ private static MoonPhase byteToMoonPhase(byte moonPhase) {
+ switch (moonPhase) {
+ case 1:
+ return MoonPhase.NEW_MOON;
+ case 2:
+ return MoonPhase.WAXING_CRESCENT;
+ case 3:
+ return MoonPhase.FIRST_QUARTER;
+ case 4:
+ return MoonPhase.WAXING_GIBBOUS;
+ case 5:
+ return MoonPhase.FULL_MOON;
+ case 6:
+ return MoonPhase.WANING_GIBBOUS;
+ case 7:
+ return MoonPhase.THIRD_QUARTER;
+ case 8:
+ return MoonPhase.WANING_CRESCENT;
+ default:
+ return null;
+ }
+ }
+
+ public enum ErrorCode {
+ NETWORK_ERROR,
+ GPS_PERMISSION_ERROR,
+ WEATHER_DISABLED
+ }
+
+ private static byte errorCodeToByte(ErrorCode errorCode) {
+ switch (errorCode) {
+ case NETWORK_ERROR:
+ return 0;
+ case GPS_PERMISSION_ERROR:
+ return 1;
+ case WEATHER_DISABLED:
+ return 2;
+ }
+ throw new RuntimeException(); // Shouldn't happen
+ }
+
+ public static class CurrentWeatherRequest extends HuaweiPacket {
+ public static final byte id = 0x01;
+
+ public CurrentWeatherRequest(
+ ParamsProvider paramsProvider,
+ Settings settings,
+ WeatherIcon icon,
+ Byte windDirection,
+ Byte windSpeed,
+ Byte lowestTemperature,
+ Byte highestTemperature,
+ Short pm25, // TODO: might be float?
+ String locationName,
+ Byte currentTemperature,
+ HuaweiTemperatureFormat temperatureUnit,
+ Short airQualityIndex,
+ Integer observationTime,
+ Float uvIndex,
+ String sourceName
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV();
+
+ HuaweiTLV tlv81 = new HuaweiTLV();
+
+ if (icon != null && settings.weatherIconSupported) {
+ tlv81.put(0x02, iconToByte(icon));
+ }
+
+ if (settings.windSupported) {
+ short wind = 0;
+ if (windSpeed != null)
+ wind = (short) windSpeed;
+
+ if (windDirection != null) {
+ if (windDirection > 0)
+ wind |= (short) (((short) (windDirection * 8 / 360)) << 8);
+ else
+ wind |= (short) (((short) (360 + windDirection) * 8 / 360) << 8);
+ }
+ tlv81.put(0x03, wind);
+ }
+
+ if (settings.weatherIconSupported || settings.windSupported)
+ this.tlv.put(0x81, tlv81);
+
+ if (lowestTemperature != null && highestTemperature != null && settings.temperatureSupported) {
+ this.tlv.put(0x85, new HuaweiTLV()
+ .put(0x06, lowestTemperature)
+ .put(0x07, highestTemperature)
+ );
+ }
+ if (windDirection != null && windSpeed != null && settings.windSupported)
+ this.tlv.put(0x03, (short) ((((short) windDirection) << 8) | ((short) windSpeed)));
+ if (pm25 != null && settings.pm25Supported)
+ this.tlv.put(0x04, pm25);
+ if (locationName != null && settings.locationNameSupported)
+ this.tlv.put(0x08, locationName);
+ if (currentTemperature != null && settings.currentTemperatureSupported)
+ this.tlv.put(0x09, currentTemperature);
+ if (temperatureUnit != null && settings.unitSupported)
+ this.tlv.put(0x0a, temperatureFormatToByte(temperatureUnit));
+ if (airQualityIndex != null && settings.airQualityIndexSupported)
+ this.tlv.put(0x0b, airQualityIndex);
+ if (observationTime != null && settings.timeSupported)
+ this.tlv.put(0x0c, observationTime);
+ if (sourceName != null && settings.sourceSupported)
+ this.tlv.put(0x0e, sourceName);
+ if (uvIndex != null && settings.uvIndexSupported)
+ this.tlv.put(0x0f, uvIndex.byteValue());
+
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+
+ public static class WeatherSupport {
+ public static final byte id = 0x02;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV().put(0x01);
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte supportedBitmap = 0;
+
+ public boolean weatherSupported = false;
+ public boolean windSupported = false;
+ public boolean pm25Supported = false;
+ public boolean temperatureSupported = false;
+ public boolean locationNameSupported = false;
+ public boolean currentTemperatureSupported = false;
+ public boolean unitSupported = false;
+ public boolean airQualityIndexSupported = false;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (!this.tlv.contains(0x01))
+ throw new MissingTagException(0x01);
+ this.supportedBitmap = this.tlv.getByte(0x01);
+
+ this.weatherSupported = (this.supportedBitmap & 0x01) != 0;
+ this.windSupported = (this.supportedBitmap & 0x02) != 0;
+ this.pm25Supported = (this.supportedBitmap & 0x04) != 0;
+ this.temperatureSupported = (this.supportedBitmap & 0x08) != 0;
+ this.locationNameSupported = (this.supportedBitmap & 0x10) != 0;
+ this.currentTemperatureSupported = (this.supportedBitmap & 0x20) != 0;
+ this.unitSupported = (this.supportedBitmap & 0x40) != 0;
+ this.airQualityIndexSupported = (this.supportedBitmap & 0x80) != 0;
+ }
+ }
+ }
+
+ public static class WeatherDeviceRequest extends HuaweiPacket {
+ public static final byte id = 0x04;
+
+ public WeatherDeviceRequest(ParamsProvider paramsProvider, int responseValue) {
+ super(paramsProvider);
+
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV().put(0x01, responseValue);
+ this.isEncrypted = false;
+ this.complete = true;
+ }
+ }
+
+ public static class WeatherUnitRequest extends HuaweiPacket {
+ public static final byte id = 0x05;
+
+ public WeatherUnitRequest(ParamsProvider paramsProvider, HuaweiTemperatureFormat temperatureFormat) {
+ super(paramsProvider);
+
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV().put(0x01, temperatureFormatToByte(temperatureFormat));
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+
+ public static class WeatherExtendedSupport {
+ public static final byte id = 0x06;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV().put(0x01);
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public short supportedBitmap = 0;
+
+ public boolean timeSupported = false;
+ public boolean sourceSupported = false;
+ public boolean weatherIconSupported = false;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (!this.tlv.contains(0x01))
+ throw new MissingTagException(0x01);
+ this.supportedBitmap = this.tlv.getShort(0x01);
+
+ this.timeSupported = (this.supportedBitmap & 0x01) != 0;
+ this.sourceSupported = (this.supportedBitmap & 0x02) != 0;
+ this.weatherIconSupported = (this.supportedBitmap & 0x04) != 0;
+ }
+ }
+ }
+
+ public static class WeatherErrorSimple {
+ public static final byte id = 0x07;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, ErrorCode errorCode) {
+ super(paramsProvider);
+
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV().put(0x01, errorCodeToByte(errorCode));
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+ }
+
+ public static class WeatherForecastData {
+ public static final byte id = 0x08;
+
+ public static class TimeData {
+ public int timestamp;
+ public WeatherIcon icon;
+ public Byte temperature;
+
+ @NonNull
+ @Override
+ public String toString() {
+ String timestampStr = new Date(timestamp * 1000L).toString();
+ return "TimeData{" +
+ "timestamp=" + timestamp +
+ ", timestamp=" + timestampStr +
+ ", icon=" + icon +
+ ", temperature=" + temperature +
+ '}';
+ }
+ }
+
+ public static class DayData {
+ public int timestamp;
+ public WeatherIcon icon;
+ public Byte highTemperature;
+ public Byte lowTemperature;
+ public Integer sunriseTime;
+ public Integer sunsetTime;
+ public Integer moonRiseTime;
+ public Integer moonSetTime;
+ public MoonPhase moonPhase;
+
+ @NonNull
+ @Override
+ public String toString() {
+ String timestampStr = new Date(timestamp * 1000L).toString();
+ return "DayData{" +
+ "timestamp=" + timestamp +
+ ", timestamp=" + timestampStr +
+ ", icon=" + icon +
+ ", highTemperature=" + highTemperature +
+ ", lowTemperature=" + lowTemperature +
+ ", sunriseTime=" + sunriseTime +
+ ", sunsetTime=" + sunsetTime +
+ ", moonRiseTime=" + moonRiseTime +
+ ", moonSetTime=" + moonSetTime +
+ ", moonPhase=" + moonPhase +
+ '}';
+ }
+ }
+
+ public static class Request extends HuaweiPacket {
+ public Request(
+ ParamsProvider paramsProvider,
+ Settings settings,
+ List<TimeData> timeDataList,
+ List<DayData> dayDataList
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV();
+
+ if (timeDataList != null && !timeDataList.isEmpty()) {
+ HuaweiTLV timeDataTlv = new HuaweiTLV();
+ for (TimeData timeData : timeDataList) {
+ HuaweiTLV timeTlv = new HuaweiTLV();
+ timeTlv.put(0x03, timeData.timestamp);
+ if (timeData.icon != null && settings.weatherIconSupported)
+ timeTlv.put(0x04, iconToByte(timeData.icon));
+ if (timeData.temperature != null && (settings.temperatureSupported || settings.currentTemperatureSupported))
+ timeTlv.put(0x05, timeData.temperature);
+ timeDataTlv.put(0x82, timeTlv);
+ }
+ this.tlv.put(0x81, timeDataTlv);
+ }
+
+ if (dayDataList != null && !dayDataList.isEmpty()) {
+ HuaweiTLV dayDataTlv = new HuaweiTLV();
+ for (DayData dayData : dayDataList) {
+ HuaweiTLV dayTlv = new HuaweiTLV();
+ dayTlv.put(0x12, dayData.timestamp);
+ if (dayData.icon != null && settings.weatherIconSupported)
+ dayTlv.put(0x13, iconToByte(dayData.icon));
+ if (settings.temperatureSupported) {
+ if (dayData.highTemperature != null)
+ dayTlv.put(0x14, dayData.highTemperature);
+ if (dayData.lowTemperature != null)
+ dayTlv.put(0x15, dayData.lowTemperature);
+ }
+ if (settings.sunRiseSetSupported) {
+ if (dayData.sunriseTime != null && dayData.sunriseTime != 0)
+ dayTlv.put(0x16, dayData.sunriseTime);
+ if (dayData.sunsetTime != null && dayData.sunsetTime != 0)
+ dayTlv.put(0x17, dayData.sunsetTime);
+ if (dayData.moonRiseTime != null && dayData.moonRiseTime != 0)
+ dayTlv.put(0x1a, dayData.moonRiseTime);
+ if (dayData.moonSetTime != null && dayData.moonSetTime != 0)
+ dayTlv.put(0x1b, dayData.moonSetTime);
+ }
+ if (dayData.moonPhase != null && settings.moonPhaseSupported)
+ dayTlv.put(0x1e, moonPhaseToByte(dayData.moonPhase));
+ dayDataTlv.put(0x91, dayTlv);
+ }
+ this.tlv.put(0x90, dayDataTlv);
+ }
+
+ this.isEncrypted = true;
+ this.isSliced = true;
+ this.complete = true;
+ }
+ }
+
+ public static class OutgoingRequest extends HuaweiPacket {
+ List<TimeData> timeDataList;
+ List<DayData> dayDataList;
+
+ public OutgoingRequest(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.complete = false;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ timeDataList = new ArrayList<>(this.tlv.getObject(0x81).getObjects(0x82).size());
+ for (HuaweiTLV timeTlv : this.tlv.getObject(0x81).getObjects(0x82)) {
+ TimeData timeData = new TimeData();
+ timeData.timestamp = timeTlv.getInteger(0x03);
+ timeData.icon = byteToIcon(timeTlv.getByte(0x04));
+ timeData.temperature = timeTlv.getByte(0x05);
+ timeDataList.add(timeData);
+ }
+
+ dayDataList = new ArrayList<>(this.tlv.getObject(0x90).getObjects(0x91).size());
+ for (HuaweiTLV dayTlv : this.tlv.getObject(0x90).getObjects(0x91)) {
+ DayData dayData = new DayData();
+ dayData.timestamp = dayTlv.getInteger(0x12);
+ dayData.icon = byteToIcon(dayTlv.getByte(0x13));
+ dayData.highTemperature = dayTlv.getByte(0x14);
+ dayData.lowTemperature = dayTlv.getByte(0x15);
+ dayData.sunriseTime = dayTlv.getInteger(0x16);
+ dayData.sunsetTime = dayTlv.getInteger(0x17);
+ dayData.moonRiseTime = dayTlv.getInteger(0x1a);
+ dayData.moonSetTime = dayTlv.getInteger(0x1b);
+ dayData.moonPhase = byteToMoonPhase(dayTlv.getByte(0x1e));
+ dayDataList.add(dayData);
+ }
+ }
+ }
+ }
+
+ public static class WeatherStart {
+ public static final byte id = 0x09;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV().put(0x01, (byte) 0x03); // TODO: find out what this means
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public int successCode = -1;
+ public boolean success = false;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ this.successCode = this.tlv.getInteger(0x7f);
+ this.success = this.successCode == 0x000186A0 || this.successCode == 0x000186A3;
+ }
+ }
+ }
+
+ public static class WeatherSunMoonSupport {
+ public static final byte id = 0x0a;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ this.tlv = new HuaweiTLV().put(0x01);
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public int supportedBitmap = 0;
+
+ public boolean sunRiseSetSupported = false;
+ public boolean moonPhaseSupported = false;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ this.serviceId = Weather.id;
+ this.commandId = id;
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ if (!this.tlv.contains(0x01))
+ throw new MissingTagException(0x01);
+ this.supportedBitmap = this.tlv.getInteger(0x01);
+
+ this.sunRiseSetSupported = (this.supportedBitmap & 0x01) != 0;
+ this.moonPhaseSupported = (this.supportedBitmap & 0x02) != 0;
+ }
+ }
+ }
+
+ public static class WeatherErrorExtended {
+ public static final byte id = 0x0c;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider, ErrorCode errorCode, boolean serverError) {
+ super(paramsProvider);
+
+ this.serviceId = Weather.id;
+ this.commandId = id;
+
+ HuaweiTLV innerTlv = new HuaweiTLV();
+ innerTlv.put(0x02, errorCodeToByte(errorCode));
+ if (errorCode == ErrorCode.NETWORK_ERROR && serverError)
+ innerTlv.put(0x03, (byte) 0x01);
+
+ this.tlv = new HuaweiTLV().put(0x81, innerTlv);
+
+ this.isEncrypted = true;
+ this.complete = true;
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/WorkMode.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/WorkMode.java
new file mode 100644
index 0000000..10d93e0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/WorkMode.java
@@ -0,0 +1,57 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.packets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class WorkMode {
+ public static final byte id = 0x26;
+
+ /*
+ * public static class ModeStatus {
+ * public static final byte id = 0x01;
+ * public static final int autoDetectMode = 0x01;
+ * public static final int footWear = 0x02;
+ * }
+ */
+
+ public static class SwitchStatusRequest extends HuaweiPacket {
+ public static final byte id = 0x02;
+ public static final int setStatus = 0x01;
+
+ public SwitchStatusRequest(ParamsProvider paramsProvider, boolean autoWorkMode) {
+ super(paramsProvider);
+
+ this.serviceId = WorkMode.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x01, autoWorkMode);
+
+ this.complete = true;
+ }
+ }
+
+ /*
+ * public static class FootWear {
+ * public static final byte id = 0x03;
+ * public static final int AutoDetectMode = 0x01;
+ * public static final int FootWear = 0x02;
+ * }
+ */
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Workout.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Workout.java
new file mode 100644
index 0000000..19c604f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/packets/Workout.java
@@ -0,0 +1,740 @@
+/* Copyright (C) 2024 Damien Gaignon, Martin.JM
+
+ 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.devices.huawei.packets;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV;
+
+public class Workout {
+ public static final byte id = 0x17;
+
+ public static class WorkoutCount {
+ public static final byte id = 0x07;
+
+ public static class Request extends HuaweiPacket {
+ public Request(
+ ParamsProvider paramsProvider,
+ int start,
+ int end
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = Workout.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV()
+ .put(0x81, new HuaweiTLV()
+ .put(0x03, start)
+ .put(0x04, end)
+ );
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public static class WorkoutNumbers {
+ public byte[] rawData;
+
+ public short workoutNumber;
+ public short dataCount;
+ public short paceCount;
+ public short segmentsCount = 0;
+
+ }
+
+ public short count;
+ public List<WorkoutNumbers> workoutNumbers;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ HuaweiTLV container = this.tlv.getObject(0x81);
+
+ this.count = container.getShort(0x02);
+ this.workoutNumbers = new ArrayList<>();
+
+ if (this.count == 0)
+ return;
+
+ List<HuaweiTLV> subContainers = container.getObjects(0x85);
+ for (HuaweiTLV subContainerTlv : subContainers) {
+ WorkoutNumbers workoutNumber = new WorkoutNumbers();
+ workoutNumber.rawData = subContainerTlv.serialize();
+ workoutNumber.workoutNumber = subContainerTlv.getShort(0x06);
+ workoutNumber.dataCount = subContainerTlv.getShort(0x07);
+ workoutNumber.paceCount = subContainerTlv.getShort(0x08);
+ if(subContainerTlv.contains(0x09)) {
+ workoutNumber.segmentsCount = subContainerTlv.getShort(0x09);
+ }
+ this.workoutNumbers.add(workoutNumber);
+ }
+
+ // Has to be sorted for the timestamp-based sync start that we use in the HuaweiSupportProvider
+ this.workoutNumbers.sort(Comparator.comparingInt(o -> o.workoutNumber));
+ }
+ }
+ }
+
+ public static class WorkoutTotals {
+ public static final byte id = 0x08;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request(ParamsProvider paramsProvider, short number) {
+ super(paramsProvider);
+
+ this.serviceId = Workout.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x81, new HuaweiTLV()
+ .put(0x02, number)
+ );
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public byte[] rawData;
+
+ public short number;
+ public byte status = -1; // TODO: enum?
+ public int startTime;
+ public int endTime;
+ public int calories = -1;
+ public int distance = -1;
+ public int stepCount = -1;
+ public int totalTime = -1;
+ public int duration = -1;
+ public byte type = -1; // TODO: enum?
+ public short strokes = -1;
+ public short avgStrokeRate = -1;
+ public short poolLength = -1; // In cm
+ public short laps = -1;
+ public short avgSwolf = -1;
+
+ public Integer maxAltitude = null;
+ public Integer minAltitude = null;
+ public Integer elevationGain = null;
+ public Integer elevationLoss = null;
+
+ public int workoutLoad = 0;
+ public int workoutAerobicEffect = 0;
+ public byte workoutAnaerobicEffect = -1;
+ public short recoveryTime = 0;
+
+ public byte minHeartRatePeak = 0;
+ public byte maxHeartRatePeak = 0;
+
+ public byte[] recoveryHeartRates = null;
+
+ public byte swimType = -1;
+
+ public int maxMET = 0;
+
+ public byte hrZoneType = -1;
+
+ public short runPaceZone1Min = -1;
+ public short runPaceZone2Min = -1;
+ public short runPaceZone3Min = -1;
+ public short runPaceZone4Min = -1;
+ public short runPaceZone5Min = -1;
+ public short runPaceZone5Max = -1;
+
+ public short runPaceZone1Time = -1;
+ public short runPaceZone2Time = -1;
+ public short runPaceZone3Time = -1;
+ public short runPaceZone4Time = -1;
+ public short runPaceZone5Time = -1;
+
+ public byte algType = 0;
+
+ public int trainingPoints = -1;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ HuaweiTLV container = this.tlv.getObject(0x81);
+
+ this.rawData = container.serialize();
+ this.number = container.getShort(0x02);
+ if (container.contains(0x03))
+ this.status = container.getByte(0x03);
+ this.startTime = container.getInteger(0x04);
+ this.endTime = container.getInteger(0x05);
+
+ if (container.contains(0x06))
+ this.calories = container.getInteger(0x06);
+ if (container.contains(0x07))
+ this.distance = container.getInteger(0x07);
+ if (container.contains(0x08))
+ this.stepCount = container.getInteger(0x08);
+ if (container.contains(0x09))
+ this.totalTime = container.getInteger(0x09);
+ if (container.contains(0x0b))
+ this.elevationGain = container.getInteger(0x0b);
+ if (container.contains(0x0c)) {
+ byte[] hrData = container.getBytes(0x0c);
+ minHeartRatePeak = hrData[0];
+ maxHeartRatePeak = hrData[1];
+ }
+ if (container.contains(0x0d))
+ this.workoutLoad = container.getInteger(0x0d);
+ if (container.contains(0x0e))
+ this.workoutAerobicEffect = container.getInteger(0x0e);
+ if (container.contains(0x10))
+ this.maxMET = container.getInteger(0x10);
+ if (container.contains(0x11))
+ this.recoveryTime = container.getShort(0x11);
+ if (container.contains(0x12))
+ this.duration = container.getInteger(0x12);
+ if (container.contains(0x14))
+ this.type = container.getByte(0x14);
+ if (container.contains(0x15))
+ this.swimType = container.getByte(0x15);
+ if (container.contains(0x16))
+ this.strokes = container.getShort(0x16);
+ if (container.contains(0x17))
+ this.avgStrokeRate = container.getShort(0x17);
+ if (container.contains(0x18))
+ this.poolLength = container.getShort(0x18);
+ if (container.contains(0x19))
+ this.laps = container.getShort(0x19);
+ if (container.contains(0x1a))
+ this.avgSwolf = container.getShort(0x1a);
+ if (container.contains(0x1b))
+ this.elevationLoss = container.getInteger(0x1b);
+ if (container.contains(0x1c))
+ this.maxAltitude = container.getInteger(0x1c);
+ if (container.contains(0x1d))
+ this.minAltitude = container.getInteger(0x1d);
+ if (container.contains(0x20))
+ this.workoutAnaerobicEffect = container.getByte(0x20);
+ if (container.contains(0x24))
+ this.hrZoneType = container.getByte(0x24);
+ if (container.contains(0x50))
+ this.runPaceZone1Min = container.getShort(0x50);
+ if (container.contains(0x51))
+ this.runPaceZone2Min = container.getShort(0x51);
+ if (container.contains(0x52))
+ this.runPaceZone3Min = container.getShort(0x52);
+ if (container.contains(0x53))
+ this.runPaceZone4Min = container.getShort(0x53);
+ if (container.contains(0x54))
+ this.runPaceZone5Min = container.getShort(0x54);
+ if (container.contains(0x55))
+ this.runPaceZone5Max = container.getShort(0x55);
+ if (container.contains(0x56))
+ this.runPaceZone1Time = container.getShort(0x56);
+ if (container.contains(0x57))
+ this.runPaceZone2Time = container.getShort(0x57);
+ if (container.contains(0x58))
+ this.runPaceZone3Time = container.getShort(0x58);
+ if (container.contains(0x59))
+ this.runPaceZone4Time = container.getShort(0x59);
+ if (container.contains(0x5a))
+ this.runPaceZone5Time = container.getShort(0x5a);
+ if (container.contains(0x5d))
+ this.algType = container.getByte(0x5d);
+ if (container.contains(0x63))
+ this.trainingPoints = container.getShort(0x63);
+ if (container.contains(0x66))
+ this.recoveryHeartRates = container.getBytes(0x66);
+ }
+ }
+ }
+
+ public static class WorkoutData {
+ public static final int id = 0x0a;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request(
+ ParamsProvider paramsProvider,
+ short workoutNumber,
+ short dataNumber
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = Workout.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x81, new HuaweiTLV()
+ .put(0x02, workoutNumber)
+ .put(0x03, dataNumber)
+ );
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public static class Header {
+ public short workoutNumber;
+ public short dataNumber;
+ public int timestamp;
+ public byte interval;
+ public short dataCount;
+ public byte dataLength;
+ public short bitmap; // TODO: can this be enum-like?
+
+ @Override
+ public String toString() {
+ return "Header{" +
+ "workoutNumber=" + workoutNumber +
+ ", dataNumber=" + dataNumber +
+ ", timestamp=" + timestamp +
+ ", interval=" + interval +
+ ", dataCount=" + dataCount +
+ ", dataLength=" + dataLength +
+ ", bitmap=" + bitmap +
+ '}';
+ }
+ }
+
+ public static class Data {
+ // If unknown data is encountered, the whole tlv will be in here so it can be parsed again later
+ public byte[] unknownData = null;
+
+ public byte heartRate = -1;
+ public short speed = -1;
+ public byte stepRate = -1;
+
+ public short cadence = -1;
+ public short stepLength = -1;
+ public short groundContactTime = -1;
+ public byte impact = -1;
+ public short swingAngle = -1;
+ public byte foreFootLanding = -1;
+ public byte midFootLanding = -1;
+ public byte backFootLanding = -1;
+ public byte eversionAngle = -1;
+
+ public short swolf = -1;
+ public short strokeRate = -1;
+
+ public short calories = -1;
+ public short cyclingPower = -1;
+ public short frequency = -1;
+ public Integer altitude = null;
+
+ public int timestamp = -1; // Calculated timestamp for this data point
+
+ @Override
+ public String toString() {
+ return "Data{" +
+ "unknownData=" + Arrays.toString(unknownData) +
+ ", heartRate=" + heartRate +
+ ", speed=" + speed +
+ ", stepRate=" + stepRate +
+ ", cadence=" + cadence +
+ ", stepLength=" + stepLength +
+ ", groundContactTime=" + groundContactTime +
+ ", impact=" + impact +
+ ", swingAngle=" + swingAngle +
+ ", foreFootLanding=" + foreFootLanding +
+ ", midFootLanding=" + midFootLanding +
+ ", backFootLanding=" + backFootLanding +
+ ", eversionAngle=" + eversionAngle +
+ ", swolf=" + swolf +
+ ", strokeRate=" + strokeRate +
+ ", calories=" + calories +
+ ", cyclingPower=" + cyclingPower +
+ ", frequency=" + frequency +
+ ", altitude=" + altitude +
+ ", timestamp=" + timestamp +
+ '}';
+ }
+ }
+
+ private final byte[] bitmapLengths = {1, 2, 1, 2, 2, 4, -1, 2, 2, 2};
+ private final byte[] innerBitmapLengths = {2, 2, 2, 1, 2, 1, 1, 1, 1, 2, 2, 1, 2, 2, 2, 2, 1, 1, 1, 2};
+
+ public short workoutNumber;
+ public short dataNumber;
+ public byte[] rawHeader;
+ public byte[] rawData;
+ public short innerBitmap;
+
+ public Header header;
+ public List<Data> dataList;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ /**
+ * This is to be able to easily reparse the error data, only accepts tlv bytes
+ *
+ * @param rawData The TLV bytes
+ */
+ public Response(byte[] rawData) throws ParseException {
+ super(null);
+ this.tlv = new HuaweiTLV().parse(rawData);
+ this.parseTlv();
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ HuaweiTLV container = this.tlv.getObject(0x81);
+
+ this.workoutNumber = container.getShort(0x02);
+ this.dataNumber = container.getShort(0x03);
+ this.rawHeader = container.getBytes(0x04);
+ this.rawData = container.getBytes(0x05); // TODO: not sure if 5 can also be omitted
+
+ if (container.contains(0x09))
+ innerBitmap = container.getShort(0x09);
+ else
+ innerBitmap = 0x01FF; // This seems to be the default
+
+ int innerDataLength = 0;
+ for (byte i = 0; i < innerBitmapLengths.length; i++) {
+ if ((innerBitmap & (1 << i)) != 0) {
+ innerDataLength += innerBitmapLengths[i];
+ }
+ }
+
+ if (this.rawHeader.length != 14)
+ throw new LengthMismatchException("Workout data header length mismatch.");
+
+ this.header = new Header();
+ ByteBuffer buf = ByteBuffer.wrap(this.rawHeader);
+ header.workoutNumber = buf.getShort();
+ header.dataNumber = buf.getShort();
+ header.timestamp = buf.getInt();
+ header.interval = buf.get();
+ header.dataCount = buf.getShort();
+ header.dataLength = buf.get();
+ header.bitmap = buf.getShort();
+
+ // Check data lengths from header
+ if (this.header.dataCount * this.header.dataLength != this.rawData.length)
+ throw new LengthMismatchException("Workout data length mismatch with header.");
+
+ // Check data lengths from bitmap
+ int dataLength = 0;
+ for (byte i = 0; i < bitmapLengths.length; i++) {
+ if ((header.bitmap & (1 << i)) != 0) {
+ if (i == 6) {
+ dataLength += innerDataLength;
+ } else {
+ dataLength += bitmapLengths[i];
+ }
+ }
+ }
+ dataLength = dataLength * header.dataCount;
+ if (dataLength != this.rawData.length)
+ throw new LengthMismatchException("Workout data length mismatch with bitmap.");
+
+ this.dataList = new ArrayList<>();
+ buf = ByteBuffer.wrap(this.rawData);
+ for (short i = 0; i < header.dataCount; i++) {
+ Data data = new Data();
+ data.timestamp = header.timestamp + header.interval * i;
+ for (byte j = 0; j < bitmapLengths.length; j++) {
+ if ((header.bitmap & (1 << j)) != 0) {
+ switch (j) {
+ case 0:
+ data.heartRate = buf.get();
+ break;
+ case 1:
+ data.speed = buf.getShort();
+ break;
+ case 2:
+ data.stepRate = buf.get();
+ break;
+ case 3:
+ data.swolf = buf.getShort();
+ break;
+ case 4:
+ data.strokeRate = buf.getShort();
+ break;
+ case 5:
+ data.altitude = buf.getInt();
+ break;
+ case 6:
+ // Inner data, parsing into data
+ // TODO: function for readability?
+ for (byte k = 0; k < innerBitmapLengths.length; k++) {
+ if ((innerBitmap & (1 << k)) != 0) {
+ switch (k) {
+ case 0:
+ data.cadence = buf.getShort();
+ break;
+ case 1:
+ data.stepLength = buf.getShort();
+ break;
+ case 2:
+ data.groundContactTime = buf.getShort();
+ break;
+ case 3:
+ data.impact = buf.get();
+ break;
+ case 4:
+ data.swingAngle = buf.getShort();
+ break;
+ case 5:
+ data.foreFootLanding = buf.get();
+ break;
+ case 6:
+ data.midFootLanding = buf.get();
+ break;
+ case 7:
+ data.backFootLanding = buf.get();
+ break;
+ case 8:
+ data.eversionAngle = buf.get();
+ break;
+ default:
+ data.unknownData = this.tlv.serialize();
+ // Fix alignment
+ for (int l = 0; l < innerBitmapLengths[k]; l++)
+ buf.get();
+ break;
+ }
+ }
+ }
+ break;
+ case 7:
+ data.calories = buf.getShort();
+ break;
+ case 8:
+ data.frequency = buf.getShort();
+ break;
+ case 9:
+ data.cyclingPower = buf.getShort();
+ break;
+ default:
+ data.unknownData = this.tlv.serialize();
+ // Fix alignment
+ for (int k = 0; k < bitmapLengths[j]; k++)
+ buf.get();
+ break;
+ }
+ }
+ }
+ this.dataList.add(data);
+ }
+ }
+ }
+ }
+
+ public static class WorkoutPace {
+ public static final int id = 0x0c;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request(
+ ParamsProvider paramsProvider,
+ short workoutNumber,
+ short paceNumber
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = Workout.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x81, new HuaweiTLV()
+ .put(0x02, workoutNumber)
+ .put(0x08, paceNumber)
+ );
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public static class Block {
+ public short distance = -1;
+ public byte type = -1;
+ public int pace = -1;
+ public short pointIndex = 0;
+ public short correction = 0;
+ public boolean hasCorrection = false;
+
+ @Override
+ public String toString() {
+ return "Block{" +
+ "distance=" + distance +
+ ", type=" + type +
+ ", pace=" + pace +
+ ", pointIndex=" + pointIndex +
+ ", correction=" + correction +
+ ", hasCorrection=" + hasCorrection +
+ '}';
+ }
+ }
+
+ public short workoutNumber;
+ public short paceNumber;
+ public List<Block> blocks;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ HuaweiTLV container = this.tlv.getObject(0x81);
+
+ this.workoutNumber = container.getShort(0x02);
+ this.paceNumber = container.getShort(0x08);
+
+ this.blocks = new ArrayList<>();
+ for (HuaweiTLV blockTlv : container.getObjects(0x83)) {
+ Block block = new Block();
+ block.distance = blockTlv.getShort(0x04);
+ block.type = blockTlv.getByte(0x05);
+ block.pace = blockTlv.getInteger(0x06);
+ if (blockTlv.contains(0x07))
+ block.pointIndex = blockTlv.getShort(0x07);
+ if (blockTlv.contains(0x09)) {
+ block.hasCorrection = true;
+ block.correction = blockTlv.getShort(0x09);
+ }
+ blocks.add(block);
+ }
+ }
+ }
+ }
+
+ public static class WorkoutSwimSegments {
+ public static final int id = 0x0e;
+
+ public static class Request extends HuaweiPacket {
+
+ public Request(
+ ParamsProvider paramsProvider,
+ short workoutNumber,
+ short segmentNumber
+ ) {
+ super(paramsProvider);
+
+ this.serviceId = Workout.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x81, new HuaweiTLV()
+ .put(0x02, workoutNumber)
+ .put(0x08, segmentNumber)
+ );
+
+ this.complete = true;
+ }
+ }
+
+ public static class Response extends HuaweiPacket {
+ public static class Block {
+ public short distance = -1;
+ public byte type = -1;
+ public int pace = -1;
+ public short pointIndex = 0;
+ public short segment = -1;
+ public byte swimType= -1;
+ public short strokes = -1;
+ public short avgSwolf = -1;
+ public int time= -1;
+
+ @Override
+ public String toString() {
+ final StringBuffer sb = new StringBuffer("Block{");
+ sb.append("distance=").append(distance);
+ sb.append(", type=").append(type);
+ sb.append(", pace=").append(pace);
+ sb.append(", pointIndex=").append(pointIndex);
+ sb.append(", segment=").append(segment);
+ sb.append(", swimType=").append(swimType);
+ sb.append(", strokes=").append(strokes);
+ sb.append(", awgSwolf=").append(avgSwolf);
+ sb.append(", time=").append(time);
+ sb.append('}');
+ return sb.toString();
+ }
+ }
+
+ public short workoutNumber;
+ public short segmentNumber;
+ public List<Block> blocks;
+
+ public Response(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+ }
+
+ @Override
+ public void parseTlv() throws ParseException {
+ HuaweiTLV container = this.tlv.getObject(0x81);
+
+ this.workoutNumber = container.getShort(0x02);
+ this.segmentNumber = container.getShort(0x08);
+
+ this.blocks = new ArrayList<>();
+ for (HuaweiTLV blockTlv : container.getObjects(0x83)) {
+ Block block = new Block();
+
+ block.distance = blockTlv.getShort(0x04);
+ block.type = blockTlv.getByte(0x05);
+ block.pace = blockTlv.getInteger(0x06);
+ if (blockTlv.contains(0x07))
+ block.pointIndex = blockTlv.getShort(0x07);
+ if (blockTlv.contains(0x09))
+ block.segment = blockTlv.getShort(0x09);
+ if (blockTlv.contains(0x0a))
+ block.swimType= blockTlv.getByte(0x0a);
+ if (blockTlv.contains(0x0b))
+ block.strokes = blockTlv.getShort(0x0b);
+ if (blockTlv.contains(0x0c))
+ block.avgSwolf = blockTlv.getShort(0x0c);
+ if (blockTlv.contains(0x0d))
+ block.time= blockTlv.getInteger(0x0d);
+
+ blocks.add(block);
+ }
+ }
+ }
+ }
+
+ public static class NotifyHeartRate {
+ public static final int id = 0x17;
+
+ public static class Request extends HuaweiPacket {
+ public Request(ParamsProvider paramsProvider) {
+ super(paramsProvider);
+
+ this.serviceId = Workout.id;
+ this.commandId = id;
+
+ this.tlv = new HuaweiTLV().put(0x01, 0x03);
+
+ this.complete = true;
+ }
+ }
+
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115Constants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115Constants.java
new file mode 100644
index 0000000..ed546f6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115Constants.java
@@ -0,0 +1,107 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Vadim Kaushan
+
+ 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.devices.id115;
+
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport.BASE_UUID;
+
+public class ID115Constants {
+ public static final UUID UUID_SERVICE_ID115 = UUID.fromString(String.format(BASE_UUID, "0AF0"));
+ public static final UUID UUID_CHARACTERISTIC_WRITE_NORMAL = UUID.fromString(String.format(BASE_UUID, "0AF6"));
+ public static final UUID UUID_CHARACTERISTIC_NOTIFY_NORMAL = UUID.fromString(String.format(BASE_UUID, "0AF7"));
+ public static final UUID UUID_CHARACTERISTIC_WRITE_HEALTH = UUID.fromString(String.format(BASE_UUID, "0AF1"));
+ public static final UUID UUID_CHARACTERISTIC_NOTIFY_HEALTH = UUID.fromString(String.format(BASE_UUID, "0AF2"));
+
+ public static final byte CMD_ID_WARE_UPDATE = 0x01;
+ public static final byte CMD_ID_GET_INFO = 0x02;
+ public static final byte CMD_ID_SETTINGS = 0x03;
+ public static final byte CMD_ID_BIND_UNBIND = 0x04;
+ public static final byte CMD_ID_NOTIFY = 0x05;
+ public static final byte CMD_ID_APP_CONTROL = 0x06;
+ public static final byte CMD_ID_BLE_CONTROL = 0x07;
+ public static final byte CMD_ID_HEALTH_DATA = 0x08;
+ public static final byte CMD_ID_DUMP_STACK = 0x20;
+ public static final byte CMD_ID_LOG = 0x21;
+ public static final byte CMD_ID_FACTORY = (byte)0xaa;
+ public static final byte CMD_ID_DEVICE_RESTART = (byte)0xf0;
+
+ // CMD_ID_SETTINGS
+ public static final byte CMD_KEY_SET_TIME = 0x01;
+ public static final byte CMD_KEY_SET_GOAL = 0x03;
+ public static final byte CMD_KEY_SET_HAND = 0x22;
+ public static final byte CMD_ARG_LEFT = 0x00;
+ public static final byte CMD_ARG_RIGHT = 0x01;
+ public static final byte CMD_KEY_SET_DISPLAY_MODE = 0x2B;
+ public static final byte CMD_ARG_HORIZONTAL = 0x00;
+ public static final byte CMD_ARG_VERTICAL = 0x02;
+
+ // CMD_ID_NOTIFY
+ public static final byte CMD_KEY_NOTIFY_CALL = 0x01;
+ public static final byte CMD_KEY_NOTIFY_STOP = 0x02;
+ public static final byte CMD_KEY_NOTIFY_MSG = 0x03;
+
+ // CMD_ID_HEALTH_DATA
+ public static final byte CMD_KEY_FETCH_ACTIVITY_TODAY = 0x03;
+
+ // CMD_ID_DEVICE_RESTART
+ public static final byte CMD_KEY_REBOOT = 0x01;
+
+ public static byte getNotificationType(NotificationType type) {
+ switch (type) {
+// case GENERIC_EMAIL:
+// return 2; // Icon is not supported
+ case WECHAT:
+ return 3;
+// case QQ:
+// return 4;
+ case FACEBOOK:
+ return 6;
+ case TWITTER:
+ return 7;
+ case WHATSAPP:
+ return 8;
+ case FACEBOOK_MESSENGER:
+ return 9;
+ case INSTAGRAM:
+ return 10;
+ case LINKEDIN:
+ return 11;
+// case GENERIC_CALENDAR:
+// return 12; // Icon is not supported
+// case SKYPE:
+// return 13; // Icon is not supported
+// case LINE:
+// return 17; // Icon is not supported
+// case VIBER:
+// return 18; // Icon is not supported
+// case KAKAO_TALK:
+// return 19; // Icon is not supported
+// case VK:
+// return 16; // Icon is not supported
+// case GMAIL:
+// return 20; // Icon is not supported
+// case OUTLOOK:
+// return 21; // Icon is not supported
+// case SNAPCHAT:
+// return 22; // Icon is not supported
+ }
+ return 1;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115Coordinator.java
new file mode 100644
index 0000000..ece3c9f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115Coordinator.java
@@ -0,0 +1,179 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k, Vadim Kaushan
+
+ 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.devices.id115;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.id115.ID115Support;
+
+public class ID115Coordinator extends AbstractBLEDeviceCoordinator {
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid service = new ParcelUuid(ID115Constants.UUID_SERVICE_ID115);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(service).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ }
+
+ @NonNull
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ if (candidate.supportsService(ID115Constants.UUID_SERVICE_ID115)) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_NONE;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new ID115SampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "VeryFit";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_wearlocation,
+ R.xml.devicesettings_screenorientation,
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return ID115Support.class;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_id115;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_h30_h10;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_h30_h10_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115SampleProvider.java
new file mode 100644
index 0000000..4882226
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/id115/ID115SampleProvider.java
@@ -0,0 +1,77 @@
+/* Copyright (C) 2018-2024 Daniele Gobbetti, Vadim Kaushan
+
+ 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.devices.id115;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.ID115ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ID115ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class ID115SampleProvider extends AbstractSampleProvider<ID115ActivitySample> {
+ public ID115SampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<ID115ActivitySample, ?> getSampleDao() {
+ return getSession().getID115ActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return ID115ActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ID115ActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ID115ActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity;
+ }
+
+ @Override
+ public ID115ActivitySample createActivitySample() {
+ return new ID115ActivitySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenConstants.java
new file mode 100644
index 0000000..08402ac
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenConstants.java
@@ -0,0 +1,30 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.idasen;
+
+import java.util.UUID;
+
+public class IdasenConstants {
+ public static final UUID CHARACTERISTIC_SVC_HEIGHT = UUID.fromString("99fa0020-338a-1024-8a49-009c0215f78a");
+ public static final UUID CHARACTERISTIC_SVC_COMMAND = UUID.fromString("99fa0001-338a-1024-8a49-009c0215f78a");
+ public static final UUID CHARACTERISTIC_COMMAND = UUID.fromString("99fa0002-338a-1024-8a49-009c0215f78a");
+ public static final UUID CHARACTERISTIC_SVC_REF_HEIGHT = UUID.fromString("99fa0030-338a-1024-8a49-009c0215f78a");
+ public static final UUID CHARACTERISTIC_SVC_DPG = UUID.fromString("99fa0010-338a-1024-8a49-009c0215f78a");
+ public static final UUID CHARACTERISTIC_REF_HEIGHT = UUID.fromString("99fa0031-338a-1024-8a49-009c0215f78a");
+ public static final UUID CHARACTERISTIC_HEIGHT = UUID.fromString("99fa0021-338a-1024-8a49-009c0215f78a");
+ public static final UUID CHARACTERISTIC_DPG = UUID.fromString("99fa0011-338a-1024-8a49-009c0215f78a");
+ public static final String ACTION_REALTIME_DESK_VALUES = ".action.realtime_desk_values";
+ public static final String EXTRA_DESK_HEIGHT = "EXTRA_DESK_HEIGHT";
+ public static final String EXTRA_DESK_SPEED = "EXTRA_DESK_SPEED";
+
+ public static final double MIN_HEIGHT = 0.62;
+ public static final double MAX_HEIGHT = 1.27;
+
+ public static final byte[] CMD_UP = new byte[]{0x47, 0x00};
+ public static final byte[] CMD_DOWN = new byte[]{0x46, 0x00};
+ public static final byte[] CMD_STOP = new byte[]{(byte)0xFF, 0x00};
+ public static final byte[] CMD_WAKEUP = new byte[]{(byte)0xFE, 0x00};
+ public static final byte[] CMD_REF_INPUT_STOP = new byte[]{0x01, (byte)0x80};
+ public static final byte[] CMD_DPG_WAKEUP_PREP = new byte[]{0x7F, (byte)0x86, 0x00};
+ public static final byte[] CMD_DPG_WAKEUP = new byte[]{0x7F, (byte)0x86, (byte)0x80, 0x01,
+ 0x02, 0x03, 0x04, 0x05, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
+ 0x0E, 0x0F, 0x10, 0x11};
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenControlActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenControlActivity.java
new file mode 100644
index 0000000..cb08e64
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenControlActivity.java
@@ -0,0 +1,105 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.idasen;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.TextView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.DecimalFormat;
+import java.util.Objects;
+
+
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.idasen.IdasenDeviceSupport;
+
+public class IdasenControlActivity extends AbstractGBActivity {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+ public TextView mDeskHeight, mDeskSpeed;
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (Objects.requireNonNull(action).equals(IdasenConstants.ACTION_REALTIME_DESK_VALUES)){
+ final WorkoutValueFormatter formatter = new WorkoutValueFormatter();
+ float height = (float) intent.getSerializableExtra(IdasenConstants.EXTRA_DESK_HEIGHT);
+ float speed = (float) intent.getSerializableExtra(IdasenConstants.EXTRA_DESK_SPEED);
+ logger.debug("Received desk values: {} {}", speed, height);
+ mDeskHeight.setText(formatter.formatValue(height * 100F, "cm"));
+ mDeskSpeed.setText(formatter.formatValue(speed * 1000F, "mm/s"));
+ }
+ }
+ };
+
+ LocalBroadcastManager localBroadcastManager;
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_idasen_control);
+
+ localBroadcastManager = LocalBroadcastManager.getInstance(this);
+
+ mDeskHeight = findViewById(R.id.idasen_desk_height);
+ mDeskSpeed = findViewById(R.id.idasen_desk_speed);
+
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(IdasenConstants.ACTION_REALTIME_DESK_VALUES);
+ LocalBroadcastManager.getInstance(getApplicationContext()).registerReceiver(mReceiver, filterLocal);
+ Intent intent = new Intent(IdasenDeviceSupport.COMMAND_GET_DESK_VALUES);
+ sendLocalBroadcast(intent);
+
+ View.OnTouchListener controlTouchListener = new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ Intent intent = null;
+
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ if (v.getId() == R.id.idasen_control_button_up) {
+ intent = new Intent(IdasenDeviceSupport.COMMAND_UP);
+ } else if (v.getId() == R.id.idasen_control_button_down) {
+ intent = new Intent(IdasenDeviceSupport.COMMAND_DOWN);
+ } else if (v.getId() == R.id.idasen_control_button_sit) {
+ intent = new Intent(IdasenDeviceSupport.COMMAND_SET_HEIGHT);
+ intent.putExtra(IdasenDeviceSupport.TARGET_HEIGHT, IdasenDeviceSupport.TARGET_POS_SIT);
+ } else if (v.getId() == R.id.idasen_control_button_stand) {
+ intent = new Intent(IdasenDeviceSupport.COMMAND_SET_HEIGHT);
+ intent.putExtra(IdasenDeviceSupport.TARGET_HEIGHT, IdasenDeviceSupport.TARGET_POS_STAND);
+ } else if (v.getId() == R.id.idasen_control_button_mid) {
+ intent = new Intent(IdasenDeviceSupport.COMMAND_SET_HEIGHT);
+ intent.putExtra(IdasenDeviceSupport.TARGET_HEIGHT, IdasenDeviceSupport.TARGET_POS_MID);
+ }
+ sendLocalBroadcast(intent);
+ } else {
+ return false;
+ }
+ return true;
+ }
+ };
+ findViewById(R.id.idasen_control_button_up).setOnTouchListener(controlTouchListener);
+ findViewById(R.id.idasen_control_button_down).setOnTouchListener(controlTouchListener);
+ findViewById(R.id.idasen_control_button_sit).setOnTouchListener(controlTouchListener);
+ findViewById(R.id.idasen_control_button_mid).setOnTouchListener(controlTouchListener);
+ findViewById(R.id.idasen_control_button_stand).setOnTouchListener(controlTouchListener);
+ }
+
+ private void sendLocalBroadcast(Intent intent) {
+ localBroadcastManager.sendBroadcast(intent);
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ localBroadcastManager.unregisterReceiver(mReceiver);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenCoordinator.java
new file mode 100644
index 0000000..7523dc0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenCoordinator.java
@@ -0,0 +1,70 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.idasen;
+
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+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.idasen.IdasenDeviceSupport;
+
+public class IdasenCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+ @Override
+ public String getManufacturer() {
+ return "IKEA";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Desk 1000", Pattern.CASE_INSENSITIVE);
+ }
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_idasen;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return IdasenDeviceSupport.class;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new IdasenSettingsCustomizer();
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_BOND;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return IdasenControlActivity.class;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_idasen
+ };
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenSettingsCustomizer.java
new file mode 100644
index 0000000..c36faa0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/idasen/IdasenSettingsCustomizer.java
@@ -0,0 +1,83 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.idasen;
+
+import android.os.Parcel;
+import android.text.InputType;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.preference.EditTextPreference;
+import androidx.preference.Preference;
+
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.GB.toast;
+
+public class IdasenSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ public static final Creator<IdasenSettingsCustomizer> CREATOR = new Creator<IdasenSettingsCustomizer>() {
+ @Override
+ public IdasenSettingsCustomizer createFromParcel(final Parcel in) {
+ return new IdasenSettingsCustomizer();
+ }
+
+ @Override
+ public IdasenSettingsCustomizer[] newArray(final int size) {
+ return new IdasenSettingsCustomizer[size];
+ }
+ };
+ private static final Logger LOG = LoggerFactory.getLogger(IdasenSettingsCustomizer.class);
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ final EditTextPreference prefMidHeight = handler.findPreference(DeviceSettingsPreferenceConst.PREF_IDASEN_MID_HEIGHT);
+ final EditTextPreference prefSitHeight = handler.findPreference(DeviceSettingsPreferenceConst.PREF_IDASEN_SIT_HEIGHT);
+ final EditTextPreference prefStandHeight = handler.findPreference(DeviceSettingsPreferenceConst.PREF_IDASEN_STAND_HEIGHT);
+ if (prefSitHeight == null || prefMidHeight == null || prefStandHeight == null) {
+ return;
+ }
+ Preference.OnPreferenceChangeListener prefListener = (preference, newValue) -> {
+ final double val = Double.parseDouble(newValue.toString());
+ if (val > IdasenConstants.MAX_HEIGHT * 100F || val < IdasenConstants.MIN_HEIGHT * 100F) {
+ toast(handler.getContext(), R.string.idasen_pref_value_warning, Toast.LENGTH_SHORT, 0);
+ return false;
+ }
+ return true;
+ };
+ for (EditTextPreference pref: List.of(prefMidHeight, prefSitHeight, prefStandHeight)){
+ pref.setOnBindEditTextListener(p -> {
+ p.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL);
+ p.setSelection(p.getText().length());
+ });
+ pref.setOnPreferenceChangeListener(prefListener);
+ }
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/itag/ITagConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/itag/ITagConstants.java
new file mode 100644
index 0000000..a63749e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/itag/ITagConstants.java
@@ -0,0 +1,26 @@
+/* Copyright (C) 2020-2024 Taavi Eom盲e
+
+ 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.devices.itag;
+
+import java.util.UUID;
+
+public final class ITagConstants {
+ /** Contains information about the button state */
+ public static final UUID UUID_SERVICE_BUTTON = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb");
+ /** Contains information about the button state */
+ public static final UUID UUID_LINK_LOSS_ALERT_LEVEL = UUID.fromString("00002a06-0000-1000-8000-00805f9b34fb");
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/itag/ITagCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/itag/ITagCoordinator.java
new file mode 100644
index 0000000..960cbf2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/itag/ITagCoordinator.java
@@ -0,0 +1,172 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Jos茅 Rebelo, Petr Van臎k, Taavi Eom盲e
+
+ 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.devices.itag;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.itag.ITagSupport;
+
+public class ITagCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("itag.*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getBondingStyle() {
+ // Some iTag devices do not support bonding but some do
+ return BONDING_STYLE_ASK;
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ScanFilter filter = new ScanFilter.Builder()
+ .setDeviceName("iTag")
+ .setDeviceName("iTAG")
+ .setDeviceName("ITAG")
+ .setDeviceName("ITag")
+ .setDeviceName("Itag")
+ .setDeviceName("itag")
+ .build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Unspecified"; //TODO: Show chip manufacturer?
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false; //TODO: RRSI
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return ITagSupport.class;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ // nothing to delete, yet
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_itag;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_itag;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_itag_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/BFH16Constants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/BFH16Constants.java
new file mode 100644
index 0000000..a86fd10
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/BFH16Constants.java
@@ -0,0 +1,125 @@
+/* Copyright (C) 2019-2024 Sophanimus
+
+ 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.devices.jyou;
+
+import java.util.UUID;
+
+public final class BFH16Constants {
+
+ //Known Services
+ public static final UUID BFH16_GENERIC_ACCESS_SERVICE = UUID.fromString("00001800-0000-1000-8000-00805f9b34fb");
+ public static final UUID BFH16_GENERIC_ATTRIBUTE_SERVICE = UUID.fromString("00001801-0000-1000-8000-00805f9b34fb");
+
+ public static final UUID BFH16_IDENTIFICATION_SERVICE1 = UUID.fromString("0000fef5-0000-1000-8000-00805f9b34fb");
+ public static final UUID BFH16_IDENTIFICATION_SERVICE2 = UUID.fromString("0000fee7-0000-1000-8000-00805f9b34fb");
+
+
+ public static final UUID BFH16_SERVICE1 = UUID.fromString("000056ff-0000-1000-8000-00805f9b34fb");
+ public static final UUID BFH16_SERVICE1_WRITE = UUID.fromString("000033f3-0000-1000-8000-00805f9b34fb");
+ public static final UUID BFH16_SERVICE1_NOTIFY = UUID.fromString("000033f4-0000-1000-8000-00805f9b34fb");
+
+ public static final UUID BFH16_SERVICE2 = UUID.fromString("0000fee7-0000-1000-8000-00805f9b34fb");
+ public static final UUID BFH16_SERVICE2_WRITE = UUID.fromString("0000fec7-0000-1000-8000-00805f9b34fb");
+ public static final UUID BFH16_SERVICE2_INDICATE= UUID.fromString("0000fec8-0000-1000-8000-00805f9b34fb");
+ public static final UUID BFH16_SERVICE2_READ = UUID.fromString("0000fec9-0000-1000-8000-00805f9b34fb");
+
+
+ //Verified command bytes
+ public static final byte CMD_SET_ALARM_1 = (byte)0x09;
+ public static final byte CMD_SET_ALARM_2 = (byte)0x22;
+ public static final byte CMD_SET_ALARM_3 = (byte)0x23;
+
+ public static final byte CMD_SET_DATE_AND_TIME = 0x08;
+
+
+ public static final byte CMD_MEASURE_HEART = (byte)0x0D; //param1: 0, param2: 0 -> STOP | 1 -> START
+ public static final byte CMD_VIBRATE = (byte)0x07; //param1: 0, param2: 1
+
+
+ public static final byte CMD_SWITCH_PHOTO_MODE = (byte)0x25; //param1: 0, param2: 0 -> OFF | 1 -> ON
+ public static final byte CMD_SWITCH_12HOUR_MODE = (byte)0x3E; //byte1: 1 -> 12HourMode | 0 -> 24HourMode
+ public static final byte CMD_SWITCH_METRIC_IMPERIAL = (byte)0x3A; //param1: 0, param2: 0 -> METRIC | 1 -> IMPERIAL //Also requests walked steps
+
+
+ //Verified receive bytes
+ public static final byte RECEIVE_DEVICE_INFO = (byte)0xF6;
+ public static final byte RECEIVE_BATTERY_LEVEL = (byte)0xF7;
+ public static final byte RECEIVE_STEPS_DATA = (byte)0xF9;
+ public static final byte RECEIVE_HEART_DATA = (byte)0xE8;
+ public static final byte RECEIVE_PHOTO_TRIGGER = (byte)0xF3;
+
+ //Verified icon bytes
+ public static final byte ICON_CALL = (byte)0x00;
+ public static final byte ICON_SMS = (byte)0x01;
+ public static final byte ICON_WECHAT = (byte)0x02;
+ public static final byte ICON_QQ = (byte)0x03;
+ public static final byte ICON_FACEBOOK = (byte)0x04;
+ public static final byte ICON_SKYPE = (byte)0x05;
+ public static final byte ICON_TWITTER = (byte)0x06;
+ public static final byte ICON_WHATSAPP = (byte)0x07;
+ public static final byte ICON_LINE = (byte)0x08;
+ public static final byte ICON_TALK = (byte)0x09;
+ public static final byte ICON_RUNNER = (byte)0x0A;
+
+
+
+ //Most probably correct command bytes
+ public static final byte CMD_SET_STEPLENGTH = (byte)0x3F; //param1: 0, param2: STEPLENGTH
+
+
+ //Probably correct command bytes
+ public static final byte CMD_SET_INACTIVITY_WARNING_TIME = (byte)0x24; //param1: 0, param2: time
+
+ public static final byte CMD_SET_HEART_TARGET = (byte)0x01; //param1: 0, param2: HEART TARGET
+ public static final byte CMD_SET_STEP_TARGET = (byte)0x03; //param1: 0, param2: STEP TARGET
+
+ public static final byte CMD_FIND_DEVICE = (byte)0x36; //param1: 0, param2: 1
+ public static final byte CMD_SET_DISCONNECT_REMIND = (byte)0x37; //param1: 0, param2: 0 -> ??? | 1 -> ???
+ public static final byte CMD_SET_AUTODETECT_HEART = (byte)0x38; //param1: 0, param2: 0 -> ??? | 1 -> ???
+
+ public static final byte CMD_READ_HISTORY_SLEEP_COUNT = (byte)0x32; //param1: 0, param2: 0
+
+ public static final byte CMD_SET_NOON_TIME = (byte)0x26; //param1: start time, param2: end time
+ public static final byte CMD_SET_SLEEP_TIME = (byte)0x27; //param1: start time, param2: end time
+
+
+ //Could be correct command bytes
+ //Send PhoneName 0x17 and 0x18
+ //Send PhoneNumber 0x19 and 0x20
+ //Weather 0x3B
+ //Power Management 0x39
+ //User Id 0x35
+ //
+
+
+
+ //______________________________________________________________________________________________
+ //It may be that BFH16 uses the same communication protocol as JYOU
+ //copied the following JYOU vars:
+
+ public static final byte CMD_SET_HEARTRATE_AUTO = 0x38;
+ public static final byte CMD_SET_HEARTRATE_WARNING_VALUE = 0x01;
+ public static final byte CMD_SET_TARGET_STEPS = 0x03;
+ //public static final byte CMD_GET_STEP_COUNT = 0x1D;
+ public static final byte CMD_GET_SLEEP_TIME = 0x32;
+ public static final byte CMD_SET_DND_SETTINGS = 0x39;
+
+ public static final byte CMD_ACTION_HEARTRATE_SWITCH = 0x0D;
+ public static final byte CMD_ACTION_SHOW_NOTIFICATION = 0x2C;
+ public static final byte CMD_ACTION_REBOOT_DEVICE = 0x0E;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/BFH16DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/BFH16DeviceCoordinator.java
new file mode 100644
index 0000000..d1fc793
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/BFH16DeviceCoordinator.java
@@ -0,0 +1,202 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Jos茅 Rebelo, Petr Van臎k, Sophanimus
+
+ 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.devices.jyou;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.regex.Pattern;
+
+import androidx.annotation.NonNull;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.jyou.BFH16DeviceSupport;
+
+public class BFH16DeviceCoordinator extends AbstractBLEDeviceCoordinator
+{
+ protected static final Logger LOG = LoggerFactory.getLogger(BFH16DeviceCoordinator.class);
+
+ @Override
+ public String getManufacturer() {
+ return "Denver";
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+
+ ParcelUuid bfhService1 = new ParcelUuid(BFH16Constants.BFH16_IDENTIFICATION_SERVICE1);
+ ParcelUuid bfhService2 = new ParcelUuid(BFH16Constants.BFH16_IDENTIFICATION_SERVICE2);
+
+ ScanFilter filter = new ScanFilter.Builder()
+ .setServiceUuid(bfhService1)
+ .setServiceUuid(bfhService2)
+ .build();
+
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("BFH-16.*");
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity(){
+ return null;
+ }
+
+ //Additional required functions ______________________________________
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity()
+ {
+ return null;
+ }
+
+
+ //Supported ________________________________________________________
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device)
+ {
+ return 3;
+ }
+
+ @Override
+ public boolean supportsFindDevice()
+ {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device)
+ {
+ return true;
+ }
+
+ @Override
+ public boolean supportsRealtimeData()
+ {
+ return true;
+ }
+
+
+ //NOT Supported ________________________________________________________
+
+ @Override
+ public boolean supportsActivityDataFetching(){
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking()
+ {
+ return false;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents()
+ {
+ return false;
+ }
+
+ @Override
+ public boolean supportsLedColor()
+ {
+ return false;
+ }
+
+ @Override
+ public boolean supportsMusicInfo()
+ {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRgbLedColor()
+ {
+ return false;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return BFH16DeviceSupport.class;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather()
+ {
+ return false;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_bfh16;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/JYouConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/JYouConstants.java
new file mode 100644
index 0000000..315e36a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/JYouConstants.java
@@ -0,0 +1,62 @@
+/* Copyright (C) 2017-2024 Da Pa, Pavel Elagin, Sami Alaoui
+
+ 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.devices.jyou;
+
+import java.util.UUID;
+
+public final class JYouConstants {
+ public static final UUID UUID_CHARACTERISTIC_CONTROL = UUID.fromString("000033f3-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_MEASURE = UUID.fromString("000033f4-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_SERVICE_JYOU = UUID.fromString("000056ff-0000-1000-8000-00805f9b34fb");
+
+ public static final byte CMD_SET_DATE_AND_TIME = 0x08;
+ public static final byte CMD_SET_HEARTRATE_AUTO = 0x38;
+ public static final byte CMD_SET_HEARTRATE_WARNING_VALUE = 0x01;
+ public static final byte CMD_SET_TARGET_STEPS = 0x03;
+ public static final byte CMD_SET_ALARM_1 = 0x09;
+ public static final byte CMD_SET_ALARM_2 = 0x22;
+ public static final byte CMD_SET_ALARM_3 = 0x23;
+ public static final byte CMD_GET_STEP_COUNT = 0x1D;
+ public static final byte CMD_GET_SLEEP_TIME = 0x32;
+ public static final byte CMD_SET_NOON_TIME = 0x26;
+ public static final byte CMD_SET_SLEEP_TIME = 0x27;
+ public static final byte CMD_SET_DND_SETTINGS = 0x39;
+ public static final byte CMD_SET_INACTIVITY_WARNING_TIME = 0x24;
+
+ public static final byte CMD_ACTION_HEARTRATE_SWITCH = 0x0D;
+ public static final byte CMD_ACTION_SHOW_NOTIFICATION = 0x2C;
+ public static final byte CMD_ACTION_REBOOT_DEVICE = 0x0E;
+
+ public static final byte RECEIVE_HISTORY_SLEEP_COUNT = 0x32;
+ public static final byte RECEIVE_BLOOD_PRESSURE = (byte) 0xE8;
+ public static final byte RECEIVE_WATCH_MAC = (byte)0xEC;
+ public static final byte RECEIVE_GET_PHOTO = (byte)0xF3;
+ public static final byte RECEIVE_DEVICE_INFO = (byte)0xF6;
+ public static final byte RECEIVE_BATTERY_LEVEL = (byte)0xF7;
+ public static final byte RECEIVE_STEPS_DATA = (byte)0xF9;
+ public static final byte RECEIVE_HEARTRATE = (byte)0xFC;
+
+ public static final byte ICON_CALL = 0;
+ public static final byte ICON_SMS = 1;
+ public static final byte ICON_WECHAT = 2;
+ public static final byte ICON_QQ = 3;
+ public static final byte ICON_FACEBOOK = 4;
+ public static final byte ICON_SKYPE = 5;
+ public static final byte ICON_TWITTER = 6;
+ public static final byte ICON_WHATSAPP = 7;
+ public static final byte ICON_LINE = 8;
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/JYouSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/JYouSampleProvider.java
new file mode 100644
index 0000000..91bdd5d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/JYouSampleProvider.java
@@ -0,0 +1,87 @@
+/* Copyright (C) 2018-2024 Da Pa, Pavel Elagin
+
+ 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.devices.jyou;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.JYouActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.JYouActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class JYouSampleProvider extends AbstractSampleProvider<JYouActivitySample> {
+
+ public static final int TYPE_ACTIVITY = -1;
+ private final float movementDivisor = 6000.0f;
+ private GBDevice mDevice;
+ private DaoSession mSession;
+
+ public JYouSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+
+ mSession = session;
+ mDevice = device;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity/movementDivisor;
+ }
+
+ @Override
+ public JYouActivitySample createActivitySample() {
+ return new JYouActivitySample();
+ }
+
+ @Override
+ public AbstractDao<JYouActivitySample, ?> getSampleDao() {
+ return getSession().getJYouActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return JYouActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return JYouActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return JYouActivitySampleDao.Properties.DeviceId;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/TeclastH30/TeclastH30Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/TeclastH30/TeclastH30Coordinator.java
new file mode 100644
index 0000000..8119ff9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/TeclastH30/TeclastH30Coordinator.java
@@ -0,0 +1,184 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Da Pa, Jos茅 Rebelo, Pavel Elagin, Petr Van臎k
+
+ 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.devices.jyou.TeclastH30;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import androidx.annotation.NonNull;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.jyou.JYouConstants;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.jyou.TeclastH30.TeclastH30Support;
+
+public class TeclastH30Coordinator extends AbstractBLEDeviceCoordinator {
+
+ protected static final Logger LOG = LoggerFactory.getLogger(TeclastH30Coordinator.class);
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid uuid = new ParcelUuid(JYouConstants.UUID_SERVICE_JYOU);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(uuid).build();
+ return Collections.singletonList(filter);
+ }
+
+ @NonNull
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ if (candidate.supportsService(JYouConstants.UUID_SERVICE_JYOU)) {
+ return true;
+ }
+
+ return super.supports(candidate);
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("TECLAST_H[13]0.*|H[13]-[ABCDEF0123456789]{4}");
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return TeclastH30Support.class;
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 3;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Teclast";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_teclast_h30;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_h30_h10;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_h30_h10_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/y5/Y5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/y5/Y5Coordinator.java
new file mode 100644
index 0000000..4cf29cc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/jyou/y5/Y5Coordinator.java
@@ -0,0 +1,161 @@
+/* Copyright (C) 2018-2024 Damien Gaignon, Daniel Dakhno, Da Pa, Jos茅
+ Rebelo, Pavel Elagin, Petr Van臎k
+
+ 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.devices.jyou.y5;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.regex.Pattern;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.jyou.JYouSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.JYouActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.jyou.y5.Y5Support;
+
+public class Y5Coordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getJYouActivitySampleDao().queryBuilder();
+ qb.where(JYouActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*Y5.*");
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new JYouSampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 3;
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(GBDevice device, int position) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Y5";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return Y5Support.class;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_y5;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_h30_h10;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_h30_h10_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/BohemicSmartBraceletDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/BohemicSmartBraceletDeviceCoordinator.java
new file mode 100644
index 0000000..fd70447
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/BohemicSmartBraceletDeviceCoordinator.java
@@ -0,0 +1,63 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.lefun;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+
+public class BohemicSmartBraceletDeviceCoordinator extends LefunDeviceCoordinator {
+ public boolean supports(GBDeviceCandidate candidate) {
+ // Since the Lefun coordinator overrides supports, we also need to
+ return ".bohemic".equals(candidate.getName());
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_liftwrist_display_noshed,
+ R.xml.devicesettings_timeformat,
+ // R.xml.devicesettings_antilost, not supported
+ R.xml.devicesettings_inactivity,
+ R.xml.devicesettings_hydration_reminder,
+ R.xml.devicesettings_lefun_interface_language,
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false; // not supported
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_bohemic_smart_bracelet;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_h30_h10;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_h30_h10_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunConstants.java
new file mode 100644
index 0000000..50c4f69
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunConstants.java
@@ -0,0 +1,86 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun;
+
+import java.util.UUID;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport.BASE_UUID;
+
+/**
+ * Constants used with Lefun device support
+ */
+public class LefunConstants {
+ // BLE UUIDs
+ public static final UUID UUID_SERVICE_LEFUN = UUID.fromString(String.format(BASE_UUID, "18D0"));
+ public static final UUID UUID_CHARACTERISTIC_LEFUN_WRITE = UUID.fromString(String.format(BASE_UUID, "2D01"));
+ public static final UUID UUID_CHARACTERISTIC_LEFUN_NOTIFY = UUID.fromString(String.format(BASE_UUID, "2D00"));
+
+ // Coordinator constants
+ public static final String ADVERTISEMENT_NAME = "Lefun";
+ public static final String MANUFACTURER_NAME = "Teng Jin Da";
+ // Commands
+ public static final byte CMD_REQUEST_ID = (byte) 0xab;
+ public static final byte CMD_RESPONSE_ID = 0x5a;
+ public static final int CMD_MAX_LENGTH = 20;
+ // 3 header bytes plus checksum
+ public static final int CMD_HEADER_LENGTH = 4;
+ public static final byte CMD_FIRMWARE_INFO = 0x00;
+ public static final byte CMD_BONDING_REQUEST = 0x01;
+ public static final byte CMD_SETTINGS = 0x02;
+ public static final byte CMD_BATTERY_LEVEL = 0x03;
+ public static final byte CMD_TIME = 0x04;
+ public static final byte CMD_ALARM = 0x05;
+ public static final byte CMD_PROFILE = 0x06;
+ public static final byte CMD_UI_PAGES = 0x07;
+ public static final byte CMD_FEATURES = 0x08;
+ public static final byte CMD_FIND_DEVICE = 0x09;
+ public static final byte CMD_FIND_PHONE = 0x0a;
+ public static final byte CMD_SEDENTARY_REMINDER_INTERVAL = 0x0b;
+ public static final byte CMD_HYDRATION_REMINDER_INTERVAL = 0x0c;
+ public static final byte CMD_REMOTE_CAMERA = 0x0d;
+ public static final byte CMD_REMOTE_CAMERA_TRIGGERED = 0x0e;
+ public static final byte CMD_PPG_START = 0x0f;
+ public static final byte CMD_PPG_RESULT = 0x10;
+ public static final byte CMD_PPG_DATA = 0x11;
+ public static final byte CMD_STEPS_DATA = 0x12;
+ public static final byte CMD_ACTIVITY_DATA = 0x13;
+ public static final byte CMD_SLEEP_TIME_DATA = 0x14;
+ public static final byte CMD_SLEEP_DATA = 0x15;
+ public static final byte CMD_NOTIFICATION = 0x17;
+ public static final byte CMD_LANGUAGE = 0x21;
+ public static final byte CMD_UNKNOWN_22 = 0x22;
+ public static final byte CMD_UNKNOWN_25 = 0x25;
+ public static final byte CMD_UNKNOWN_80 = (byte) 0x80;
+ public static final int PPG_TYPE_INVALID = -1;
+ public static final int PPG_TYPE_HEART_RATE = 0;
+ public static final int PPG_TYPE_BLOOD_PRESSURE = 1;
+ public static final int PPG_TYPE_BLOOD_OXYGEN = 2;
+ public static final int PPG_TYPE_COUNT = 3;
+ // DB activity kinds
+ public static final int DB_ACTIVITY_KIND_UNKNOWN = 0;
+ public static final int DB_ACTIVITY_KIND_ACTIVITY = 1;
+ public static final int DB_ACTIVITY_KIND_HEART_RATE = 2;
+ public static final int DB_ACTIVITY_KIND_LIGHT_SLEEP = 3;
+ public static final int DB_ACTIVITY_KIND_DEEP_SLEEP = 4;
+ // Pseudo-intensity
+ public static final int INTENSITY_MIN = 0;
+ public static final int INTENSITY_DEEP_SLEEP = 1;
+ public static final int INTENSITY_LIGHT_SLEEP = 2;
+ public static final int INTENSITY_AWAKE = 3;
+ public static final int INTENSITY_MAX = 4;
+ public static int NUM_ALARM_SLOTS = 5;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunDeviceCoordinator.java
new file mode 100644
index 0000000..c660219
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunDeviceCoordinator.java
@@ -0,0 +1,166 @@
+/* Copyright (C) 2020-2024 Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k, Yukai Li
+
+ 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.devices.lefun;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.lefun.LefunDeviceSupport;
+
+import static nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants.ADVERTISEMENT_NAME;
+import static nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants.MANUFACTURER_NAME;
+import static nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants.NUM_ALARM_SLOTS;
+
+/**
+ * Device coordinator for Lefun band
+ */
+public class LefunDeviceCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ // There's a bunch of other names other than "Lefun", but let's just focus on one for now.
+ if (ADVERTISEMENT_NAME.equals(candidate.getName())) {
+ // The device does not advertise service UUIDs, so can't check whether it supports
+ // the proper service. We can check that it doesn't advertise any services, though.
+ // We're actually supposed to check for presence of the string "TJDR" within the
+ // manufacturer specific data, which consists of the device's MAC address and said
+ // string. But we're not being given it, so *shrug*.
+ if (candidate.getServiceUuids().length == 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new LefunSampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return NUM_ALARM_SLOTS;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return MANUFACTURER_NAME;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ return new BatteryConfig[]{
+ new BatteryConfig(
+ 0,
+ GBDevice.BATTERY_ICON_DEFAULT,
+ GBDevice.BATTERY_LABEL_DEFAULT,
+ 15,
+ 100
+ )
+ };
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_liftwrist_display_noshed,
+ R.xml.devicesettings_timeformat,
+ R.xml.devicesettings_antilost,
+ R.xml.devicesettings_inactivity,
+ R.xml.devicesettings_hydration_reminder,
+ R.xml.devicesettings_lefun_interface_language,
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return LefunDeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_lefun;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_h30_h10;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_h30_h10_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunFeatureSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunFeatureSupport.java
new file mode 100644
index 0000000..06ca0e7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunFeatureSupport.java
@@ -0,0 +1,59 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun;
+
+/**
+ * Feature support utilities for Lefun devices
+ */
+public class LefunFeatureSupport {
+ public static final int SUPPORT_HEART_RATE = 1 << 2;
+ public static final int SUPPORT_BLOOD_PRESSURE = 1 << 3;
+ public static final int SUPPORT_FAKE_ECG = 1 << 10;
+ public static final int SUPPORT_ECG = 1 << 11;
+ public static final int SUPPORT_WALLPAPER_UPLOAD = 1 << 12;
+
+ public static final int RESERVE_BLOOD_OXYGEN = 1 << 0;
+ public static final int RESERVE_CLOCK_FACE_UPLOAD = 1 << 3;
+ public static final int RESERVE_CONTACTS = 1 << 5;
+ public static final int RESERVE_WALLPAPER = 1 << 6;
+ public static final int RESERVE_REMOTE_CAMERA = 1 << 7;
+
+ /**
+ * Checks whether a feature is supported
+ *
+ * @param deviceSupport the feature flags from the device
+ * @param featureSupport the feature you want to check
+ * @return whether feature is supported
+ */
+ public static boolean checkSupported(short deviceSupport, int featureSupport) {
+ return (deviceSupport & featureSupport) == featureSupport;
+ }
+
+ /**
+ * Checks whether a feature is not reserved
+ * <p>
+ * Reserve flags indicate a feature is not available if set. This function takes care of the
+ * inverting for you, so if you get true, the feature is available.
+ *
+ * @param deviceReserve the reserve flags from the device
+ * @param featureReserve the reserve flag you want to check
+ * @return whether feature is supported
+ */
+ public static boolean checkNotReserved(short deviceReserve, int featureReserve) {
+ return !((deviceReserve & featureReserve) == featureReserve);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunSampleProvider.java
new file mode 100644
index 0000000..3fa2b48
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/LefunSampleProvider.java
@@ -0,0 +1,100 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.LefunActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.LefunActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+/**
+ * Sample provider for Lefun devices
+ */
+public class LefunSampleProvider extends AbstractSampleProvider<LefunActivitySample> {
+ public LefunSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<LefunActivitySample, ?> getSampleDao() {
+ return getSession().getLefunActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return LefunActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return LefunActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return LefunActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ switch (rawType) {
+ case LefunConstants.DB_ACTIVITY_KIND_ACTIVITY:
+ case LefunConstants.DB_ACTIVITY_KIND_HEART_RATE:
+ return ActivityKind.ACTIVITY;
+ case LefunConstants.DB_ACTIVITY_KIND_LIGHT_SLEEP:
+ return ActivityKind.LIGHT_SLEEP;
+ case LefunConstants.DB_ACTIVITY_KIND_DEEP_SLEEP:
+ return ActivityKind.DEEP_SLEEP;
+ default:
+ return ActivityKind.UNKNOWN;
+ }
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ switch (activityKind) {
+ case ACTIVITY:
+ return LefunConstants.DB_ACTIVITY_KIND_ACTIVITY;
+ case LIGHT_SLEEP:
+ return LefunConstants.DB_ACTIVITY_KIND_LIGHT_SLEEP;
+ case DEEP_SLEEP:
+ return LefunConstants.DB_ACTIVITY_KIND_DEEP_SLEEP;
+ default:
+ return LefunConstants.DB_ACTIVITY_KIND_UNKNOWN;
+ }
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / (float) LefunConstants.INTENSITY_MAX;
+ }
+
+ @Override
+ public LefunActivitySample createActivitySample() {
+ return new LefunActivitySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/VivitarHrBpMonitorActivityTrackerCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/VivitarHrBpMonitorActivityTrackerCoordinator.java
new file mode 100644
index 0000000..137769b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/VivitarHrBpMonitorActivityTrackerCoordinator.java
@@ -0,0 +1,61 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.lefun;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+
+public class VivitarHrBpMonitorActivityTrackerCoordinator extends LefunDeviceCoordinator {
+ public boolean supports(GBDeviceCandidate candidate) {
+ // Since the Lefun coordinator overrides supports, we also need to
+ return "IMP-2027".equals(candidate.getName());
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_liftwrist_display_noshed,
+ R.xml.devicesettings_timeformat,
+ // R.xml.devicesettings_antilost, not supported
+ R.xml.devicesettings_inactivity,
+ R.xml.devicesettings_hydration_reminder,
+ R.xml.devicesettings_lefun_interface_language,
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false; // not supported
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_vivitar_hr_bp_monitor_activity_tracker;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_h30_h10;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_h30_h10_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/AlarmCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/AlarmCommand.java
new file mode 100644
index 0000000..e9387e7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/AlarmCommand.java
@@ -0,0 +1,173 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class AlarmCommand extends BaseCommand {
+ public static final int DOW_SUNDAY = 0;
+ public static final int DOW_MONDAY = 1;
+ public static final int DOW_TUESDAY = 2;
+ public static final int DOW_WEDNESDAY = 3;
+ public static final int DOW_THURSDAY = 4;
+ public static final int DOW_FRIDAY = 5;
+ public static final int DOW_SATURDAY = 6;
+
+ private byte op;
+ private byte index;
+ private boolean enabled;
+ // Snooze is not implemented how you think it would be
+ // Number of snoozes is decremented every time the alarm triggers, and the alarm time
+ // is moved forward by number of minutes in snooze time. It never gets reset to the
+ // original time.
+ private byte numOfSnoozes;
+ private byte snoozeTime;
+ private byte dayOfWeek;
+ private byte hour;
+ private byte minute;
+
+ private boolean setSuccess;
+
+ public byte getOp() {
+ return op;
+ }
+
+ public void setOp(byte op) {
+ if (op != OP_GET && op != OP_SET)
+ throw new IllegalArgumentException("Operation must be get or set");
+ this.op = op;
+ }
+
+ public byte getIndex() {
+ return index;
+ }
+
+ public void setIndex(byte index) {
+ if (index < 0 || index >= LefunConstants.NUM_ALARM_SLOTS)
+ throw new IllegalArgumentException("Index must be between 0 and "
+ + (LefunConstants.NUM_ALARM_SLOTS - 1) + " inclusive");
+ this.index = index;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public byte getNumOfSnoozes() {
+ return numOfSnoozes;
+ }
+
+ public void setNumOfSnoozes(byte numOfSnoozes) {
+ this.numOfSnoozes = numOfSnoozes;
+ }
+
+ public byte getSnoozeTime() {
+ return snoozeTime;
+ }
+
+ public void setSnoozeTime(byte snoozeTime) {
+ this.snoozeTime = snoozeTime;
+ }
+
+ public boolean getDayOfWeek(int day) {
+ if (day < 0 || day > 6)
+ throw new IllegalArgumentException("Invalid day of week");
+ return getBit(dayOfWeek, 1 << day);
+ }
+
+ public void setDayOfWeek(int day, boolean enabled) {
+ if (day < 0 || day > 6)
+ throw new IllegalArgumentException("Invalid day of week");
+ dayOfWeek = setBit(dayOfWeek, 1 << day, enabled);
+ }
+
+ public byte getHour() {
+ return hour;
+ }
+
+ public void setHour(byte hour) {
+ if (hour < 0 || hour > 23)
+ throw new IllegalArgumentException("Hour must be between 0 and 23 inclusive");
+ this.hour = hour;
+ }
+
+ public byte getMinute() {
+ return minute;
+ }
+
+ public void setMinute(byte minute) {
+ if (minute < 0 || minute > 59)
+ throw new IllegalArgumentException("Minute must be between 0 and 59 inclusive");
+ this.minute = minute;
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_ALARM);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 2)
+ throwUnexpectedLength();
+
+ op = params.get();
+ index = params.get();
+ if (op == OP_GET) {
+ if (paramsLength != 8)
+ throwUnexpectedLength();
+
+ enabled = params.get() == 1;
+ numOfSnoozes = params.get();
+ snoozeTime = params.get();
+ dayOfWeek = params.get();
+ hour = params.get();
+ minute = params.get();
+ } else if (op == OP_SET) {
+ if (paramsLength != 3)
+ throwUnexpectedLength();
+
+ setSuccess = params.get() == 1;
+ } else {
+ throw new IllegalArgumentException("Invalid operation type received");
+ }
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(op);
+ // No alarm ID for get; all of them are returned
+ if (op == OP_SET) {
+ params.put(index);
+ params.put((byte)(enabled ? 1: 0));
+ params.put(numOfSnoozes);
+ params.put(snoozeTime);
+ params.put(dayOfWeek);
+ params.put(hour);
+ params.put(minute);
+ }
+ return LefunConstants.CMD_ALARM;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/BaseCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/BaseCommand.java
new file mode 100644
index 0000000..3bb2fed
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/BaseCommand.java
@@ -0,0 +1,235 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+/**
+ * Base class for Lefun Bluetooth commands and responses
+ */
+public abstract class BaseCommand {
+ // Common constants
+ /**
+ * Common get operation type
+ */
+ public static final byte OP_GET = 0;
+ /**
+ * Common set operation type
+ */
+ public static final byte OP_SET = 1;
+
+ /**
+ * Calculates command checksum
+ *
+ * @param data the data to generate checksum from
+ * @param offset the offset in data to start calculating from
+ * @param length the number of bytes to include in calculation
+ * @return the computed checksum
+ */
+ public static byte calculateChecksum(byte[] data, int offset, int length) {
+ int checksum = 0;
+ for (int i = offset; i < offset + length; ++i) {
+ byte b = data[i];
+ for (int j = 0; j < 8; ++j) {
+ if (((b ^ checksum) & 1) == 0) {
+ checksum >>= 1;
+ } else {
+ checksum = (checksum ^ 0x18) >> 1 | 0x80;
+ }
+ b >>= 1;
+ }
+ }
+ return (byte) checksum;
+ }
+
+ /**
+ * When implemented in a subclass, parses the response from a device
+ *
+ * @param id the command ID
+ * @param params the params buffer
+ */
+ abstract protected void deserializeParams(byte id, ByteBuffer params);
+
+ /**
+ * When implemented in a subclass, provides the arguments to send in the command
+ *
+ * @param params the params buffer to write to
+ * @return the command ID
+ */
+ abstract protected byte serializeParams(ByteBuffer params);
+
+ /**
+ * Deserialize a response from the device
+ *
+ * @param response the response data to deserialize
+ */
+ public void deserialize(byte[] response) {
+ if (response.length < LefunConstants.CMD_HEADER_LENGTH || response.length < response[1])
+ throw new IllegalArgumentException("Response is too short");
+ if (calculateChecksum(response, 0, response[1] - 1) != response[response[1] - 1])
+ throw new IllegalArgumentException("Incorrect message checksum");
+ ByteBuffer buffer = ByteBuffer.wrap(response, LefunConstants.CMD_HEADER_LENGTH - 1,
+ response[1] - LefunConstants.CMD_HEADER_LENGTH);
+ buffer.order(ByteOrder.BIG_ENDIAN);
+ deserializeParams(response[2], buffer);
+ }
+
+ /**
+ * Serializes a command to send to the device
+ *
+ * @return the data to send to the device
+ */
+ public byte[] serialize() {
+ ByteBuffer buffer = ByteBuffer.allocate(LefunConstants.CMD_MAX_LENGTH - LefunConstants.CMD_HEADER_LENGTH);
+ buffer.order(ByteOrder.BIG_ENDIAN);
+ byte id = serializeParams(buffer);
+ return makeCommand(id, buffer);
+ }
+
+ /**
+ * Builds a command given ID and parameters buffer.
+ *
+ * @param id the command ID
+ * @param params the parameters buffer
+ * @return the assembled command buffer
+ */
+ protected byte[] makeCommand(byte id, ByteBuffer params) {
+ if (params.position() > LefunConstants.CMD_MAX_LENGTH - LefunConstants.CMD_HEADER_LENGTH)
+ throw new IllegalArgumentException("params is too long to fit");
+
+ int paramsLength = params.position();
+ byte[] request = new byte[paramsLength + LefunConstants.CMD_HEADER_LENGTH];
+ request[0] = LefunConstants.CMD_REQUEST_ID;
+ request[1] = (byte) request.length;
+ request[2] = id;
+ params.flip();
+ params.get(request, LefunConstants.CMD_HEADER_LENGTH - 1, paramsLength);
+ request[request.length - 1] = calculateChecksum(request, 0, request.length - 1);
+ return request;
+ }
+
+ /**
+ * Throws a standard parameters length exception
+ */
+ protected void throwUnexpectedLength() {
+ throw new IllegalArgumentException("Unexpected parameters length");
+ }
+
+ /**
+ * Checks for valid command ID and throws if wrong ID provided
+ *
+ * @param id command ID from device
+ * @param expectedId expected command ID
+ */
+ protected void validateId(byte id, byte expectedId) {
+ if (id != expectedId)
+ throw new IllegalArgumentException("Wrong command ID");
+ }
+
+ /**
+ * Checks for valid command ID and command length
+ *
+ * @param id command ID from device
+ * @param params params buffer from device
+ * @param expectedId expected command ID
+ * @param expectedLength expected params length
+ */
+ protected void validateIdAndLength(byte id, ByteBuffer params, byte expectedId, int expectedLength) {
+ validateId(id, expectedId);
+ if (params.limit() - params.position() != expectedLength)
+ throwUnexpectedLength();
+ }
+
+ /**
+ * Gets whether a bit is set
+ *
+ * @param value the value to check against
+ * @param mask the bitmask
+ * @return whether the bits indicated by the bitmask are set
+ */
+ protected boolean getBit(int value, int mask) {
+ return (value & mask) != 0;
+ }
+
+ /**
+ * Sets a bit in a value
+ *
+ * @param value the value to modify
+ * @param mask the bitmask
+ * @param set whether to set or clear the bits
+ * @return the modified value
+ */
+ protected int setBit(int value, int mask, boolean set) {
+ if (set) {
+ return value | mask;
+ } else {
+ return value & ~mask;
+ }
+ }
+
+ /**
+ * Sets a bit in a value
+ *
+ * @param value the value to modify
+ * @param mask the bitmask
+ * @param set whether to set or clear the bits
+ * @return the modified value
+ */
+ protected short setBit(short value, int mask, boolean set) {
+ if (set) {
+ return (short) (value | mask);
+ } else {
+ return (short) (value & ~mask);
+ }
+ }
+
+ /**
+ * Sets a bit in a value
+ *
+ * @param value the value to modify
+ * @param mask the bitmask
+ * @param set whether to set or clear the bits
+ * @return the modified value
+ */
+ protected byte setBit(byte value, int mask, boolean set) {
+ if (set) {
+ return (byte) (value | mask);
+ } else {
+ return (byte) (value & ~mask);
+ }
+ }
+
+ /**
+ * Find index of first bit that is set
+ *
+ * @param value the value to look at
+ * @return the index of the lowest set bit, starting at 0 for least significant bit; -1 if no bits set
+ */
+ protected int getLowestSetBitIndex(int value) {
+ if (value == 0) return -1;
+
+ int i = 0;
+ while ((value & 1) == 0) {
+ ++i;
+ value >>= 1;
+ }
+ return i;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/Cmd22Command.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/Cmd22Command.java
new file mode 100644
index 0000000..ff399fe
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/Cmd22Command.java
@@ -0,0 +1,83 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class Cmd22Command extends BaseCommand {
+ private byte op;
+ private short unknown;
+
+ private boolean setSuccess;
+
+ public byte getOp() {
+ return op;
+ }
+
+ public void setOp(byte op) {
+ if (op != OP_GET && op != OP_SET)
+ throw new IllegalArgumentException("Operation must be get or set");
+ this.op = op;
+ }
+
+ public short getUnknown() {
+ return unknown;
+ }
+
+ public void setUnknown(short unknown) {
+ this.unknown = unknown;
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_UNKNOWN_22);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 1)
+ throwUnexpectedLength();
+
+ op = params.get();
+ if (op == OP_GET) {
+ if (paramsLength != 3)
+ throwUnexpectedLength();
+
+ unknown = params.getShort();
+ } else if (op == OP_SET) {
+ if (paramsLength != 2)
+ throwUnexpectedLength();
+
+ setSuccess = params.get() == 1;
+ } else {
+ throw new IllegalArgumentException("Invalid operation type received");
+ }
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(op);
+ if (op == OP_SET) {
+ params.putShort(unknown);
+ }
+ return LefunConstants.CMD_UNKNOWN_22;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/Cmd25Command.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/Cmd25Command.java
new file mode 100644
index 0000000..abd2b8b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/Cmd25Command.java
@@ -0,0 +1,85 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class Cmd25Command extends BaseCommand {
+ private byte op;
+ private int unknown;
+
+ private boolean setSuccess;
+
+ public byte getOp() {
+ return op;
+ }
+
+ public void setOp(byte op) {
+ if (op != OP_GET && op != OP_SET)
+ throw new IllegalArgumentException("Operation must be get or set");
+ this.op = op;
+ }
+
+ public int getUnknown() {
+ return unknown;
+ }
+
+ public void setUnknown(int unknown) {
+ if (unknown < 0 || unknown > 65000)
+ throw new IllegalArgumentException("Value must be between 0 and 65000 inclusive");
+ this.unknown = unknown;
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_UNKNOWN_25);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 1)
+ throwUnexpectedLength();
+
+ op = params.get();
+ if (op == OP_GET) {
+ if (paramsLength != 5)
+ throwUnexpectedLength();
+
+ unknown = params.getInt();
+ } else if (op == OP_SET) {
+ if (paramsLength != 2)
+ throwUnexpectedLength();
+
+ setSuccess = params.get() == 1;
+ } else {
+ throw new IllegalArgumentException("Invalid operation type received");
+ }
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(op);
+ if (op == OP_SET) {
+ params.putInt(unknown);
+ }
+ return LefunConstants.CMD_UNKNOWN_25;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FeaturesCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FeaturesCommand.java
new file mode 100644
index 0000000..b6901b0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FeaturesCommand.java
@@ -0,0 +1,94 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class FeaturesCommand extends BaseCommand {
+ public static final int FEATURE_RAISE_TO_WAKE = 0;
+ public static final int FEATURE_SEDENTARY_REMINDER = 1;
+ public static final int FEATURE_HYDRATION_REMINDER = 2;
+ public static final int FEATURE_REMOTE_CAMERA = 3;
+ public static final int FEATURE_UNKNOWN_4 = 4;
+ public static final int FEATURE_ANTI_LOST = 5;
+
+ private byte op;
+ private short features;
+
+ private boolean setSuccess;
+
+ public byte getOp() {
+ return op;
+ }
+
+ public void setOp(byte op) {
+ if (op != OP_GET && op != OP_SET)
+ throw new IllegalArgumentException("Operation must be get or set");
+ this.op = op;
+ }
+
+ public boolean getFeature(int index) {
+ if (index < 0 || index > 5)
+ throw new IllegalArgumentException("Index must be between 0 and 5 inclusive");
+ return getBit(features, 1 << index);
+ }
+
+ public void setFeature(int index, boolean enabled) {
+ if (index < 0 || index > 5)
+ throw new IllegalArgumentException("Index must be between 0 and 5 inclusive");
+ features = setBit(features, 1 << index, enabled);
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_FEATURES);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 1)
+ throwUnexpectedLength();
+
+ op = params.get();
+ if (op == OP_GET) {
+ if (paramsLength != 3)
+ throwUnexpectedLength();
+
+ features = params.getShort();
+ } else if (op == OP_SET) {
+ if (paramsLength != 2)
+ throwUnexpectedLength();
+
+ setSuccess = params.get() == 1;
+ } else {
+ throw new IllegalArgumentException("Invalid operation type received");
+ }
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(op);
+ if (op == OP_SET) {
+ params.putShort(features);
+ }
+ return LefunConstants.CMD_FEATURES;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FindDeviceCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FindDeviceCommand.java
new file mode 100644
index 0000000..2ccec76
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FindDeviceCommand.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class FindDeviceCommand extends BaseCommand {
+ private boolean success;
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_FIND_DEVICE, 1);
+
+ success = params.get() == 1;
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ return LefunConstants.CMD_FIND_DEVICE;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FindPhoneCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FindPhoneCommand.java
new file mode 100644
index 0000000..71c6aff
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/FindPhoneCommand.java
@@ -0,0 +1,33 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class FindPhoneCommand extends BaseCommand {
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_FIND_PHONE, 0);
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ return LefunConstants.CMD_FIND_PHONE;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetActivityDataCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetActivityDataCommand.java
new file mode 100644
index 0000000..b28d78a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetActivityDataCommand.java
@@ -0,0 +1,108 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class GetActivityDataCommand extends BaseCommand {
+ private byte daysAgo;
+ private byte totalRecords;
+ private byte currentRecord;
+ private byte year;
+ private byte month;
+ private byte day;
+ private byte hour;
+ private byte minute;
+ private short steps;
+ private short distance; // m
+ private short calories; // calories
+
+ public byte getDaysAgo() {
+ return daysAgo;
+ }
+
+ public void setDaysAgo(byte daysAgo) {
+ if (daysAgo < 0 || daysAgo > 6)
+ throw new IllegalArgumentException("Days ago must be between 0 and 6 inclusive");
+ this.daysAgo = daysAgo;
+ }
+
+ public byte getTotalRecords() {
+ return totalRecords;
+ }
+
+ public byte getCurrentRecord() {
+ return currentRecord;
+ }
+
+ public byte getYear() {
+ return year;
+ }
+
+ public byte getMonth() {
+ return month;
+ }
+
+ public byte getDay() {
+ return day;
+ }
+
+ public byte getHour() {
+ return hour;
+ }
+
+ public byte getMinute() {
+ return minute;
+ }
+
+ public short getSteps() {
+ return steps;
+ }
+
+ public short getDistance() {
+ return distance;
+ }
+
+ public short getCalories() {
+ return calories;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_ACTIVITY_DATA, 14);
+
+ daysAgo = params.get();
+ totalRecords = params.get();
+ currentRecord = params.get();
+ year = params.get();
+ month = params.get();
+ day = params.get();
+ hour = params.get();
+ minute = params.get();
+ steps = params.getShort();
+ distance = params.getShort();
+ calories = params.getShort();
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(daysAgo);
+ return LefunConstants.CMD_ACTIVITY_DATA;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetBatteryLevelCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetBatteryLevelCommand.java
new file mode 100644
index 0000000..0612ace
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetBatteryLevelCommand.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class GetBatteryLevelCommand extends BaseCommand {
+ private byte batteryLevel;
+
+ public byte getBatteryLevel() {
+ return batteryLevel;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_BATTERY_LEVEL, 1);
+
+ batteryLevel = params.get();
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ return LefunConstants.CMD_BATTERY_LEVEL;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetFirmwareInfoCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetFirmwareInfoCommand.java
new file mode 100644
index 0000000..a156466
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetFirmwareInfoCommand.java
@@ -0,0 +1,76 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class GetFirmwareInfoCommand extends BaseCommand {
+ private short supportCode;
+ private short devTypeReserveCode;
+ private String typeCode;
+ private short hardwareVersion;
+ private short softwareVersion;
+ private String vendorCode;
+
+ public short getSupportCode() {
+ return supportCode;
+ }
+
+ public short getDevTypeReserveCode() {
+ return devTypeReserveCode;
+ }
+
+ public String getTypeCode() {
+ return typeCode;
+ }
+
+ public short getHardwareVersion() {
+ return hardwareVersion;
+ }
+
+ public short getSoftwareVersion() {
+ return softwareVersion;
+ }
+
+ public String getVendorCode() {
+ return vendorCode;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_FIRMWARE_INFO, 16);
+
+ supportCode = (short) (params.get() | (params.get() << 8));
+ devTypeReserveCode = params.getShort();
+ byte[] typeCodeBytes = new byte[4];
+ params.get(typeCodeBytes);
+ typeCode = new String(typeCodeBytes, StandardCharsets.US_ASCII);
+ hardwareVersion = params.getShort();
+ softwareVersion = params.getShort();
+ byte[] vendorCodeBytes = new byte[4];
+ params.get(vendorCodeBytes);
+ vendorCode = new String(vendorCodeBytes, StandardCharsets.US_ASCII);
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ return LefunConstants.CMD_FIRMWARE_INFO;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetPpgDataCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetPpgDataCommand.java
new file mode 100644
index 0000000..6dbca28
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetPpgDataCommand.java
@@ -0,0 +1,135 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class GetPpgDataCommand extends BaseCommand {
+ private byte ppgType;
+ private short totalRecords;
+ private short currentRecord;
+ private byte year;
+ private byte month;
+ private byte day;
+ private byte hour;
+ private byte minute;
+ private byte second;
+ private byte[] ppgData;
+
+ public int getPpgType() {
+ return getLowestSetBitIndex(ppgType);
+ }
+
+ public void setPpgType(int type) {
+ if (type < 0 || type > 2)
+ throw new IllegalArgumentException("Invalid PPG type");
+ this.ppgType = (byte)(1 << type);
+ }
+
+ public short getTotalRecords() {
+ return totalRecords;
+ }
+
+ public short getCurrentRecord() {
+ return currentRecord;
+ }
+
+ public byte getYear() {
+ return year;
+ }
+
+ public byte getMonth() {
+ return month;
+ }
+
+ public byte getDay() {
+ return day;
+ }
+
+ public byte getHour() {
+ return hour;
+ }
+
+ public byte getMinute() {
+ return minute;
+ }
+
+ public byte getSecond() {
+ return second;
+ }
+
+ public byte[] getPpgData() {
+ return ppgData;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_PPG_DATA);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 9)
+ throwUnexpectedLength();
+
+ ppgType = params.get();
+ totalRecords = params.get();
+ currentRecord = params.get();
+ year = params.get();
+ month = params.get();
+ day = params.get();
+ hour = params.get();
+ minute = params.get();
+ second = params.get();
+
+ int typeIndex = getPpgType();
+ int dataLength;
+ switch (typeIndex) {
+ case LefunConstants.PPG_TYPE_HEART_RATE:
+ case LefunConstants.PPG_TYPE_BLOOD_OXYGEN:
+ dataLength = 1;
+ break;
+ case LefunConstants.PPG_TYPE_BLOOD_PRESSURE:
+ dataLength = 2;
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown PPG type");
+ }
+
+ if (paramsLength < dataLength + 9)
+ throwUnexpectedLength();
+
+ ppgData = new byte[dataLength];
+ params.get(ppgData);
+
+ // Extended count/index
+ if (paramsLength == dataLength + 11)
+ {
+ totalRecords |= params.get() << 8;
+ currentRecord |= params.get() << 8;
+ }
+ else if (paramsLength > dataLength + 11) {
+ throwUnexpectedLength();
+ }
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(ppgType);
+ return LefunConstants.CMD_PPG_DATA;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetSleepDataCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetSleepDataCommand.java
new file mode 100644
index 0000000..dba937f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetSleepDataCommand.java
@@ -0,0 +1,100 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class GetSleepDataCommand extends BaseCommand {
+ public static final int SLEEP_TYPE_AWAKE = 1;
+ public static final int SLEEP_TYPE_LIGHT_SLEEP = 2;
+ public static final int SLEEP_TYPE_DEEP_SLEEP = 3;
+
+ private byte daysAgo;
+ private short totalRecords;
+ private short currentRecord;
+ private byte year;
+ private byte month;
+ private byte day;
+ private byte hour;
+ private byte minute;
+ private byte sleepType;
+
+ public byte getDaysAgo() {
+ return daysAgo;
+ }
+
+ public void setDaysAgo(byte daysAgo) {
+ if (daysAgo < 0 || daysAgo > 6)
+ throw new IllegalArgumentException("Days ago must be between 0 and 6 inclusive");
+ this.daysAgo = daysAgo;
+ }
+
+ public short getTotalRecords() {
+ return totalRecords;
+ }
+
+ public short getCurrentRecord() {
+ return currentRecord;
+ }
+
+ public byte getYear() {
+ return year;
+ }
+
+ public byte getMonth() {
+ return month;
+ }
+
+ public byte getDay() {
+ return day;
+ }
+
+ public byte getHour() {
+ return hour;
+ }
+
+ public byte getMinute() {
+ return minute;
+ }
+
+ public byte getSleepType() {
+ return sleepType;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_SLEEP_DATA, 11);
+
+ daysAgo = params.get();
+ totalRecords = params.getShort();
+ currentRecord = params.getShort();
+ year = params.get();
+ month = params.get();
+ day = params.get();
+ hour = params.get();
+ minute = params.get();
+ sleepType = params.get();
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(daysAgo);
+ return LefunConstants.CMD_SLEEP_DATA;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetSleepTimeCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetSleepTimeCommand.java
new file mode 100644
index 0000000..fe4ae4e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetSleepTimeCommand.java
@@ -0,0 +1,73 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class GetSleepTimeCommand extends BaseCommand {
+ private byte daysAgo;
+ private byte year;
+ private byte month;
+ private byte day;
+ private short minutes;
+
+ public byte getDaysAgo() {
+ return daysAgo;
+ }
+
+ public void setDaysAgo(byte daysAgo) {
+ if (daysAgo < 0 || daysAgo > 6)
+ throw new IllegalArgumentException("Days ago must be between 0 and 6 inclusive");
+ this.daysAgo = daysAgo;
+ }
+
+ public byte getYear() {
+ return year;
+ }
+
+ public byte getMonth() {
+ return month;
+ }
+
+ public byte getDay() {
+ return day;
+ }
+
+ public short getMinutes() {
+ return minutes;
+ }
+
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_SLEEP_TIME_DATA, 6);
+
+ daysAgo = params.get();
+ year = params.get();
+ month = params.get();
+ day = params.get();
+ minutes = params.getShort();
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(daysAgo);
+ return LefunConstants.CMD_SLEEP_TIME_DATA;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetStepsDataCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetStepsDataCommand.java
new file mode 100644
index 0000000..3010fb7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/GetStepsDataCommand.java
@@ -0,0 +1,84 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class GetStepsDataCommand extends BaseCommand {
+ private byte daysAgo;
+ private byte year;
+ private byte month;
+ private byte day;
+ private int steps;
+ private int distance; // m
+ private int calories; // calories
+
+ public byte getDaysAgo() {
+ return daysAgo;
+ }
+
+ public void setDaysAgo(byte daysAgo) {
+ if (daysAgo < 0 || daysAgo > 6)
+ throw new IllegalArgumentException("Days ago must be between 0 and 6 inclusive");
+ this.daysAgo = daysAgo;
+ }
+
+ public byte getYear() {
+ return year;
+ }
+
+ public byte getMonth() {
+ return month;
+ }
+
+ public byte getDay() {
+ return day;
+ }
+
+ public int getSteps() {
+ return steps;
+ }
+
+ public int getDistance() {
+ return distance;
+ }
+
+ public int getCalories() {
+ return calories;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_STEPS_DATA, 16);
+
+ daysAgo = params.get();
+ year = params.get();
+ month = params.get();
+ day = params.get();
+ steps = params.getInt();
+ distance = params.getInt();
+ calories = params.getInt();
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(daysAgo);
+ return LefunConstants.CMD_STEPS_DATA;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/HydrationReminderIntervalCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/HydrationReminderIntervalCommand.java
new file mode 100644
index 0000000..1194020
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/HydrationReminderIntervalCommand.java
@@ -0,0 +1,85 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class HydrationReminderIntervalCommand extends BaseCommand {
+ private byte op;
+ private byte hydrationReminderInterval;
+
+ private boolean setSuccess;
+
+ public byte getOp() {
+ return op;
+ }
+
+ public void setOp(byte op) {
+ if (op != OP_GET && op != OP_SET)
+ throw new IllegalArgumentException("Operation must be get or set");
+ this.op = op;
+ }
+
+ public byte getHydrationReminderInterval() {
+ return hydrationReminderInterval;
+ }
+
+ public void setHydrationReminderInterval(byte hydrationReminderInterval) {
+ if (hydrationReminderInterval == 0)
+ throw new IllegalArgumentException("Interval must be non-zero");
+ this.hydrationReminderInterval = hydrationReminderInterval;
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_HYDRATION_REMINDER_INTERVAL);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 1)
+ throwUnexpectedLength();
+
+ op = params.get();
+ if (op == OP_GET) {
+ if (paramsLength != 2)
+ throwUnexpectedLength();
+
+ hydrationReminderInterval = params.get();
+ } else if (op == OP_SET) {
+ if (paramsLength != 2)
+ throwUnexpectedLength();
+
+ setSuccess = params.get() == 1;
+ } else {
+ throw new IllegalArgumentException("Invalid operation type received");
+ }
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(op);
+ if (op == OP_SET) {
+ params.put(hydrationReminderInterval);
+ }
+ return LefunConstants.CMD_HYDRATION_REMINDER_INTERVAL;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/NotificationCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/NotificationCommand.java
new file mode 100644
index 0000000..04facc7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/NotificationCommand.java
@@ -0,0 +1,123 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class NotificationCommand extends BaseCommand {
+ public static final byte SERVICE_TYPE_CALL = 0;
+ public static final byte SERVICE_TYPE_TEXT = 1;
+ public static final byte SERVICE_TYPE_QQ = 2;
+ public static final byte SERVICE_TYPE_WECHAT = 3;
+ public static final byte SERVICE_TYPE_EXTENDED = 4;
+
+ public static final byte EXTENDED_SERVICE_TYPE_FACEBOOK = 1;
+ public static final byte EXTENDED_SERVICE_TYPE_TWITTER = 2;
+ public static final byte EXTENDED_SERVICE_TYPE_LINKEDIN = 3;
+ public static final byte EXTENDED_SERVICE_TYPE_WHATSAPP = 4;
+ public static final byte EXTENDED_SERVICE_TYPE_LINE = 5;
+ public static final byte EXTENDED_SERVICE_TYPE_KAKAOTALK = 6;
+
+ public static final int MAX_PAYLOAD_LENGTH = 13;
+ public static final int MAX_MESSAGE_LENGTH = 254;
+
+ private byte serviceType;
+ private byte totalPieces;
+ private byte currentPiece;
+ private byte extendedServiceType;
+ private byte[] payload;
+
+ public int getServiceType() {
+ return getLowestSetBitIndex(serviceType);
+ }
+
+ public void setServiceType(int type) {
+ if (type < 0 || type > 4)
+ throw new IllegalArgumentException("Invalid service type");
+ this.serviceType = (byte) (1 << type);
+ }
+
+ public byte getTotalPieces() {
+ return totalPieces;
+ }
+
+ public void setTotalPieces(byte totalPieces) {
+ // This check isn't on device, but should probably be added
+ if (totalPieces == 0)
+ throw new IllegalArgumentException("Total pieces must not be 0");
+ this.totalPieces = totalPieces;
+ }
+
+ public byte getCurrentPiece() {
+ return currentPiece;
+ }
+
+ public void setCurrentPiece(byte currentPiece) {
+ // This check isn't on device, but should probably be added
+ if (currentPiece == 0)
+ throw new IllegalArgumentException("Current piece must not be 0");
+ this.currentPiece = currentPiece;
+ }
+
+ public byte getExtendedServiceType() {
+ return extendedServiceType;
+ }
+
+ public void setExtendedServiceType(byte extendedServiceType) {
+ this.extendedServiceType = extendedServiceType;
+ }
+
+ public byte[] getPayload() {
+ return payload;
+ }
+
+ public void setPayload(byte[] payload) {
+ if (payload == null)
+ throw new IllegalArgumentException("Payload must not be null");
+ if (payload.length > 13)
+ throw new IllegalArgumentException("Payload is too long");
+ this.payload = payload;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ // We should not receive a response for this
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ boolean hasExtendedServiceType = (serviceType & (1 << SERVICE_TYPE_EXTENDED)) != 0
+ && (extendedServiceType & 0x0f) != 0;
+ int maxPayloadLength = MAX_PAYLOAD_LENGTH;
+ if (hasExtendedServiceType) maxPayloadLength -= 1;
+
+ if (payload.length > maxPayloadLength)
+ throw new IllegalStateException("Payload is too long");
+
+ params.put(serviceType);
+ params.put(totalPieces);
+ params.put(currentPiece);
+ if (hasExtendedServiceType)
+ params.put(extendedServiceType);
+ params.put(payload);
+
+ return LefunConstants.CMD_NOTIFICATION;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/PpgResultCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/PpgResultCommand.java
new file mode 100644
index 0000000..ffdb6b0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/PpgResultCommand.java
@@ -0,0 +1,71 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class PpgResultCommand extends BaseCommand {
+ private byte ppgType;
+ private byte[] ppgData;
+
+ public int getPpgType() {
+ return getLowestSetBitIndex(ppgType);
+ }
+
+ public byte[] getPpgData() {
+ return ppgData;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_PPG_RESULT);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 1)
+ throwUnexpectedLength();
+
+ ppgType = params.get();
+
+ int typeIndex = getPpgType();
+ int dataLength;
+ switch (typeIndex) {
+ case LefunConstants.PPG_TYPE_HEART_RATE:
+ case LefunConstants.PPG_TYPE_BLOOD_OXYGEN:
+ dataLength = 1;
+ break;
+ case LefunConstants.PPG_TYPE_BLOOD_PRESSURE:
+ dataLength = 2;
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown PPG type");
+ }
+
+ if (paramsLength != dataLength + 1)
+ throwUnexpectedLength();
+
+ ppgData = new byte[dataLength];
+ params.get(ppgData);
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ // No handler on device side
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/ProfileCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/ProfileCommand.java
new file mode 100644
index 0000000..edf59cf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/ProfileCommand.java
@@ -0,0 +1,129 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class ProfileCommand extends BaseCommand {
+ public static final byte GENDER_FEMALE = 0;
+ public static final byte GENDER_MALE = 1;
+
+ private byte op;
+ private byte gender;
+ private byte height; // cm
+ private byte weight; // kg
+ private byte age; // years
+
+ private boolean setSuccess;
+
+ public byte getOp() {
+ return op;
+ }
+
+ public void setOp(byte op) {
+ if (op != OP_GET && op != OP_SET)
+ throw new IllegalArgumentException("Operation must be get or set");
+ this.op = op;
+ }
+
+ public byte getGender() {
+ return gender;
+ }
+
+ public void setGender(byte gender) {
+ if (gender != GENDER_FEMALE && gender != GENDER_MALE)
+ throw new IllegalArgumentException("Invalid gender");
+ this.gender = gender;
+ }
+
+ public byte getHeight() {
+ return height;
+ }
+
+ public void setHeight(byte height) {
+ int intHeight = (int)height & 0xff;
+ if (intHeight < 40 || intHeight > 210)
+ throw new IllegalArgumentException("Height must be between 40 and 210 cm inclusive");
+ this.height = height;
+ }
+
+ public byte getWeight() {
+ return weight;
+ }
+
+ public void setWeight(byte weight) {
+ int intWeight = (int)weight & 0xff;
+ if (intWeight < 5 || intWeight > 200)
+ throw new IllegalArgumentException("Weight must be between 5 and 200 kg inclusive");
+ this.weight = weight;
+ }
+
+ public byte getAge() {
+ return age;
+ }
+
+ public void setAge(byte age) {
+ if (age < 0 || age > 110)
+ throw new IllegalArgumentException("Age must be between 0 and 110 years inclusive");
+ this.age = age;
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_PROFILE);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 1)
+ throwUnexpectedLength();
+
+ op = params.get();
+ if (op == OP_GET) {
+ if (paramsLength != 5)
+ throwUnexpectedLength();
+
+ gender = params.get();
+ height = params.get();
+ weight = params.get();
+ age = params.get();
+ } else if (op == OP_SET) {
+ if (paramsLength != 2)
+ throwUnexpectedLength();
+
+ setSuccess = params.get() == 1;
+ } else {
+ throw new IllegalArgumentException("Invalid operation type received");
+ }
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(op);
+ if (op == OP_SET) {
+ params.put(gender);
+ params.put(height);
+ params.put(weight);
+ params.put(age);
+ }
+ return LefunConstants.CMD_PROFILE;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/RemoteCameraTriggeredCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/RemoteCameraTriggeredCommand.java
new file mode 100644
index 0000000..5c0528b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/RemoteCameraTriggeredCommand.java
@@ -0,0 +1,34 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class RemoteCameraTriggeredCommand extends BaseCommand {
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_REMOTE_CAMERA_TRIGGERED, 0);
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ // No handler on device side
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/RequestBondingCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/RequestBondingCommand.java
new file mode 100644
index 0000000..671a65d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/RequestBondingCommand.java
@@ -0,0 +1,44 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class RequestBondingCommand extends BaseCommand {
+ public static final byte STATUS_ALREADY_BONDED = 0;
+ public static final byte STATUS_BONDING_SUCCESSFUL = 1;
+
+ private byte status;
+
+ public byte getStatus() {
+ return status;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_BONDING_REQUEST, 1);
+
+ status = params.get();
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ return LefunConstants.CMD_BONDING_REQUEST;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SedentaryReminderIntervalCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SedentaryReminderIntervalCommand.java
new file mode 100644
index 0000000..c359311
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SedentaryReminderIntervalCommand.java
@@ -0,0 +1,85 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class SedentaryReminderIntervalCommand extends BaseCommand {
+ private byte op;
+ private byte sedentaryReminderInterval;
+
+ private boolean setSuccess;
+
+ public byte getOp() {
+ return op;
+ }
+
+ public void setOp(byte op) {
+ if (op != OP_GET && op != OP_SET)
+ throw new IllegalArgumentException("Operation must be get or set");
+ this.op = op;
+ }
+
+ public byte getSedentaryReminderInterval() {
+ return sedentaryReminderInterval;
+ }
+
+ public void setSedentaryReminderInterval(byte sedentaryReminderInterval) {
+ if (sedentaryReminderInterval == 0)
+ throw new IllegalArgumentException("Interval must be non-zero");
+ this.sedentaryReminderInterval = sedentaryReminderInterval;
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_SEDENTARY_REMINDER_INTERVAL);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 1)
+ throwUnexpectedLength();
+
+ op = params.get();
+ if (op == OP_GET) {
+ if (paramsLength != 2)
+ throwUnexpectedLength();
+
+ sedentaryReminderInterval = params.get();
+ } else if (op == OP_SET) {
+ if (paramsLength != 2)
+ throwUnexpectedLength();
+
+ setSuccess = params.get() == 1;
+ } else {
+ throw new IllegalArgumentException("Invalid operation type received");
+ }
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(op);
+ if (op == OP_SET) {
+ params.put(sedentaryReminderInterval);
+ }
+ return LefunConstants.CMD_SEDENTARY_REMINDER_INTERVAL;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SetLanguageCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SetLanguageCommand.java
new file mode 100644
index 0000000..79ed798
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SetLanguageCommand.java
@@ -0,0 +1,52 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class SetLanguageCommand extends BaseCommand {
+ private byte language;
+
+ private boolean setSuccess;
+
+ public byte getLanguage() {
+ return language;
+ }
+
+ public void setLanguage(byte language) {
+ this.language = language;
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_LANGUAGE, 1);
+
+ setSuccess = params.get() == 1;
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(language);
+ return LefunConstants.CMD_LANGUAGE;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SetRemoteCameraCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SetRemoteCameraCommand.java
new file mode 100644
index 0000000..91fab2c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SetRemoteCameraCommand.java
@@ -0,0 +1,52 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class SetRemoteCameraCommand extends BaseCommand {
+ private boolean remoteCameraEnabled;
+
+ private boolean setSuccess;
+
+ public boolean getRemoteCameraEnabled() {
+ return remoteCameraEnabled;
+ }
+
+ public void setRemoteCameraEnabled(boolean remoteCameraEnabled) {
+ this.remoteCameraEnabled = remoteCameraEnabled;
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_REMOTE_CAMERA, 1);
+
+ setSuccess = params.get() == 1;
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put((byte)(remoteCameraEnabled ? 1 : 0));
+ return LefunConstants.CMD_REMOTE_CAMERA;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SettingsCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SettingsCommand.java
new file mode 100644
index 0000000..04beba4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/SettingsCommand.java
@@ -0,0 +1,116 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class SettingsCommand extends BaseCommand {
+ public static final byte AM_PM_24_HOUR = 0;
+ public static final byte AM_PM_12_HOUR = 1;
+ public static final byte MEASUREMENT_UNIT_METRIC = 0;
+ public static final byte MEASUREMENT_UNIT_IMPERIAL = 1;
+
+ private byte op;
+ private byte option1;
+ private byte amPmIndicator;
+ private byte measurementUnit;
+
+ private boolean setSuccess;
+
+ public byte getOp() {
+ return op;
+ }
+
+ public void setOp(byte op) {
+ if (op != OP_GET && op != OP_SET)
+ throw new IllegalArgumentException("Operation must be get or set");
+ this.op = op;
+ }
+
+ public byte getOption1() {
+ return option1;
+ }
+
+ public void setOption1(byte option1) {
+ if (option1 != (byte)0xff && (option1 < 0 || option1 > 24))
+ throw new IllegalArgumentException("option1 must be between 0 and 24 inclusive");
+ this.option1 = option1;
+ }
+
+ public byte getAmPmIndicator() {
+ return amPmIndicator;
+ }
+
+ public void setAmPmIndicator(byte amPmIndicator) {
+ if (amPmIndicator != (byte)0xff && (amPmIndicator != AM_PM_12_HOUR && amPmIndicator != AM_PM_24_HOUR))
+ throw new IllegalArgumentException("Indicator must be 12 or 24 hours");
+ this.amPmIndicator = amPmIndicator;
+ }
+
+ public byte getMeasurementUnit() {
+ return measurementUnit;
+ }
+
+ public void setMeasurementUnit(byte measurementUnit) {
+ if (measurementUnit != (byte)0xff && (measurementUnit != MEASUREMENT_UNIT_METRIC && measurementUnit != MEASUREMENT_UNIT_IMPERIAL))
+ throw new IllegalArgumentException(("Unit must be metric or imperial"));
+ this.measurementUnit = measurementUnit;
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_SETTINGS);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 1)
+ throwUnexpectedLength();
+
+ op = params.get();
+ if (op == OP_GET) {
+ if (paramsLength != 4)
+ throwUnexpectedLength();
+
+ option1 = params.get();
+ amPmIndicator = params.get();
+ measurementUnit = params.get();
+ } else if (op == OP_SET) {
+ if (paramsLength != 2)
+ throwUnexpectedLength();
+
+ setSuccess = params.get() == 1;
+ } else {
+ throw new IllegalArgumentException("Invalid operation type received");
+ }
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(op);
+ if (op == OP_SET) {
+ params.put(option1);
+ params.put(amPmIndicator);
+ params.put(measurementUnit);
+ }
+ return LefunConstants.CMD_SETTINGS;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/StartPpgSensingCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/StartPpgSensingCommand.java
new file mode 100644
index 0000000..6b02161
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/StartPpgSensingCommand.java
@@ -0,0 +1,55 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class StartPpgSensingCommand extends BaseCommand {
+ private byte ppgType;
+
+ private boolean setSuccess;
+
+ public int getPpgType() {
+ return getLowestSetBitIndex(ppgType);
+ }
+
+ public void setPpgType(int type) {
+ if (type < 0 || type > 2)
+ throw new IllegalArgumentException("Invalid PPG type");
+ this.ppgType = (byte)(1 << type);
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateIdAndLength(id, params, LefunConstants.CMD_PPG_START, 2);
+
+ ppgType = params.get();
+ setSuccess = params.get() == 1;
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(ppgType);
+ return LefunConstants.CMD_PPG_START;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/TimeCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/TimeCommand.java
new file mode 100644
index 0000000..1083ce8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/TimeCommand.java
@@ -0,0 +1,148 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class TimeCommand extends BaseCommand {
+ private byte op;
+ private byte year;
+ private byte month;
+ private byte day;
+ private byte hour;
+ private byte minute;
+ private byte second;
+
+ private boolean setSuccess;
+
+ public byte getOp() {
+ if (op != OP_GET && op != OP_SET)
+ throw new IllegalArgumentException("Operation must be get or set");
+ return op;
+ }
+
+ public void setOp(byte op) {
+ this.op = op;
+ }
+
+ public byte getYear() {
+ return year;
+ }
+
+ public void setYear(byte year) {
+ this.year = year;
+ }
+
+ public byte getMonth() {
+ return month;
+ }
+
+ public void setMonth(byte month) {
+ if (month < 1 || month > 12)
+ throw new IllegalArgumentException("Month must be between 1 and 12 inclusive");
+ this.month = month;
+ }
+
+ public byte getDay() {
+ return day;
+ }
+
+ public void setDay(byte day) {
+ if (day < 1 || day > 31)
+ throw new IllegalArgumentException("Day must be between 1 and 31 inclusive");
+ this.day = day;
+ }
+
+ public byte getHour() {
+ return hour;
+ }
+
+ public void setHour(byte hour) {
+ if (hour < 0 || hour > 23)
+ throw new IllegalArgumentException("Hour must be between 0 and 23 inclusive");
+ this.hour = hour;
+ }
+
+ public byte getMinute() {
+ return minute;
+ }
+
+ public void setMinute(byte minute) {
+ if (minute < 0 || minute > 59)
+ throw new IllegalArgumentException("Minute must be between 0 and 59 inclusive");
+ this.minute = minute;
+ }
+
+ public byte getSecond() {
+ return second;
+ }
+
+ public void setSecond(byte second) {
+ if (second < 0 || second > 59)
+ throw new IllegalArgumentException("Second must be between 0 and 59 inclusive");
+ this.second = second;
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_TIME);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 1)
+ throwUnexpectedLength();
+
+ op = params.get();
+ if (op == OP_GET) {
+ if (paramsLength != 7)
+ throwUnexpectedLength();
+
+ year = params.get();
+ month = params.get();
+ day = params.get();
+ hour = params.get();
+ minute = params.get();
+ second = params.get();
+ } else if (op == OP_SET) {
+ if (paramsLength != 2)
+ throwUnexpectedLength();
+
+ setSuccess = params.get() == 1;
+ } else {
+ throw new IllegalArgumentException("Invalid operation type received");
+ }
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(op);
+ if (op == OP_SET) {
+ params.put(year);
+ params.put(month);
+ params.put(day);
+ params.put(hour);
+ params.put(minute);
+ params.put(second);
+ }
+ return LefunConstants.CMD_TIME;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/UiPagesCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/UiPagesCommand.java
new file mode 100644
index 0000000..0ff5af6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lefun/commands/UiPagesCommand.java
@@ -0,0 +1,90 @@
+/* Copyright (C) 2020-2024 Yukai Li
+
+ 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.devices.lefun.commands;
+
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunConstants;
+
+public class UiPagesCommand extends BaseCommand {
+ // I don't know which pages since they're not implemented in my watch, so no
+ // constants here for now
+
+ private byte op;
+ private short pages;
+
+ private boolean setSuccess;
+
+ public byte getOp() {
+ return op;
+ }
+
+ public void setOp(byte op) {
+ if (op != OP_GET && op != OP_SET)
+ throw new IllegalArgumentException("Operation must be get or set");
+ this.op = op;
+ }
+
+ public boolean getPage(int index) {
+ if (index < 0 || index >= 16)
+ throw new IllegalArgumentException("Index must be between 0 and 15 inclusive");
+ return getBit(pages, 1 << index);
+ }
+
+ public void setPage(int index, boolean enabled) {
+ if (index < 0 || index >= 16)
+ throw new IllegalArgumentException("Index must be between 0 and 15 inclusive");
+ pages = setBit(pages, 1 << index, enabled);
+ }
+
+ public boolean isSetSuccess() {
+ return setSuccess;
+ }
+
+ @Override
+ protected void deserializeParams(byte id, ByteBuffer params) {
+ validateId(id, LefunConstants.CMD_UI_PAGES);
+
+ int paramsLength = params.limit() - params.position();
+ if (paramsLength < 1)
+ throwUnexpectedLength();
+
+ op = params.get();
+ if (op == OP_GET) {
+ if (paramsLength != 3)
+ throwUnexpectedLength();
+
+ pages = params.getShort();
+ } else if (op == OP_SET) {
+ if (paramsLength != 2)
+ throwUnexpectedLength();
+
+ setSuccess = params.get() == 1;
+ } else {
+ throw new IllegalArgumentException("Invalid operation type received");
+ }
+ }
+
+ @Override
+ protected byte serializeParams(ByteBuffer params) {
+ params.put(op);
+ if (op == OP_SET) {
+ params.putShort(pages);
+ }
+ return LefunConstants.CMD_UI_PAGES;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/DataType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/DataType.java
new file mode 100644
index 0000000..b94f8eb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/DataType.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2019-2024 mamucho, mkusnierz
+
+ 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.devices.lenovo;
+
+public enum DataType {
+ STEPS(new byte[]{0x00, 0x00}),
+ SLEEP(new byte[]{0x00, 0x01}),
+ HEART_RATE(new byte[]{0x00, 0x02}),
+ BLOOD_PRESSURE(new byte[]{0x00, 0x06}),
+ INFRARED_TEMPERATURE(new byte[]{0x00, 0x08}),
+ ENVIRONMENT_TEMPERATURE(new byte[]{0x00, 0x09}),
+ AIR_PRESSURE(new byte[]{0x00, 0x0A});
+
+ private final byte[] value;
+
+ DataType(byte[] value) {
+ this.value = value;
+ }
+
+ public byte[] getValue() {
+ return value;
+ }
+
+ public static DataType getType(int value) {
+ for(DataType type : values()) {
+ int intVal = (type.getValue()[1] & 0xff) | ((type.getValue()[0] & 0xff) << 8);
+ if(intVal == value) {
+ return type;
+ }
+ }
+ throw new RuntimeException("No value defined for " + value);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchCalibrationActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchCalibrationActivity.java
new file mode 100644
index 0000000..ae913c1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchCalibrationActivity.java
@@ -0,0 +1,124 @@
+/* Copyright (C) 2019-2024 mamucho, mkusnierz
+
+ 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.devices.lenovo;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.View;
+import android.widget.Button;
+import android.widget.NumberPicker;
+
+import androidx.annotation.NonNull;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class LenovoWatchCalibrationActivity extends AbstractGBActivity {
+
+ private static final String STATE_DEVICE = "stateDevice";
+ private GBDevice device;
+
+ private NumberPicker pickerHour, pickerMinute, pickerSecond;
+
+ private Handler handler;
+ private Runnable holdCalibration;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_watchxplus_calibration);
+
+ pickerHour = findViewById(R.id.np_hour);
+ pickerMinute = findViewById(R.id.np_minute);
+ pickerSecond = findViewById(R.id.np_second);
+
+ pickerHour.setMinValue(1);
+ pickerHour.setMaxValue(12);
+ pickerHour.setValue(12);
+ pickerMinute.setMinValue(0);
+ pickerMinute.setMaxValue(59);
+ pickerMinute.setValue(0);
+ pickerSecond.setMinValue(0);
+ pickerSecond.setMaxValue(59);
+ pickerSecond.setValue(0);
+
+ handler = new Handler();
+ holdCalibration = new Runnable() {
+ @Override
+ public void run() {
+ LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(new Intent(LenovoWatchConstants.ACTION_CALIBRATION_HOLD));
+ handler.postDelayed(this, 10000);
+ }
+ };
+
+ Intent intent = getIntent();
+ device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (device == null && savedInstanceState != null) {
+ device = savedInstanceState.getParcelable(STATE_DEVICE);
+ }
+ if (device == null) {
+ finish();
+ }
+
+ final Button btCalibrate = findViewById(R.id.watch9_bt_calibrate);
+ btCalibrate.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ btCalibrate.setEnabled(false);
+ handler.removeCallbacks(holdCalibration);
+ Intent calibrationData = new Intent(LenovoWatchConstants.ACTION_CALIBRATION_SEND);
+ calibrationData.putExtra(LenovoWatchConstants.VALUE_CALIBRATION_HOUR, pickerHour.getValue());
+ calibrationData.putExtra(LenovoWatchConstants.VALUE_CALIBRATION_MINUTE, pickerMinute.getValue());
+ calibrationData.putExtra(LenovoWatchConstants.VALUE_CALIBRATION_SECOND, pickerSecond.getValue());
+ LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(calibrationData);
+ finish();
+ }
+ });
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelable(STATE_DEVICE, device);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ device = savedInstanceState.getParcelable(STATE_DEVICE);
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ Intent calibration = new Intent(LenovoWatchConstants.ACTION_CALIBRATION);
+ calibration.putExtra(LenovoWatchConstants.ACTION_ENABLE, true);
+ LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(calibration);
+ handler.postDelayed(holdCalibration, 1000);
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ Intent calibration = new Intent(LenovoWatchConstants.ACTION_CALIBRATION);
+ calibration.putExtra(LenovoWatchConstants.ACTION_ENABLE, false);
+ LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(calibration);
+ handler.removeCallbacks(holdCalibration);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchConstants.java
new file mode 100644
index 0000000..ecf8c39
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchConstants.java
@@ -0,0 +1,79 @@
+/* Copyright (C) 2019-2024 mkusnierz
+
+ 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.devices.lenovo;
+
+public class LenovoWatchConstants {
+
+ public static final byte RESPONSE = 0x13;
+ public static final byte REQUEST = 0x31;
+
+ public static final byte WRITE_VALUE = 0x01;
+ public static final byte READ_VALUE = 0x02;
+ public static final byte TASK = 0x04;
+ public static final byte KEEP_ALIVE = -0x80;
+
+ public static final byte[] CMD_HEADER = new byte[]{0x23, 0x01, 0x00, 0x00, 0x00};
+
+ // byte[] COMMAND = new byte[]{0x23, 0x01, 0x00, 0x31, 0x00, ... , 0x00}
+ // | | | | | | 鈹� Checksum
+ // | | | | | 鈹� Command + value
+ // | | | | 鈹� Sequence number
+ // | | | 鈹� Response/Request indicator
+ // | | 鈹� Value length
+ // | |
+ // 鈹�-----鈹� Header
+
+ public static final byte[] CMD_FIRMWARE_INFO = new byte[]{0x01, 0x02};
+ public static final byte[] CMD_AUTHORIZATION_TASK = new byte[]{0x01, 0x05};
+ public static final byte[] CMD_TIME_SETTINGS = new byte[]{0x01, 0x08};
+ public static final byte[] CMD_ALARM_SETTINGS = new byte[]{0x01, 0x0A};
+ public static final byte[] CMD_BATTERY_INFO = new byte[]{0x01, 0x14};
+
+ public static final byte[] CMD_NOTIFICATION_TASK = new byte[]{0x03, 0x01};
+ public static final byte[] CMD_NOTIFICATION_SETTINGS = new byte[]{0x03, 0x02};
+ public static final byte[] CMD_CALIBRATION_INIT_TASK = new byte[]{0x03, 0x31};
+ public static final byte[] CMD_CALIBRATION_TASK = new byte[]{0x03, 0x33, 0x01};
+ public static final byte[] CMD_CALIBRATION_KEEP_ALIVE = new byte[]{0x03, 0x34};
+ public static final byte[] CMD_DO_NOT_DISTURB_SETTINGS = new byte[]{0x03, 0x61};
+
+ public static final byte[] CMD_FITNESS_GOAL_SETTINGS = new byte[]{0x10, 0x02};
+
+ public static final byte[] RESP_AUTHORIZATION_TASK = new byte[]{0x01, 0x01, 0x05};
+ public static final byte[] RESP_BUTTON_INDICATOR = new byte[]{0x04, 0x03, 0x11};
+ public static final byte[] RESP_ALARM_INDICATOR = new byte[]{-0x80, 0x01, 0x0A};
+
+ public static final byte[] RESP_FIRMWARE_INFO = new byte[]{0x08, 0x01, 0x02};
+ public static final byte[] RESP_TIME_SETTINGS = new byte[]{0x08, 0x01, 0x08};
+ public static final byte[] RESP_BATTERY_INFO = new byte[]{0x08, 0x01, 0x14};
+ public static final byte[] RESP_NOTIFICATION_SETTINGS = new byte[]{0x01, 0x03, 0x02};
+
+ public static final String ACTION_ENABLE = "action.watch9.enable";
+
+ public static final String ACTION_CALIBRATION
+ = "nodomain.freeyourgadget.gadgetbridge.devices.action.lenovowatch.start_calibration";
+ public static final String ACTION_CALIBRATION_SEND
+ = "nodomain.freeyourgadget.gadgetbridge.devices.action.lenovowatch.send_calibration";
+ public static final String ACTION_CALIBRATION_HOLD
+ = "nodomain.freeyourgadget.gadgetbridge.devices.action.lenovowatch.keep_calibrating";
+ public static final String VALUE_CALIBRATION_HOUR
+ = "value.lenovowatch.calibration_hour";
+ public static final String VALUE_CALIBRATION_MINUTE
+ = "value.lenovowatch.calibration_minute";
+ public static final String VALUE_CALIBRATION_SECOND
+ = "value.lenovowatch.calibration_second";
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchPairingActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchPairingActivity.java
new file mode 100644
index 0000000..c845d92
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/LenovoWatchPairingActivity.java
@@ -0,0 +1,166 @@
+/* Copyright (C) 2019-2024 Andreas B枚hler, Jos茅 Rebelo, mamucho, mkusnierz,
+ Taavi Eom盲e
+
+ 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.devices.lenovo;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.BondingUtil.STATE_DEVICE_CANDIDATE;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2;
+import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.BondingInterface;
+import nodomain.freeyourgadget.gadgetbridge.util.BondingUtil;
+
+public class LenovoWatchPairingActivity extends AbstractGBActivity implements BondingInterface {
+ private static final Logger LOG = LoggerFactory.getLogger(LenovoWatchPairingActivity.class);
+
+ private TextView message;
+ private GBDeviceCandidate deviceCandidate;
+
+ private final BroadcastReceiver pairingReceiver = BondingUtil.getPairingReceiver(this);
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_watch9_pairing);
+
+ message = findViewById(R.id.watch9_pair_message);
+ Intent intent = getIntent();
+ deviceCandidate = intent.getParcelableExtra(DeviceCoordinator.EXTRA_DEVICE_CANDIDATE);
+ if (deviceCandidate == null && savedInstanceState != null) {
+ deviceCandidate = savedInstanceState.getParcelable(STATE_DEVICE_CANDIDATE);
+ }
+ if (deviceCandidate == null) {
+ Toast.makeText(this, getString(R.string.message_cannot_pair_no_mac), Toast.LENGTH_SHORT).show();
+ startActivity(new Intent(this, DiscoveryActivityV2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
+ finish();
+ return;
+ }
+
+ startPairing(deviceCandidate);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelable(STATE_DEVICE_CANDIDATE, deviceCandidate);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ deviceCandidate = savedInstanceState.getParcelable(STATE_DEVICE_CANDIDATE);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ BondingUtil.handleActivityResult(this, requestCode, resultCode, data);
+ }
+
+ private void startPairing(GBDeviceCandidate deviceCandidate) {
+ message.setText(getString(R.string.pairing, deviceCandidate));
+
+ registerBroadcastReceivers();
+
+ BondingUtil.connectThenComplete(this, deviceCandidate);
+ }
+
+ @Override
+ public void onBondingComplete(boolean success) {
+ startActivity(new Intent(this, ControlCenterv2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
+ finish();
+ }
+
+ @Override
+ public GBDeviceCandidate getCurrentTarget() {
+ return this.deviceCandidate;
+ }
+
+ @Override
+ public String getMacAddress() {
+ return deviceCandidate.getDevice().getAddress();
+ }
+
+ @Override
+ public boolean getAttemptToConnect() {
+ return true;
+ }
+
+ @Override
+ protected void onResume() {
+ registerBroadcastReceivers();
+ super.onResume();
+ }
+
+ @Override
+ protected void onStart() {
+ registerBroadcastReceivers();
+ super.onStart();
+ }
+
+ @Override
+ protected void onDestroy() {
+ unregisterBroadcastReceivers();
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onStop() {
+ unregisterBroadcastReceivers();
+ super.onStop();
+ }
+
+ @Override
+ protected void onPause() {
+ // WARN: Do not remove listeners on pause
+ // Bonding process can pause the activity and you might miss broadcasts
+ super.onPause();
+ }
+
+ public void unregisterBroadcastReceivers() {
+ AndroidUtils.safeUnregisterBroadcastReceiver(LocalBroadcastManager.getInstance(this), pairingReceiver);
+ }
+
+ public void registerBroadcastReceivers() {
+ LocalBroadcastManager.getInstance(this).registerReceiver(pairingReceiver, new IntentFilter(GBDevice.ACTION_DEVICE_CHANGED));
+ }
+
+ @Override
+ public Context getContext() {
+ return this;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusConstants.java
new file mode 100644
index 0000000..87e71f8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusConstants.java
@@ -0,0 +1,103 @@
+/* Copyright (C) 2019-2024 Damien Gaignon, mamucho, mkusnierz
+
+ 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.devices.lenovo.watchxplus;
+
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.lenovo.LenovoWatchConstants;
+
+public final class WatchXPlusConstants extends LenovoWatchConstants {
+ public static final UUID UUID_SERVICE_WATCHXPLUS = UUID.fromString("0000a800-0000-1000-8000-00805f9b34fb");
+
+ public static final UUID UUID_UNKNOWN_DESCRIPTOR = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
+
+ public static final UUID UUID_CHARACTERISTIC_WRITE = UUID.fromString("0000a801-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_DATABASE_READ = UUID.fromString("0000a802-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_UNKNOWN_3 = UUID.fromString("0000a803-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_UNKNOWN_4 = UUID.fromString("0000a804-0000-1000-8000-00805f9b34fb");
+
+
+ // new
+ public static final String PREF_CONTINIOUS_RING = "notification_enable_continious_ring";
+ public static final String PREF_REPEAT_RING = "notification_repeat_ring";
+ public static final String PREF_MISSED_CALL_ENABLE = "notification_enable_missed_call";
+ public static final String PREF_MISSED_CALL_REPEAT = "notification_repeat_missed_call";
+ public static final String PREF_BUTTON_REJECT = "notification_button_reject";
+ public static final String PREF_SHAKE_REJECT = "notification_shake_reject";
+ public static final String PREF_FORCE_TIME = "pref_device_spec_settings_force_time";
+ public static final String PREF_BP_CAL_LOW = "pref_sensors_bp_calibration_low";
+ public static final String PREF_BP_CAL_HIGH = "pref_sensors_bp_calibration_high";
+
+ public static final String PREF_SHOW_RAW_GRAPH = "show_raw_graph";
+
+ // time format constants
+ public static final byte ARG_SET_TIMEMODE_24H = 0x00;
+ public static final byte ARG_SET_TIMEMODE_12H = 0x01;
+
+ public static final int NOTIFICATION_CHANNEL_DEFAULT = 0;
+ public static final int NOTIFICATION_CHANNEL_PHONE_CALL = 10;
+
+ public static final byte[] CMD_WEATHER_SET = new byte[]{0x01, 0x10};
+ public static final byte[] CMD_RETRIEVE_DATA_COUNT = new byte[]{(byte)0xF0, 0x10};
+ public static final byte[] CMD_RETRIEVE_DATA_DETAILS = new byte[]{(byte)0xF0, 0x11};
+ public static final byte[] CMD_RETRIEVE_DATA_CONTENT = new byte[]{(byte)0xF0, 0x12};
+ public static final byte[] CMD_REMOVE_DATA_CONTENT = new byte[]{(byte)0xF0, 0x32};
+ public static final byte[] CMD_BLOOD_PRESSURE_MEASURE = new byte[]{0x05, 0x0D};
+ public static final byte[] CMD_HEART_RATE_MEASURE = new byte[]{0x03, 0x23};
+ public static final byte[] CMD_IS_BP_CALIBRATED = new byte[]{0x05, 0x0B};
+ public static final byte[] CMD_BP_CALIBRATION = new byte[]{0x05, 0x0C};
+
+ public static final byte[] CMD_NOTIFICATION_TEXT_TASK = new byte[]{0x03, 0x06};
+ public static final byte[] CMD_NOTIFICATION_CANCEL = new byte[]{0x03, 0x04};
+ public static final byte[] CMD_NOTIFICATION_SETTINGS = new byte[]{0x03, 0x02};
+ public static final byte[] CMD_POWER_MODE = new byte[]{0x03, -0x7F};
+ public static final byte[] CMD_SET_DND_HOURS_TIME = new byte[]{0x03, 0x62};
+ public static final byte[] CMD_SET_DND_HOURS_SWITCH = new byte[]{0x03, 0x61};
+ public static final byte[] CMD_SET_PERSONAL_INFO = new byte[]{0x01, 0x0E};
+ public static final byte[] CMD_INACTIVITY_REMINDER_SWITCH = new byte[]{0x03, 0x51};
+ public static final byte[] CMD_INACTIVITY_REMINDER_SET = new byte[]{0x03, 0x52};
+ public static final byte[] CMD_SET_UNITS = new byte[]{0x03, -0x6D};
+
+ public static final byte[] CMD_FITNESS_GOAL_SETTINGS = new byte[]{0x10, 0x02};
+ public static final byte[] CMD_DAY_STEPS_INFO = new byte[]{0x10, 0x03};
+
+ public static final byte[] CMD_SHAKE_SWITCH = new byte[]{0x03, -0x6E};
+ public static final byte[] CMD_DISCONNECT_REMIND = new byte[]{0x00, 0x11};
+ public static final byte[] CMD_TIME_LANGUAGE = new byte[]{0x03, -0x6F};
+ public static final byte[] CMD_ALTITUDE = new byte[]{0x05, 0x0A};
+
+ public static final byte[] RESP_SHAKE_SWITCH = new byte[]{0x08, 0x03, -0x6E};
+ public static final byte[] RESP_DISCONNECT_REMIND = new byte[]{0x08, 0x00, 0x11};
+ public static final byte[] RESP_IS_BP_CALIBRATED = new byte[]{0x08, 0x05, 0x0B};
+ public static final byte[] RESP_BUTTON_WHILE_RING = new byte[]{0x04, 0x03, 0x03};
+ public static final byte[] RESP_BP_CALIBRATION = new byte[]{0x08, 0x05, 0x0C};
+ public static final byte[] RESP_SET_PERSONAL_INFO = new byte[]{0x08, 0x01, 0x0E};
+ public static final byte[] RESP_GOAL_AIM_STATUS = new byte[]{0x08, 0x10, 0x02};
+ public static final byte[] RESP_INACTIVITY_REMINDER_SWITCH = new byte[]{0x08, 0x03, 0x51};
+ public static final byte[] RESP_INACTIVITY_REMINDER_SET = new byte[]{0x08, 0x03, 0x52};
+
+ public static final byte[] RESP_AUTHORIZATION_TASK = new byte[]{0x01, 0x01, 0x05};
+ public static final byte[] RESP_DAY_STEPS_INDICATOR = new byte[]{0x08, 0x10, 0x03};
+ public static final byte[] RESP_HEARTRATE = new byte[]{(byte) 0x80, 0x15, 0x03};
+
+ public static final byte[] RESP_DATA_COUNT = new byte[]{0x08, (byte)0xF0, 0x10};
+ public static final byte[] RESP_DATA_DETAILS = new byte[]{0x08, (byte)0xF0, 0x11};
+ public static final byte[] RESP_DATA_CONTENT = new byte[]{0x08, (byte)0xF0, 0x12};
+ public static final byte[] RESP_DATA_CONTENT_REMOVE = new byte[]{-0x80, (byte)0xF0, 0x32};
+ public static final byte[] RESP_BP_MEASURE_STARTED = new byte[]{0x08, 0x05, 0x0D};
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusDeviceCoordinator.java
new file mode 100644
index 0000000..2fd76a4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusDeviceCoordinator.java
@@ -0,0 +1,319 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Jos茅 Rebelo, mamucho, mkusnierz, Petr Van臎k
+
+ 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.devices.lenovo.watchxplus;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.lenovo.LenovoWatchCalibrationActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.lenovo.LenovoWatchPairingActivity;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.lenovo.watchxplus.WatchXPlusDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+import static nodomain.freeyourgadget.gadgetbridge.GBApplication.getContext;
+
+
+public class WatchXPlusDeviceCoordinator extends AbstractBLEDeviceCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(WatchXPlusDeviceSupport.class);
+ private static final int FindPhone_ON = -1;
+ public static final int FindPhone_OFF = 0;
+ public static boolean isBPCalibrated = false;
+
+ private static final Prefs prefs = GBApplication.getPrefs();
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid watchXpService = new ParcelUuid(WatchXPlusConstants.UUID_SERVICE_WATCHXPLUS);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(watchXpService).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @NonNull
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ String macAddress = candidate.getMacAddress().toUpperCase();
+ String deviceName = candidate.getName().toUpperCase();
+ if (candidate.supportsService(WatchXPlusConstants.UUID_SERVICE_WATCHXPLUS)) {
+ return true;
+ } else if (macAddress.startsWith("DC:41:E5")) {
+ return true;
+ } else if (deviceName.equalsIgnoreCase("WATCH XPLUS")) {
+ return true;
+ // add initial support for Watch X non-plus (forces Watch X to be recognized as Watch XPlus)
+ // Watch X non-plus have same MAC address as Watch 9 (starts with "1C:87:79")
+ } else if (deviceName.equalsIgnoreCase("WATCH X")) {
+ return true;
+ }
+ return false;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return LenovoWatchPairingActivity.class;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new WatchXPlusSampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 3;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Lenovo";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() { return false; }
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() { return false; }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_liftwrist_display_noshed,
+ R.xml.devicesettings_disconnectnotification_noshed,
+ R.xml.devicesettings_donotdisturb_no_auto,
+ R.xml.devicesettings_inactivity,
+ R.xml.devicesettings_find_phone,
+ R.xml.devicesettings_timeformat,
+ R.xml.devicesettings_power_mode,
+ R.xml.devicesettings_watchxplus,
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return WatchXPlusDeviceSupport.class;
+ }
+
+ @Override
+ public EnumSet<ServiceDeviceSupport.Flags> getInitialFlags() {
+ return EnumSet.of(ServiceDeviceSupport.Flags.THROTTLING, ServiceDeviceSupport.Flags.BUSY_CHECKING);
+ }
+
+// find phone settings
+ /**
+ * @return {@link #FindPhone_OFF}, {@link #FindPhone_ON}, or the duration
+ */
+ public static int getFindPhone(SharedPreferences sharedPrefs) {
+ String findPhone = sharedPrefs.getString(DeviceSettingsPreferenceConst.PREF_FIND_PHONE, getContext().getString(R.string.p_off));
+
+ assert findPhone != null;
+ if (findPhone.equals(getContext().getString(R.string.p_off))) {
+ return FindPhone_OFF;
+ } else if (findPhone.equals(getContext().getString(R.string.p_on))) {
+ return FindPhone_ON;
+ } else { // Duration
+ String duration = sharedPrefs.getString(DeviceSettingsPreferenceConst.PREF_FIND_PHONE_DURATION, "0");
+
+ try {
+ int iDuration;
+
+ try {
+ assert duration != null;
+ iDuration = Integer.valueOf(duration);
+ } catch (Exception ex) {
+ iDuration = 60;
+ }
+
+ return iDuration;
+ } catch (Exception e) {
+ return FindPhone_ON;
+ }
+ }
+ }
+
+
+ /**
+ * @param startOut out Only hour/minute are used.
+ * @param endOut out Only hour/minute are used.
+ * @return True if DND hours are enabled.
+ */
+ public static boolean getDNDHours(String deviceAddress, Calendar startOut, Calendar endOut) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ String doNotDisturb = prefs.getString(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO, getContext().getString(R.string.p_off));
+
+ assert doNotDisturb != null;
+ if (doNotDisturb.equals(getContext().getString(R.string.p_off))) {
+ LOG.info(" DND is disabled ");
+ return false;
+ } else {
+
+ String start = prefs.getString(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO_START, "01:00");
+ String end = prefs.getString(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO_END, "06:00");
+
+ DateFormat df = new SimpleDateFormat("HH:mm");
+
+ try {
+ startOut.setTime(df.parse(start));
+ endOut.setTime(df.parse(end));
+
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+ }
+
+ /**
+ * @param startOut out Only hour/minute are used.
+ * @param endOut out Only hour/minute are used.
+ * @return True if DND hours are enabled.
+ */
+ public static boolean getLongSitHours(String deviceAddress, Calendar startOut, Calendar endOut) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress);
+ boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE, false);
+
+ if (!enabled) {
+ LOG.info(" Long sit reminder is disabled ");
+ return false;
+ } else {
+ String start = prefs.getString(DeviceSettingsPreferenceConst.PREF_INACTIVITY_START, "06:00");
+ String end = prefs.getString(DeviceSettingsPreferenceConst.PREF_INACTIVITY_END, "23:00");
+
+ DateFormat df = new SimpleDateFormat("HH:mm");
+
+ try {
+ startOut.setTime(df.parse(start));
+ endOut.setTime(df.parse(end));
+
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getCalibrationActivity() {
+ return LenovoWatchCalibrationActivity.class;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_watchxplus;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_watchxplus;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_watchxplus_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusSampleProvider.java
new file mode 100644
index 0000000..f42a6d7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/lenovo/watchxplus/WatchXPlusSampleProvider.java
@@ -0,0 +1,541 @@
+/* Copyright (C) 2019-2024 mamucho, mkusnierz
+
+ 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.devices.lenovo.watchxplus;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.WatchXPlusActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.WatchXPlusActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+
+public class WatchXPlusSampleProvider extends AbstractSampleProvider<WatchXPlusActivitySample> {
+ private GBDevice mDevice;
+ private DaoSession mSession;
+
+ private final float movementDivisor = 950.0f;
+
+ private static final Logger LOG = LoggerFactory.getLogger(WatchXPlusSampleProvider.class);
+
+ public WatchXPlusSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ mSession = session;
+ mDevice = device;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ LOG.debug("toRawActivityKind: {}", activityKind);
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ float newIntensity = 0;
+ if (rawIntensity <= 0) {
+ //newIntensity = (rawIntensity * 0.5f) + 0.7f;
+ newIntensity = rawIntensity;
+ } else {
+ newIntensity = rawIntensity / movementDivisor;
+ }
+ //LOG.info(" normalizeIntensity: " + rawIntensity + " to " + newIntensity);
+ return newIntensity;
+ //return rawIntensity;
+ }
+
+ @Override
+ public WatchXPlusActivitySample createActivitySample() {
+ return new WatchXPlusActivitySample();
+ }
+
+ @Override
+ public AbstractDao<WatchXPlusActivitySample, ?> getSampleDao() {
+ return getSession().getWatchXPlusActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return WatchXPlusActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return WatchXPlusActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return WatchXPlusActivitySampleDao.Properties.DeviceId;
+ }
+
+ // generate ActivityKind.NOT_MEASURED if there are no data for more than 15 min. and less than 60 min.
+ // generate ActivityKind.NOT_WORN if there are no data for more than 60 min.
+ @NonNull
+ private List<WatchXPlusActivitySample> checkActivityData(List<WatchXPlusActivitySample> samples, int notMeasuredTS, int notWornTS) {
+ int oldTS = 0;
+ int newTS = 0;
+ oldTS = samples.get(0).getTimestamp();
+ for (int i = 0; i < samples.size(); i++) {
+ //oldTS = resultList.get(i).getTimestamp();
+ newTS = samples.get(i).getTimestamp();
+ if ((newTS - oldTS) < notMeasuredTS) { //check data timestamp diff is more than 15 min
+ oldTS = samples.get(i).getTimestamp();
+ } else if (((newTS - oldTS) > notMeasuredTS) && ((newTS - oldTS) < notWornTS)) { //set data to ActivityKind.NOT_MEASURED) if timestamp diff is more than 15 min
+ samples.get(i-1).setRawKind(ActivityKind.NOT_MEASURED.getCode());
+ samples.get(i).setRawKind(ActivityKind.NOT_MEASURED.getCode());
+ oldTS = samples.get(i).getTimestamp();
+ } else if ((newTS - oldTS) > notWornTS) { //set data to ActivityKind.NOT_WORN if timestamp diff is more than 60 min
+ samples.get(i-1).setRawKind(ActivityKind.NOT_WORN.getCode());
+ samples.get(i).setRawKind(ActivityKind.NOT_WORN.getCode());
+ oldTS = samples.get(i).getTimestamp();
+ }
+ }
+ return samples;
+ }
+
+
+
+
+ @NonNull
+ @Override
+ public List<WatchXPlusActivitySample> getAllActivitySamples(int timestamp_from, int timestamp_to) {
+ boolean showRawData = GBApplication.getDeviceSpecificSharedPrefs(mDevice.getAddress()).getBoolean(WatchXPlusConstants.PREF_SHOW_RAW_GRAPH, false);
+ if (showRawData) {
+ return getGBActivitySamples(timestamp_from, timestamp_to);
+ }
+ List<WatchXPlusActivitySample> samples = getGBActivitySamples(timestamp_from, timestamp_to);
+ int numEntries = samples.size();
+ if (numEntries < 3) {
+ return samples;
+ }
+
+ List<WatchXPlusActivitySample> resultList = new ArrayList<>(numEntries);
+
+ // how many elements to scan for sleep sate before and after sleep block
+ int seekAhead = 10;
+ boolean secondBlock = false;
+
+// find sleep start and sleep stop index based on ActivityKind.DEEP_SLEEP BLOCK 1
+ int sleepStartIndex_1 = 0;
+ int sleepStopIndex_1 = numEntries;
+ int countNextSleepStart_1 = 0;
+ int countNextSleepStop_1 = 0;
+
+ for (int i = 0; i < numEntries; i++) {
+ if (samples.get(i).getRawKind() == ActivityKind.DEEP_SLEEP.getCode()) {
+ // normalize RawIntensity
+ samples.get(i).setRawIntensity(1000);
+ // find sleep start index
+ if (sleepStartIndex_1 == 0) {
+ sleepStartIndex_1 = i;
+ sleepStopIndex_1 = sleepStartIndex_1;
+ countNextSleepStop_1 = sleepStopIndex_1;
+ } else {
+ if (countNextSleepStart_1 == 0) {
+ countNextSleepStart_1 = i;
+ // reset start index if next index is far ahead
+ if ((countNextSleepStart_1 - sleepStartIndex_1) > seekAhead * 3) {
+ sleepStartIndex_1 = countNextSleepStart_1;
+ sleepStopIndex_1 = sleepStartIndex_1;
+ countNextSleepStop_1 = sleepStopIndex_1;
+ }
+ }
+ }
+
+
+ if ((i - sleepStopIndex_1) < (seekAhead * 4)) {
+ sleepStopIndex_1 = i;
+ }
+ countNextSleepStop_1 = i;
+ }
+ }
+
+// find sleep start and sleep stop index based on ActivityKind.DEEP_SLEEP BLOCK 2
+ int sleepStartIndex_2 = 0;
+ int sleepStopIndex_2 = numEntries;
+ int countNextSleepStart_2 = 0;
+ int countNextSleepStop_2 = 0;
+ int next_block = numEntries;
+
+ for (int i = sleepStopIndex_1 + 1; i < numEntries; i++) {
+ if (samples.get(i).getRawKind() == ActivityKind.DEEP_SLEEP.getCode()) {
+ // find sleep start index
+ if (sleepStartIndex_2 == 0) {
+ sleepStartIndex_2 = i;
+ sleepStopIndex_2 = sleepStartIndex_2;
+ countNextSleepStop_2 = sleepStopIndex_2;
+ } else {
+ if (countNextSleepStart_2 == 0) {
+ countNextSleepStart_2 = i;
+ // reset start index if next index is far ahead
+ if ((countNextSleepStart_2 - sleepStartIndex_2) > seekAhead * 3) {
+ sleepStartIndex_2 = countNextSleepStart_2;
+ sleepStopIndex_2 = sleepStartIndex_2;
+ countNextSleepStop_2 = sleepStopIndex_2;
+ }
+ }
+ }
+ if ((i - sleepStopIndex_2) < (seekAhead * 4)) {
+ sleepStopIndex_2 = i;
+ }
+ countNextSleepStop_2 = i;
+ }
+ }
+ if (sleepStartIndex_2 != 0) {
+ secondBlock = true;
+ LOG.info(" Found second block ");
+ }
+
+ LOG.info(" sleep_1 begin index:" + sleepStartIndex_1 + " next index: " + countNextSleepStart_1 + " sleep end index: " + sleepStopIndex_1 + " sleep end: " + countNextSleepStop_1);
+ if (secondBlock) {
+ LOG.info(" sleep_2 begin index:" + sleepStartIndex_2 + " next index: " + countNextSleepStart_2 + " sleep end index: " + sleepStopIndex_2 + " sleep end: " + countNextSleepStop_2);
+ }
+
+// SLEEP BLOCK 1
+ // add all activity before sleep start
+ if (secondBlock) {
+ next_block = sleepStartIndex_2;
+ }
+ int newSleepStartIndex_1 = 0;
+ if (sleepStartIndex_1 >= seekAhead) {
+ newSleepStartIndex_1 = sleepStartIndex_1 - seekAhead;
+ } else {
+ newSleepStartIndex_1 = 0;
+ }
+ for (int i = 0; i < newSleepStartIndex_1; i++) {
+ if (samples.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) {
+ if (samples.get(i).getRawIntensity() <= 300) {
+ samples.get(i).setRawIntensity(200);
+ } else if ((samples.get(i).getRawIntensity() <= 1000) && (samples.get(i).getRawIntensity() > 100)) {
+ samples.get(i).setRawIntensity(400);
+ } if (samples.get(i).getRawIntensity() > 1000) {
+ samples.get(i).setRawIntensity(600);
+ }
+ samples.get(i).setRawKind(1);
+ resultList.add(samples.get(i));
+ } else {
+ if (samples.get(i).getRawKind() == ActivityKind.ACTIVITY.getCode()) {
+ if (i < (newSleepStartIndex_1 - 3)) {
+ if ((samples.get(i + 1).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) || (samples.get(i + 2).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) || (samples.get(i + 3).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode())) {
+ samples.get(i).setRawKind(1);
+ //samples.get(i).setRawIntensity(700);
+ } else {
+ samples.get(i).setRawIntensity(1000);
+ }
+ }
+ //samples.get(i).setRawIntensity(1000);
+ } else {
+ samples.get(i).setRawIntensity(1000);
+ }
+ resultList.add(samples.get(i));
+ }
+ }
+
+// add sleep activity
+ int newSleepStopIndex_1;
+
+ if ((sleepStopIndex_1 + seekAhead * 2) < next_block) {
+ newSleepStopIndex_1 = sleepStopIndex_1 + seekAhead * 2;
+ } else {
+ newSleepStopIndex_1 = next_block;
+ }
+
+ boolean replaceActivity_1 = false;
+ for (int i = newSleepStartIndex_1; i < newSleepStopIndex_1; i++) {
+ ActivitySample sample = samples.get(i);
+ if (i < sleepStartIndex_1) {
+ if (samples.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) {
+ replaceActivity_1 = true;
+ samples.get(i).setRawIntensity(600);
+ resultList.add(samples.get(i));
+ } else {
+ if (replaceActivity_1) {
+ samples.get(i).setRawKind(2);
+ samples.get(i).setRawIntensity(600);
+ resultList.add(samples.get(i));
+ } else {
+ samples.get(i).setRawIntensity(600);
+ resultList.add(samples.get(i));
+ }
+ }
+ }
+ if ((samples.get(i).getRawKind() == ActivityKind.DEEP_SLEEP.getCode()) || (samples.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode())) {
+ if (samples.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) {
+ if (i > 0) {
+ if (samples.get(i - 1).getHeartRate() > 0) {
+ samples.get(i).setHeartRate(samples.get(i - 1).getHeartRate());
+ }
+ } else {
+ if (samples.get(i + 1).getHeartRate() > 0) {
+ samples.get(i).setHeartRate(samples.get(i + 1).getHeartRate());
+ }
+ }
+ samples.get(i).setRawIntensity(600);
+ resultList.add(samples.get(i));
+ } else {
+ samples.get(i).setRawIntensity(1000);
+ resultList.add(samples.get(i));
+ }
+ }
+
+ if ((samples.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) && (i > sleepStopIndex_1)) {
+ samples.get(i).setRawIntensity(600);
+ resultList.add(samples.get(i));
+ }
+ }
+
+
+// add remaining activity
+ if (newSleepStopIndex_1 < next_block) {
+ for (int i = newSleepStopIndex_1; i < (next_block-1); i++) {
+ if (samples.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) {
+ if (samples.get(i).getRawIntensity() <= 300) {
+ samples.get(i).setRawIntensity(200);
+ } else if ((samples.get(i).getRawIntensity() <= 1000) && (samples.get(i).getRawIntensity() > 100)) {
+ samples.get(i).setRawIntensity(400);
+ } if (samples.get(i).getRawIntensity() > 1000) {
+ samples.get(i).setRawIntensity(600);
+ }
+ samples.get(i).setRawKind(1);
+ resultList.add(samples.get(i));
+ } else {
+ if (samples.get(i).getRawKind() == ActivityKind.ACTIVITY.getCode()) {
+ if (i < (next_block - 3)) {
+ if ((samples.get(i + 1).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) || (samples.get(i + 2).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) || (samples.get(i + 3).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode())) {
+ samples.get(i).setRawKind(1);
+ //samples.get(i).setRawIntensity(700);
+ } else {
+ samples.get(i).setRawIntensity(1000);
+ }
+ }
+ //samples.get(i).setRawIntensity(1000);
+ } else {
+ samples.get(i).setRawIntensity(1000);
+ }
+ resultList.add(samples.get(i));
+ }
+ }
+ }
+// SLEEP BLOCK 2
+ if (secondBlock) {
+// add sleep activity
+ int newSleepStopIndex_2;
+ int newSleepStartIndex_2 = 0;
+ boolean replaceActivity_2 = false;
+ if (sleepStartIndex_2 >= next_block + seekAhead) {
+ newSleepStartIndex_2 = sleepStartIndex_2 - seekAhead;
+ } else {
+ newSleepStartIndex_2 = next_block;
+ }
+ if ((sleepStopIndex_2 + seekAhead * 2) < numEntries) {
+ newSleepStopIndex_2 = sleepStopIndex_2 + seekAhead * 2;
+ } else {
+ newSleepStopIndex_2 = numEntries;
+ }
+ for (int i = newSleepStartIndex_2; i < newSleepStopIndex_2; i++) {
+ ActivitySample sample = samples.get(i);
+ if (i < sleepStartIndex_2) {
+ if (samples.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) {
+ replaceActivity_2 = true;
+ samples.get(i).setRawIntensity(600);
+ resultList.add(samples.get(i));
+ } else {
+ if (replaceActivity_2) {
+ samples.get(i).setRawKind(2);
+ samples.get(i).setRawIntensity(600);
+ resultList.add(samples.get(i));
+ } else {
+ samples.get(i).setRawIntensity(600);
+ resultList.add(samples.get(i));
+ }
+ }
+ }
+
+
+ if ((samples.get(i).getRawKind() == ActivityKind.DEEP_SLEEP.getCode()) || (samples.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode())) {
+ if (samples.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) {
+ if (i > 0) {
+ if (samples.get(i - 1).getHeartRate() > 0) {
+ samples.get(i).setHeartRate(samples.get(i - 1).getHeartRate());
+ }
+ } else {
+ if (samples.get(i + 1).getHeartRate() > 0) {
+ samples.get(i).setHeartRate(samples.get(i + 1).getHeartRate());
+ }
+ }
+ samples.get(i).setRawIntensity(600);
+ resultList.add(samples.get(i));
+ } else {
+ samples.get(i).setRawIntensity(1000);
+ resultList.add(samples.get(i));
+ }
+ }
+ if ((samples.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) && (i > sleepStopIndex_2)) {
+ samples.get(i).setRawIntensity(600);
+ resultList.add(samples.get(i));
+ }
+ }
+
+ // add remaining activity
+ if (newSleepStopIndex_2 < numEntries) {
+ for (int i = newSleepStopIndex_2; i < (numEntries - 1); i++) {
+ if (samples.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) {
+ if (samples.get(i).getRawIntensity() <= 300) {
+ samples.get(i).setRawIntensity(200);
+ } else if ((samples.get(i).getRawIntensity() <= 1000) && (samples.get(i).getRawIntensity() > 100)) {
+ samples.get(i).setRawIntensity(400);
+ }
+ if (samples.get(i).getRawIntensity() > 1000) {
+ samples.get(i).setRawIntensity(600);
+ }
+ samples.get(i).setRawKind(1);
+ resultList.add(samples.get(i));
+ } else {
+ if (samples.get(i).getRawKind() == ActivityKind.ACTIVITY.getCode()) {
+ if (i < (numEntries - 3)) {
+ if ((samples.get(i + 1).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) || (samples.get(i + 2).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode()) || (samples.get(i + 3).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode())) {
+ samples.get(i).setRawKind(1);
+ //samples.get(i).setRawIntensity(700);
+ } else {
+ samples.get(i).setRawIntensity(1000);
+ }
+ }
+ //samples.get(i).setRawIntensity(1000);
+ } else {
+ samples.get(i).setRawIntensity(1000);
+ }
+ resultList.add(samples.get(i));
+ }
+ }
+ }
+ }
+ // add one ActivityKind.NOT_MEASURED at end of data
+ samples.get(numEntries-1).setRawIntensity(0);
+ samples.get(numEntries-1).setRawKind(ActivityKind.NOT_MEASURED.getCode());
+ samples.get(numEntries-1).setHeartRate(0);
+ resultList.add(samples.get(numEntries-1));
+
+ // find all steps, total activity intensity and maxHR
+ int totalSteps = 0;
+ int maxHeartRate = 10;
+ numEntries = resultList.size();
+ for (int i = 0; i < numEntries-1; i++) {
+ if (resultList.get(i).getRawKind() == ActivityKind.ACTIVITY.getCode()) {
+ if (resultList.get(i).getSteps() > 0) {
+ totalSteps = totalSteps + resultList.get(i).getSteps();
+ }
+ }
+ if (resultList.get(i).getHeartRate() > maxHeartRate) {
+ maxHeartRate = resultList.get(i).getHeartRate();
+ }
+ }
+
+ // reformat activity data based on heart rate
+ int newIntensity, correctedSteps;
+ int totalIntensity = 0;
+ for (int i = 0; i < numEntries-1; i++) {
+ if ((resultList.get(i).getRawKind() == ActivityKind.ACTIVITY.getCode()) || (resultList.get(i).getRawKind() == ActivityKind.LIGHT_SLEEP.getCode())) {
+ if (resultList.get(i).getRawIntensity() <= 600) { // set interpolated intensity based on heart rate for every TYPE_ACTIVITY which are converted from TYPE_LIGHT_SLEEP
+ if (resultList.get(i).getHeartRate() < 10) {
+ newIntensity = resultList.get(i).getRawIntensity() + ((maxHeartRate - resultList.get(i+1).getHeartRate()) * 2);
+ } else {
+ newIntensity = resultList.get(i).getRawIntensity() + ((maxHeartRate - resultList.get(i).getHeartRate()) * 2);
+ }
+ } else { // because there are not RAW intensity values for every TYPE_ACTIVITY set interpolated intensity based on heart rate
+ newIntensity = resultList.get(i).getRawIntensity() - ((maxHeartRate - resultList.get(i).getHeartRate()) * 2);
+ }
+ /*
+ if (stepsPerActivity > 0.0f) { // because there are not steps values for every TYPE_ACTIVITY set interpolated steps
+ correctedSteps = (int) (resultList.get(i).getRawIntensity() / stepsPerActivity);
+ resultList.get(i).setSteps(correctedSteps);
+ }
+ */
+ resultList.get(i).setRawIntensity(newIntensity);
+ if (resultList.get(i).getRawIntensity() > 0) {
+ totalIntensity = totalIntensity + newIntensity;
+ }
+ } else { // because there are not TYPE_DEEP_SLEEP intensity set random DEEP_SLEEP intensity
+ Random r = new Random();
+ newIntensity = resultList.get(i).getRawIntensity() - ((maxHeartRate - (int)(r.nextFloat() * maxHeartRate)) * 2);
+ resultList.get(i).setRawIntensity(newIntensity);
+ if (resultList.get(i).getRawIntensity() > 0) {
+ totalIntensity = totalIntensity + newIntensity;
+ }
+ }
+ }
+
+ // because there are not steps values for every TYPE_ACTIVITY set interpolated steps
+ float stepsPerActivity = 0.000f;
+ int newTotalSteps = 0;
+ int activityCount = 0;
+ if (totalSteps > 0) {
+ stepsPerActivity = totalIntensity / totalSteps;
+ for (int i = 0; i < numEntries - 1; i++) {
+ if (resultList.get(i).getRawKind() == ActivityKind.ACTIVITY.getCode()) {
+ if (stepsPerActivity > 0.0f) {
+ correctedSteps = (int) (resultList.get(i).getRawIntensity() / stepsPerActivity);
+ resultList.get(i).setSteps(correctedSteps);
+ newTotalSteps = newTotalSteps + correctedSteps;
+ activityCount = activityCount + 1;
+ }
+ }
+ }
+ }
+ if (newTotalSteps < totalSteps) {
+ int stepsDiff = newTotalSteps - totalSteps;
+ int increaseStepsWith = stepsDiff / activityCount;
+ if (increaseStepsWith <= 1) {
+ increaseStepsWith = 2;
+ }
+ newTotalSteps = 0;
+ for (int i = 0; i < numEntries - 1; i++) {
+ if (resultList.get(i).getRawKind() == ActivityKind.ACTIVITY.getCode()) {
+ correctedSteps = resultList.get(i).getSteps() + increaseStepsWith;
+ newTotalSteps = newTotalSteps + correctedSteps;
+ if (newTotalSteps <= totalSteps) {
+ resultList.get(i).setSteps(correctedSteps);
+ } else {
+ break;
+ }
+ }
+ }
+ }
+ return checkActivityData(resultList, 900, 3600);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/liveview/LiveviewConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/liveview/LiveviewConstants.java
new file mode 100644
index 0000000..0055834
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/liveview/LiveviewConstants.java
@@ -0,0 +1,126 @@
+/* Copyright (C) 2016-2024 Daniele Gobbetti
+
+ 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.devices.liveview;
+//Changed by Renze: Fixed brightness constants
+
+import java.nio.ByteOrder;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Message constants reverse-engineered by Andrew de Quincey (<a
+ * href="http://adq.livejournal.com">http://adq.livejournal.com</a>).
+ *
+ * @author Robert <xperimental@solidproject.de>
+ */
+public final class LiveviewConstants {
+
+ public static Charset ENCODING = StandardCharsets.ISO_8859_1;
+ public static ByteOrder BYTE_ORDER = ByteOrder.BIG_ENDIAN;
+
+ public static final byte CLOCK_24H = 0;
+ public static final byte CLOCK_12H = 1;
+
+ public static final byte MSG_GETCAPS = 1;
+ public static final byte MSG_GETCAPS_RESP = 2;
+
+ public static final byte MSG_DISPLAYTEXT = 3;
+ public static final byte MSG_DISPLAYTEXT_ACK = 4;
+
+ public static final byte MSG_DISPLAYPANEL = 5;
+ public static final byte MSG_DISPLAYPANEL_ACK = 6;
+
+ public static final byte MSG_DEVICESTATUS = 7;
+ public static final byte MSG_DEVICESTATUS_ACK = 8;
+
+ public static final byte MSG_DISPLAYBITMAP = 19;
+ public static final byte MSG_DISPLAYBITMAP_ACK = 20;
+
+ public static final byte MSG_CLEARDISPLAY = 21;
+ public static final byte MSG_CLEARDISPLAY_ACK = 22;
+
+ public static final byte MSG_SETMENUSIZE = 23;
+ public static final byte MSG_SETMENUSIZE_ACK = 24;
+
+ public static final byte MSG_GETMENUITEM = 25;
+ public static final byte MSG_GETMENUITEM_RESP = 26;
+
+ public static final byte MSG_GETALERT = 27;
+ public static final byte MSG_GETALERT_RESP = 28;
+
+ public static final byte MSG_NAVIGATION = 29;
+ public static final byte MSG_NAVIGATION_RESP = 30;
+
+ public static final byte MSG_SETSTATUSBAR = 33;
+ public static final byte MSG_SETSTATUSBAR_ACK = 34;
+
+ public static final byte MSG_GETMENUITEMS = 35;
+
+ public static final byte MSG_SETMENUSETTINGS = 36;
+ public static final byte MSG_SETMENUSETTINGS_ACK = 37;
+
+ public static final byte MSG_GETTIME = 38;
+ public static final byte MSG_GETTIME_RESP = 39;
+
+ public static final byte MSG_SETLED = 40;
+ public static final byte MSG_SETLED_ACK = 41;
+
+ public static final byte MSG_SETVIBRATE = 42;
+ public static final byte MSG_SETVIBRATE_ACK = 43;
+
+ public static final byte MSG_ACK = 44;
+
+ public static final byte MSG_SETSCREENMODE = 64;
+ public static final byte MSG_SETSCREENMODE_ACK = 65;
+
+ public static final byte MSG_GETSCREENMODE = 66;
+ public static final byte MSG_GETSCREENMODE_RESP = 67;
+
+ public static final int DEVICESTATUS_OFF = 0;
+ public static final int DEVICESTATUS_ON = 1;
+ public static final int DEVICESTATUS_MENU = 2;
+
+ public static final byte RESULT_OK = 0;
+ public static final byte RESULT_ERROR = 1;
+ public static final byte RESULT_OOM = 2;
+ public static final byte RESULT_EXIT = 3;
+ public static final byte RESULT_CANCEL = 4;
+
+ public static final int NAVACTION_PRESS = 0;
+ public static final int NAVACTION_LONGPRESS = 1;
+ public static final int NAVACTION_DOUBLEPRESS = 2;
+
+ public static final int NAVTYPE_UP = 0;
+ public static final int NAVTYPE_DOWN = 1;
+ public static final int NAVTYPE_LEFT = 2;
+ public static final int NAVTYPE_RIGHT = 3;
+ public static final int NAVTYPE_SELECT = 4;
+ public static final int NAVTYPE_MENUSELECT = 5;
+
+ public static final int ALERTACTION_CURRENT = 0;
+ public static final int ALERTACTION_FIRST = 1;
+ public static final int ALERTACTION_LAST = 2;
+ public static final int ALERTACTION_NEXT = 3;
+ public static final int ALERTACTION_PREV = 4;
+
+ public static final int BRIGHTNESS_OFF = 49;
+ public static final int BRIGHTNESS_DIM = 50;
+ public static final int BRIGHTNESS_MAX = 51;
+
+ public static final String CLIENT_SOFTWARE_VERSION = "0.0.3";
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/liveview/LiveviewCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/liveview/LiveviewCoordinator.java
new file mode 100644
index 0000000..4fa298f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/liveview/LiveviewCoordinator.java
@@ -0,0 +1,138 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien
+ Gaignon, Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.liveview;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.liveview.LiveviewSupport;
+
+public class LiveviewCoordinator extends AbstractBLClassicDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("LiveView.*");
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Sony Ericsson";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return LiveviewSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_liveview;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ // nothing to delete, yet
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3Constants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3Constants.java
new file mode 100644
index 0000000..83b793f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3Constants.java
@@ -0,0 +1,338 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Cre3per, Damien Gaignon
+
+ 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.devices.makibeshr3;
+
+import java.util.UUID;
+
+public final class MakibesHR3Constants {
+
+ public static final UUID UUID_SERVICE = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID UUID_CHARACTERISTIC_CONTROL = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID UUID_CHARACTERISTIC_REPORT = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
+
+ // Services and Characteristics
+ // 00001801-0000-1000-8000-00805f9b34fb
+ // 00002a05-0000-1000-8000-00805f9b34fb
+ // 00001800-0000-1000-8000-00805f9b34fb
+ // 00002a00-0000-1000-8000-00805f9b34fb
+ // 00002a01-0000-1000-8000-00805f9b34fb
+ // 00002a02-0000-1000-8000-00805f9b34fb
+ // 00002a04-0000-1000-8000-00805f9b34fb
+ // 00002aa6-0000-1000-8000-00805f9b34fb
+ // 6e400001-b5a3-f393-e0a9-e50e24dcca9e // Nordic UART Service
+ // 6e400002-b5a3-f393-e0a9-e50e24dcca9e // control (RX)
+ // 6e400003-b5a3-f393-e0a9-e50e24dcca9e // report
+ // 0000fee7-0000-1000-8000-00805f9b34fb
+ // 0000fec9-0000-1000-8000-00805f9b34fb
+ // 0000fea1-0000-1000-8000-00805f9b34fb
+ // 0000fea2-0000-1000-8000-00805f9b34fb
+
+ // Command structure
+ // ab 00 [argument_count] ff [command] 80 [arguments]
+ // where [argument_count] is [arguments].length + 3
+ // 80 might by different.
+
+
+ public static final byte[] DATA_TEMPLATE = {
+ (byte) 0xab,
+ (byte) 0x00,
+ (byte) 0, // argument_count
+ (byte) 0xff,
+ (byte) 0, // command
+ (byte) 0x80
+// ,arguments
+ };
+
+ public static final int DATA_ARGUMENT_COUNT_INDEX = 2;
+ public static final int DATA_COMMAND_INDEX = 4;
+ public static final int DATA_ARGUMENTS_INDEX = 6;
+
+ // blood oxygen percentage
+ public static final byte[] RPRT_BLOOD_OXYGEN = new byte[]{ (byte) 0x31, (byte) 0x12 };
+
+
+ // blood oxygen percentage
+ // blood oxygen percentage
+ public static final byte[] RPRT_SINGLE_BLOOD_OXYGEN = new byte[]{ (byte) 0x31, (byte) 0x11 };
+
+
+ // steps might take up more bytes. I don't know which ones and I won't walk that much.
+ // Only sent after we send CMD_51
+ // 00 (maybe also used for steps)
+ // [steps hi]
+ // [steps lo]
+ // 00
+ // 00
+ // 01 (also was 0b. Maybe minutes of activity.)
+ // 00
+ // 00
+ // 00
+ // 00
+ // 00
+ public static final byte[] RPRT_FITNESS = new byte[]{ (byte) 0x51, 0x08 };
+
+
+ // year (+2000)
+ // month
+ // day
+ // hour
+ // minute
+ // heart rate
+ // heart rate
+ public static final byte[] RPRT_HEART_RATE_SAMPLE = new byte[]{ (byte) 0x51, (byte) 0x11 };
+
+
+ // WearFit says "walking" in the step details. This is probably also in here, but
+ // I don't run :O
+ // year (+2000)
+ // month
+ // day
+ // hour (start of measurement. interval is 1h. Might be longer when running.)
+ // 00 (either used for steps or minute)
+ // accumulated steps (hi)
+ // accumulated steps (lo)
+ // 00
+ // 00
+ // ?? (changes whenever steps change. Ranges from 00 to 16.)
+ // 00
+ // 00
+ // 00
+ // 00
+ public static final byte[] RPRT_STEPS_SAMPLE = new byte[]{ (byte) 0x51, (byte) 0x20 };
+
+
+ // enable (00/01)
+ public static final byte RPRT_REVERSE_FIND_DEVICE = (byte) 0x7d;
+
+
+ // The proximity sensor sees air..
+ public static final byte ARG_HEARTRATE_NO_TARGET = (byte) 0xff;
+ // The hr sensor didn't find the heart rate yet.
+ public static final byte ARG_HEARTRATE_NO_READING = (byte) 0x00;
+
+ // heart rate
+ public static final byte RPRT_HEARTRATE = (byte) 0x84;
+
+
+ // charging (00/01)
+ // battery percentage (step size is 20).
+ public static final byte RPRT_BATTERY = (byte) 0x91;
+
+ // firmware_major
+ // firmware_minor
+ // 37
+ // 00
+ // 00
+ // 00
+ // 00
+ // 00
+ // 00
+ // 20
+ // 0e
+ public static final byte RPRT_SOFTWARE = (byte) 0x92;
+
+ // 00
+ public static final byte CMD_FACTORY_RESET = (byte) 0x23;
+
+
+ // enable (00/01)
+ public static final byte[] CMD_SET_REAL_TIME_BLOOD_OXYGEN = new byte[]{ (byte) 0x31, (byte) 0x12 };
+
+
+ // After disabling, the watch replies with RPRT_SINGLE_BLOOD_OXYGEN
+ // enable (00/01)
+ public static final byte[] CMD_SET_SINGLE_BLOOD_OXYGEN = new byte[]{ (byte) 0x31, (byte) 0x11 };
+
+ // device replies with
+ // {@link MakibesHR3Constants#RPRT_HEART_RATE_SAMPLE}
+ // {@link MakibesHR3Constants#RPRT_STEPS_SAMPLE} (Only if steps are non-zero)
+ // {@link MakibesHR3Constants#RPRT_FITNESS}
+ // there are also multiple 6 * 00 reports
+ // 00
+ // year (+2000) steps after
+ // month steps after
+ // day steps after
+ // hour steps after
+ // minute steps after
+ // year (+2000) heart rate after
+ // month heart rate after
+ // day heart rate after
+ // hour heart rate after
+ // minute heart rate after
+ public static final byte CMD_REQUEST_FITNESS = (byte) 0x51;
+
+
+ // Manually sending this doesn't yield a reply. The heart rate history is sent in response to
+ // CMD_CMD_REQUEST_FITNESS.
+ // 00
+ // year (+2000) (probably not current)
+ // month (not current!)
+ // day (not current!)
+ // hour (current)
+ // minute (current)
+ public static final byte CMD_52 = (byte) 0x52;
+
+
+ // vibrates 6 times
+ public static final byte CMD_FIND_DEVICE = (byte) 0x71;
+
+
+ // WearFit writes uses other sources as well. They don't do anything though.
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_CALL = (byte) 0x01;
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_STOP_CALL = (byte) 0x02;
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_MESSAGE = (byte) 0x03;
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_QQ = (byte) 0x07;
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_WECHAT = (byte) 0x09;
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_WHATSAPP = (byte) 0x0a;
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_LINE = (byte) 0x0e;
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_TWITTER = (byte) 0x0f;
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_FACEBOOK = (byte) 0x10;
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_FACEBOOK2 = (byte) 0x11;
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_WEIBO = (byte) 0x13;
+ public static final byte ARG_SEND_NOTIFICATION_SOURCE_KAKOTALK = (byte) 0x14;
+ // ARG_SET_NOTIFICATION_SOURCE_*
+ // 02 (This is 00 and 01 during connection. Doesn't seem to do anything.)
+ // ASCII
+ public static final byte CMD_SEND_NOTIFICATION = (byte) 0x72;
+
+
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_WEEKDAY = (byte) 0x1F;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_EVERY_DAY = (byte) 0x7F;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_ONE_TIME = (byte) 0x80;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_MONDAY = (byte) 0x01;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_TUESDAY = (byte) 0x02;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_WEDNESDAY = (byte) 0x04;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_THURSDAY = (byte) 0x08;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_FRIDAY = (byte) 0x10;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_SATURDAY = (byte) 0x20;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_SUNDAY = (byte) 0x40;
+
+ // reminder id starting at 0
+ // enable (00/01)
+ // hour
+ // minute
+ // bit field of ARG_SET_ALARM_REMINDER_REPEAT_*
+ public static final byte CMD_SET_ALARM_REMINDER = (byte) 0x73;
+
+
+ public static final byte ARG_SET_PERSONAL_INFORMATION_UNIT_DISTANCE_MILES = (byte) 0x00;
+ public static final byte ARG_SET_PERSONAL_INFORMATION_UNIT_DISTANCE_KILOMETERS = (byte) 0x01;
+ // step length (in/cm)
+ // step length (in/cm)
+ // age (years)
+ // height (in/cm)
+ // weight (lb/kg)
+ // ARG_SET_PERSONAL_INFORMATION_UNIT_DISTANCE_*
+ // target step count (kilo)
+ // 5a
+ // 82
+ // 3c
+ // 5a
+ // 28
+ // b4
+ // 5d
+ // 64
+ public static final byte CMD_SET_PERSONAL_INFORMATION = (byte) 0x74;
+
+
+ // enable (00/01)
+ // start hour
+ // start minute
+ // end hour
+ // end minute
+ // 2d
+ public static final byte CMD_SET_SEDENTARY_REMINDER = (byte) 0x75;
+
+
+ // enable (00/01)
+ // start hour
+ // start minute
+ // end hour
+ // end minute
+ public static final byte CMD_SET_QUITE_HOURS = (byte) 0x76;
+
+
+ // enable (00/01)
+ public static final byte CMD_SET_HEADS_UP_SCREEN = (byte) 0x77;
+
+
+ // Looks like enable/disable.
+ public static final byte CMD_78 = (byte) 0x78;
+
+
+ // The watch enters photograph mode, but doesn't appear to send a trigger signal.
+ // enable (00/01)
+ public static final byte CMD_SET_PHOTOGRAPH_MODE = (byte) 0x79;
+
+
+ // enable (00/01)
+ public static final byte CMD_SET_LOST_REMINDER = (byte) 0x7a;
+
+
+ // 7b has 1 argument. Looks like enable/disable.
+
+ public static final byte ARG_SET_TIMEMODE_24H = 0x00;
+ public static final byte ARG_SET_TIMEMODE_12H = 0x01;
+ // ARG_SET_TIMEMODE_*
+ public static final byte CMD_SET_TIMEMODE = (byte) 0x7c;
+
+
+ // 14 arguments. Watch might reply with RPRT_BATTERY.
+ public static final byte CMD_7e = (byte) 0x7e;
+
+
+ // 01
+ // fall hour
+ // fall minute
+ // awake hour
+ // awake minute
+ public static final byte CMD_SET_SLEEP_TIME = (byte) 0x7f;
+
+
+ // enable (00/01)
+ public static final byte CMD_SET_REAL_TIME_HEART_RATE = (byte) 0x84;
+
+
+ // looks like enable/disable.
+ public static final byte CMD_85 = (byte) 0x85;
+
+
+ // 00
+ // year hi
+ // year lo
+ // month
+ // day
+ // hour
+ // minute
+ // second
+ public static final byte CMD_SET_DATE_TIME = (byte) 0x93;
+
+ // 3 arguments. Sent when saving personal information.
+ public static final byte CMD_95 = (byte) 0x95;
+
+ // looks like enable/disable.
+ public static final byte CMD_96 = (byte) 0x96;
+
+
+ // looks like enable/disable.
+ public static final byte CMD_e5 = (byte) 0xe5;
+
+
+ // If this is sent after {@link CMD_FACTORY_RESET}, it's a shutdown, not a reboot.
+ // Rebooting resets the watch face and wallpaper.
+ public static final byte CMD_REBOOT = (byte) 0xff;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3Coordinator.java
new file mode 100644
index 0000000..bcfeb5f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3Coordinator.java
@@ -0,0 +1,255 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Cre3per, Damien Gaignon,
+ Daniel Dakhno, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.makibeshr3;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.MakibesHR3ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.makibeshr3.MakibesHR3DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+import static nodomain.freeyourgadget.gadgetbridge.GBApplication.getContext;
+
+
+public class MakibesHR3Coordinator extends AbstractBLEDeviceCoordinator {
+
+ public static final int FindPhone_ON = -1;
+ public static final int FindPhone_OFF = 0;
+
+ private static final Logger LOG = LoggerFactory.getLogger(MakibesHR3Coordinator.class);
+
+
+ public static boolean shouldEnableHeadsUpScreen(SharedPreferences sharedPrefs) {
+ String liftMode = sharedPrefs.getString(DeviceSettingsPreferenceConst.PREF_ACTIVATE_DISPLAY_ON_LIFT, getContext().getString(R.string.p_on));
+
+ // Makibes HR3 doesn't support scheduled intervals. Treat it as "on".
+ return !liftMode.equals(getContext().getString(R.string.p_off));
+ }
+
+ public static boolean shouldEnableLostReminder(SharedPreferences sharedPrefs) {
+ String lostReminder = sharedPrefs.getString(DeviceSettingsPreferenceConst.PREF_DISCONNECT_NOTIFICATION, getContext().getString(R.string.p_on));
+
+ // Makibes HR3 doesn't support scheduled intervals. Treat it as "on".
+ return !lostReminder.equals(getContext().getString(R.string.p_off));
+ }
+
+ /**
+ * @param startOut out Only hour/minute are used.
+ * @param endOut out Only hour/minute are used.
+ * @return True if quite hours are enabled.
+ */
+ public static boolean getQuiteHours(SharedPreferences sharedPrefs, Calendar startOut, Calendar endOut) {
+ String doNotDisturb = sharedPrefs.getString(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO, getContext().getString(R.string.p_off));
+
+ if (doNotDisturb.equals(getContext().getString(R.string.p_off))) {
+ return false;
+ } else {
+ String start = sharedPrefs.getString(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO_START, "00:00");
+ String end = sharedPrefs.getString(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_NOAUTO_END, "00:00");
+
+ DateFormat df = new SimpleDateFormat("HH:mm");
+
+ try {
+ startOut.setTime(df.parse(start));
+ endOut.setTime(df.parse(end));
+
+ return true;
+ } catch (Exception e) {
+ LOG.error("Unexpected exception in MiBand2Coordinator.getTime: " + e.getMessage());
+ return false;
+ }
+ }
+ }
+
+ /**
+ * @return {@link #FindPhone_OFF}, {@link #FindPhone_ON}, or the duration
+ */
+ public static int getFindPhone(SharedPreferences sharedPrefs) {
+ String findPhone = sharedPrefs.getString(DeviceSettingsPreferenceConst.PREF_FIND_PHONE, getContext().getString(R.string.p_off));
+
+ if (findPhone.equals(getContext().getString(R.string.p_off))) {
+ return FindPhone_OFF;
+ } else if (findPhone.equals(getContext().getString(R.string.p_on))) {
+ return FindPhone_ON;
+ } else { // Duration
+ String duration = sharedPrefs.getString(DeviceSettingsPreferenceConst.PREF_FIND_PHONE_DURATION, "0");
+
+ try {
+ int iDuration;
+
+ try {
+ iDuration = Integer.valueOf(duration);
+ } catch (Exception ex) {
+ LOG.warn(ex.getMessage());
+ iDuration = 60;
+ }
+
+ return iDuration;
+ } catch (Exception e) {
+ LOG.error("Unexpected exception in MiBand2Coordinator.getTime: " + e.getMessage());
+ return FindPhone_ON;
+ }
+ }
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Y808|MAKIBES HR3");
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getMakibesHR3ActivitySampleDao().queryBuilder();
+ qb.where(MakibesHR3ActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_BOND;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new MakibesHR3SampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 8;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Makibes";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_timeformat,
+ R.xml.devicesettings_liftwrist_display,
+ R.xml.devicesettings_disconnectnotification,
+ R.xml.devicesettings_donotdisturb_no_auto,
+ R.xml.devicesettings_find_phone,
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MakibesHR3DeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_makibes_hr3;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3SampleProvider.java
new file mode 100644
index 0000000..9a2dbc7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/makibeshr3/MakibesHR3SampleProvider.java
@@ -0,0 +1,78 @@
+/* Copyright (C) 2019-2024 Cre3per
+
+ 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.devices.makibeshr3;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.MakibesHR3ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.MakibesHR3ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class MakibesHR3SampleProvider extends AbstractSampleProvider<MakibesHR3ActivitySample> {
+ public MakibesHR3SampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity;
+ }
+
+ @Override
+ public MakibesHR3ActivitySample createActivitySample() {
+ return new MakibesHR3ActivitySample();
+ }
+
+ @Override
+ public AbstractDao<MakibesHR3ActivitySample, ?> getSampleDao() {
+ return getSession().getMakibesHR3ActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return MakibesHR3ActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return MakibesHR3ActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return MakibesHR3ActivitySampleDao.Properties.DeviceId;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/marstek/MarstekB2500DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/marstek/MarstekB2500DeviceCoordinator.java
new file mode 100644
index 0000000..c08919a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/marstek/MarstekB2500DeviceCoordinator.java
@@ -0,0 +1,102 @@
+/* Copyright (C) 2024 Andreas Shimokawa
+
+ 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.devices.marstek;
+
+
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractDeviceCoordinator;
+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.marstek.MarstekB2500DeviceSupport;
+
+
+public class MarstekB2500DeviceCoordinator extends AbstractDeviceCoordinator {
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_marstek_b2500;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_vesc;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_vesc_disabled;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Marstek";
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MarstekB2500DeviceSupport.class;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("HM_B2500_.*");
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return SolarEquipmentStatusActivity.class;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_battery_allow_pass_through,
+ R.xml.devicesettings_battery_minimum_charge,
+ R.xml.devicesettings_battery_discharge_5
+ };
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/marstek/SolarEquipmentStatusActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/marstek/SolarEquipmentStatusActivity.java
new file mode 100644
index 0000000..0816469
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/marstek/SolarEquipmentStatusActivity.java
@@ -0,0 +1,190 @@
+/* Copyright (C) 2024 Andreas Shimokawa
+
+ 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.devices.marstek;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.core.content.ContextCompat;
+import androidx.gridlayout.widget.GridLayout;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+
+import com.google.android.material.card.MaterialCardView;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.GaugeDrawer;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class SolarEquipmentStatusActivity extends AbstractGBActivity {
+ public static String ACTION_SEND_SOLAR_EQUIPMENT_STATUS = "send_solar_equipment_status";
+ public static String EXTRA_BATTERY_PCT = "battery_pct";
+ public static String EXTRA_PANEL1_WATT = "panel1_watt";
+ public static String EXTRA_PANEL2_WATT = "panel2_watt";
+ public static String EXTRA_OUTPUT1_WATT = "output1_watt";
+ public static String EXTRA_OUTPUT2_WATT = "output2_watt";
+
+ private final Map<String, View> widgetMap = new HashMap<>();
+ private GridLayout gridLayout;
+ private SwipeRefreshLayout swipeLayout;
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (Objects.requireNonNull(action).equals(ACTION_SEND_SOLAR_EQUIPMENT_STATUS)) {
+ Bundle extras = intent.getExtras();
+ if (extras != null) {
+ int battery_pct = extras.getInt(EXTRA_BATTERY_PCT);
+ int panel1_watt = extras.getInt(EXTRA_PANEL1_WATT);
+ int panel2_watt = extras.getInt(EXTRA_PANEL2_WATT);
+ int output1_watt = extras.getInt(EXTRA_OUTPUT1_WATT);
+ int output2_watt = extras.getInt(EXTRA_OUTPUT2_WATT);
+ updateWidget("battery", battery_pct + "%", (float) (battery_pct / 100.0));
+ updateWidget("panel1", panel1_watt + "W", (float) (panel1_watt / 380.0));
+ updateWidget("panel2", panel2_watt + "W", (float) (panel1_watt / 380.0));
+ updateWidget("output1", output1_watt + "W", (float) (output1_watt / 400.0));
+ updateWidget("output2", output2_watt + "W", (float) (output1_watt / 400.0));
+
+ swipeLayout.setRefreshing(false);
+ }
+ }
+ }
+ };
+ private GBDevice gBDevice = null;
+
+ public static int[] getColors() {
+ return new int[]{
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_stress_unknown),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_poor_color),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_fair_color),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_good_color),
+ };
+ }
+
+ public static int[] getColorsOutput() {
+ return new int[]{
+ ContextCompat.getColor(GBApplication.getContext(), R.color.chart_stress_unknown),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_good_color),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_fair_color),
+ ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_poor_color),
+ };
+ }
+
+ public static float[] getSegments() {
+ return new float[]{
+ 0.01f,
+ 0.09f,
+ 0.2f,
+ 0.7f,
+ };
+ }
+
+ public static float[] getSegmentsOutput() {
+ return new float[]{
+ 0.01f,
+ 0.33f,
+ 0.33f,
+ 0.33f,
+ };
+ }
+
+ private void updateWidget(String name, String value, float gaugeFill) {
+ View view = widgetMap.get(name);
+ if (view != null) {
+ TextView gaugeValue = view.findViewById(R.id.gauge_value);
+ gaugeValue.setText(value);
+ GaugeDrawer gaugeDrawer = new GaugeDrawer();
+ ImageView gaugeBar = view.findViewById(R.id.gauge_bar);
+ gaugeDrawer.drawSegmentedGauge(gaugeBar, name.startsWith("output") ? getColorsOutput() : getColors(), name.startsWith("output") ? getSegmentsOutput() : getSegments(), gaugeFill, true, false);
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ gBDevice = extras.getParcelable(GBDevice.EXTRA_DEVICE);
+ }
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_solar_equipment_status);
+ gridLayout = findViewById(R.id.solarequipmentview_gridlayout);
+ createWidget("panel1", "Panel 1", 1);
+ createWidget("panel2", "Panel 2", 1);
+ createWidget("battery", "Battery", 2);
+ createWidget("output1", "Output 1", 1);
+ createWidget("output2", "Output 2", 1);
+
+ // Set pull-down-to-refresh action
+ swipeLayout = findViewById(R.id.solarequipmentview_swipe_layout);
+ swipeLayout.setOnRefreshListener(() -> {
+ if (gBDevice == null || !gBDevice.isInitialized()) {
+ swipeLayout.setRefreshing(false);
+ GB.toast(getString(R.string.info_no_devices_connected), Toast.LENGTH_LONG, GB.WARN);
+ return;
+ }
+ GBApplication.deviceService(gBDevice).onFetchRecordedData(0);
+ });
+
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(ACTION_SEND_SOLAR_EQUIPMENT_STATUS);
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filterLocal);
+ }
+
+ private void createWidget(String name, String label, int columnSpan) {
+ final float scale = getResources().getDisplayMetrics().density;
+
+ GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(
+ GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f),
+ GridLayout.spec(GridLayout.UNDEFINED, columnSpan, GridLayout.FILL, 1f)
+ );
+ layoutParams.width = 0;
+ int pixels_8dp = (int) (8 * scale + 0.5f);
+ layoutParams.setMargins(pixels_8dp, pixels_8dp, pixels_8dp, pixels_8dp);
+
+ MaterialCardView card = new MaterialCardView(this);
+ int pixels_4dp = (int) (4 * scale + 0.5f);
+ card.setRadius(pixels_4dp);
+ card.setCardElevation(pixels_4dp);
+ card.setContentPadding(pixels_4dp, pixels_4dp, pixels_4dp, pixels_4dp);
+ card.setLayoutParams(layoutParams);
+ LayoutInflater inflater = getLayoutInflater();
+
+ final View gaugeView = inflater.inflate(R.layout.dashboard_widget_generic_gauge, card, false);
+ final TextView gaugeLabel = gaugeView.findViewById(R.id.gauge_label);
+ gaugeLabel.setText(label);
+
+ card.addView(gaugeView);
+ widgetMap.put(name, gaugeView);
+ gridLayout.addView(card);
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWHelper.java
new file mode 100644
index 0000000..f01e4b3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWHelper.java
@@ -0,0 +1,155 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Jos茅 Rebelo, Taavi Eom盲e
+
+ 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.devices.miband;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareType;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+
+/**
+ * Also see Mi1SFirmwareInfo.
+ */
+public abstract class AbstractMiBandFWHelper {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractMiBandFWHelper.class);
+
+ private byte[] fw;
+
+ public AbstractMiBandFWHelper(Uri uri, Context context) throws IOException {
+ UriHelper uriHelper = UriHelper.get(uri, context);
+ String pebblePattern = ".*\\.(pbw|pbz|pbl)";
+ if (uriHelper.getFileName().matches(pebblePattern)) {
+ throw new IOException("Firmware has a filename that looks like a Pebble app/firmware.");
+ }
+
+ if (uriHelper.getFileSize() > getMaxExpectedFileSize()) {
+ throw new IOException("Firmware size is larger than the maximum expected file size of " + getMaxExpectedFileSize());
+ }
+
+ try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ this.fw = FileUtils.readAll(in, getMaxExpectedFileSize());
+ determineFirmwareInfo(fw);
+ } catch (IOException ex) {
+ throw ex; // pass through
+ } catch (IllegalArgumentException ex) {
+ throw new IOException("This doesn't seem to be a Mi Band firmware: " + ex.getLocalizedMessage(), ex);
+ } catch (Exception e) {
+ throw new IOException("Error reading firmware file: " + uri.toString(), e);
+ }
+ }
+
+ /**
+ * Returns a localized, user-visible representation of the kind of firmware to be installed.
+ */
+ @NonNull
+ public abstract String getFirmwareKind();
+
+ public abstract int getFirmwareVersion();
+
+ public abstract int getFirmware2Version();
+
+ public static String formatFirmwareVersion(int version) {
+ if (version == -1) {
+ return GBApplication.getContext().getString(R.string._unknown_);
+ }
+
+ return String.format(Locale.UK,
+ "%d.%d.%d.%d",
+ version >> 24 & 255,
+ version >> 16 & 255,
+ version >> 8 & 255,
+ version & 255);
+ }
+
+ public String getHumanFirmwareVersion() {
+ return format(getFirmwareVersion());
+ }
+
+ public abstract String getHumanFirmwareVersion2();
+
+ public String format(int version) {
+ return formatFirmwareVersion(version);
+ }
+
+ @NonNull
+ public byte[] getFw() {
+ if (fw == null) {
+ throw new IllegalStateException("fw is null");
+ }
+
+ return fw;
+ }
+
+ public void unsetFwBytes() {
+ this.fw = null;
+ }
+
+ public boolean isFirmwareWhitelisted() {
+ for (int wlf : getWhitelistedFirmwareVersions()) {
+ if (wlf == getFirmwareVersion()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * The maximum expected file size, in bytes. Files larger than this are assumed to be invalid.
+ */
+ public long getMaxExpectedFileSize() {
+ return 1024 * 1024 * 16; // 16.0MB
+ }
+
+ protected abstract int[] getWhitelistedFirmwareVersions();
+
+ public abstract boolean isFirmwareGenerallyCompatibleWith(GBDevice device);
+
+ public abstract boolean isSingleFirmware();
+
+ /**
+ * @param wholeFirmwareBytes
+ * @return
+ * @throws IllegalArgumentException when the data is not recognized as firmware data
+ */
+ @NonNull
+ protected abstract void determineFirmwareInfo(byte[] wholeFirmwareBytes);
+
+ public abstract void checkValid() throws IllegalArgumentException;
+
+ public abstract HuamiFirmwareType getFirmwareType();
+
+ public Bitmap getPreview() {
+ return null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWInstallHandler.java
new file mode 100644
index 0000000..d27b4f8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWInstallHandler.java
@@ -0,0 +1,158 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
+ Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.miband;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareType.AGPS_UIHH;
+import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareType.WATCHFACE;
+
+public abstract class AbstractMiBandFWInstallHandler implements InstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractMiBandFWInstallHandler.class);
+
+ protected final Uri mUri;
+ protected final Context mContext;
+ protected AbstractMiBandFWHelper helper;
+ private String errorMessage;
+
+ public AbstractMiBandFWInstallHandler(Uri uri, Context context) {
+ mUri = uri;
+ mContext = context;
+
+ try {
+ helper = createHelper(uri, context);
+ } catch (IOException e) {
+ errorMessage = e.getMessage();
+ LOG.warn(errorMessage, e);
+ }
+ }
+
+ public Uri getUri() {
+ return mUri;
+ }
+
+ public Context getContext() {
+ return mContext;
+ }
+
+ public AbstractMiBandFWHelper getHelper() {
+ return helper;
+ }
+
+ protected abstract AbstractMiBandFWHelper createHelper(Uri uri, Context context) throws IOException;
+
+ private GenericItem createInstallItem(GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return new GenericItem(mContext.getString(R.string.installhandler_firmware_name, mContext.getString(coordinator.getDeviceNameResource()), helper.getFirmwareKind(), helper.getHumanFirmwareVersion()));
+ }
+
+ protected String getFwUpgradeNotice() {
+ return mContext.getString(R.string.fw_upgrade_notice, helper.getHumanFirmwareVersion());
+ }
+
+ @Override
+ public void validateInstallation(InstallActivity installActivity, GBDevice device) {
+ if (device.isBusy()) {
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!isSupportedDeviceType(device)) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ } else if (!device.isInitialized()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ try {
+ helper.checkValid();
+ } catch (IllegalArgumentException ex) {
+ installActivity.setInfoText(ex.getLocalizedMessage());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+
+ GenericItem fwItem = createInstallItem(device);
+ fwItem.setIcon(coordinator.getDefaultIconResource());
+
+ if (!helper.isFirmwareGenerallyCompatibleWith(device)) {
+ fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_incompatible_version));
+ installActivity.setInfoText(mContext.getString(R.string.fwinstaller_firmware_not_compatible_to_device));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ StringBuilder builder = new StringBuilder();
+ if (!helper.getFirmwareType().isWatchface() && !helper.getFirmwareType().isApp() && helper.getFirmwareType() != AGPS_UIHH) {
+ if (helper.isSingleFirmware()) {
+ builder.append(getFwUpgradeNotice());
+ } else {
+ builder.append(mContext.getString(R.string.fw_multi_upgrade_notice, helper.getHumanFirmwareVersion(), helper.getHumanFirmwareVersion2()));
+ }
+
+
+ if (helper.isFirmwareWhitelisted()) {
+ builder.append(" ").append(mContext.getString(R.string.miband_firmware_known));
+ fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_compatible_version));
+ // TODO: set a CHECK (OKAY) button
+ } else {
+ builder.append(" ").append(mContext.getString(R.string.miband_firmware_unknown_warning)).append(" \n\n")
+ .append(mContext.getString(R.string.miband_firmware_suggest_whitelist, String.valueOf(helper.getFirmwareVersion())));
+ fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_untested_version));
+ // TODO: set a UNKNOWN (question mark) button
+ }
+ }
+
+ installActivity.setPreview(helper.getPreview());
+
+ installActivity.setInfoText(builder.toString());
+ installActivity.setInstallItem(fwItem);
+ installActivity.setInstallEnabled(true);
+ }
+
+ protected abstract boolean isSupportedDeviceType(GBDevice device);
+
+ @Override
+ public void onStartInstall(GBDevice device) {
+
+ }
+
+ @Override
+ public boolean isValid() {
+ return helper != null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandSampleProvider.java
new file mode 100644
index 0000000..896472c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandSampleProvider.java
@@ -0,0 +1,73 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti
+
+ 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.devices.miband;
+
+import androidx.annotation.NonNull;
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.MiBandActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.MiBandActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+/**
+ * Base class for Mi1 and Mi2 sample providers. At the moment they both share the
+ * same activity sample class.
+ */
+public abstract class AbstractMiBandSampleProvider extends AbstractSampleProvider<MiBandActivitySample> {
+
+ // maybe this should be configurable 256 seems way off, though.
+ private final float movementDivisor = 180.0f; //256.0f;
+
+ public AbstractMiBandSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / movementDivisor;
+ }
+
+ @Override
+ public AbstractDao<MiBandActivitySample, ?> getSampleDao() {
+ return getSession().getMiBandActivitySampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return MiBandActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return MiBandActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return MiBandActivitySampleDao.Properties.RawKind;
+ }
+
+ @Override
+ public MiBandActivitySample createActivitySample() {
+ return new MiBandActivitySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DateTimeDisplay.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DateTimeDisplay.java
new file mode 100644
index 0000000..9e34fdd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DateTimeDisplay.java
@@ -0,0 +1,22 @@
+/* Copyright (C) 2016-2024 Carsten Pfeiffer
+
+ 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.devices.miband;
+
+public enum DateTimeDisplay {
+ TIME,
+ DATE_TIME
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DoNotDisturb.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DoNotDisturb.java
new file mode 100644
index 0000000..ebd8bc9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DoNotDisturb.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2017-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.miband;
+
+public enum DoNotDisturb {
+ OFF,
+ AUTOMATIC,
+ SCHEDULED,
+ ALWAYS,
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBand2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBand2SampleProvider.java
new file mode 100644
index 0000000..9b5238e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBand2SampleProvider.java
@@ -0,0 +1,101 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti
+
+ 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.devices.miband;
+
+import java.util.List;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.MiBandActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.MiBandActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.TYPE_IGNORE;
+import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.TYPE_NO_CHANGE;
+import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.TYPE_UNSET;
+
+public class MiBand2SampleProvider extends AbstractMiBandSampleProvider {
+
+ public MiBand2SampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ protected List<MiBandActivitySample> getGBActivitySamples(int timestamp_from, int timestamp_to) {
+ List<MiBandActivitySample> samples = super.getGBActivitySamples(timestamp_from, timestamp_to);
+ postprocess(samples);
+ return samples;
+ }
+
+ /**
+ * "Temporary" runtime post processing of activity kinds.
+ */
+ private void postprocess(List<MiBandActivitySample> samples) {
+ if (samples.isEmpty()) {
+ return;
+ }
+
+ int lastValidKind = determinePreviousValidActivityType(samples.get(0));
+ for (MiBandActivitySample sample : samples) {
+ int rawKind = sample.getRawKind();
+ if (rawKind != TYPE_UNSET) {
+ rawKind &= 0xf;
+ sample.setRawKind(rawKind);
+ }
+
+ switch (rawKind) {
+ case TYPE_IGNORE:
+ case TYPE_NO_CHANGE:
+ if (lastValidKind != TYPE_UNSET) {
+ sample.setRawKind(lastValidKind);
+ }
+ break;
+ default:
+ lastValidKind = rawKind;
+ break;
+ }
+ }
+ }
+
+ private int determinePreviousValidActivityType(MiBandActivitySample sample) {
+ QueryBuilder<MiBandActivitySample> qb = getSampleDao().queryBuilder();
+ qb.where(MiBandActivitySampleDao.Properties.DeviceId.eq(sample.getDeviceId()),
+ MiBandActivitySampleDao.Properties.UserId.eq(sample.getUserId()),
+ MiBandActivitySampleDao.Properties.Timestamp.lt(sample.getTimestamp()),
+ MiBandActivitySampleDao.Properties.RawKind.notIn(TYPE_NO_CHANGE, TYPE_IGNORE, TYPE_UNSET, 16, 80, 96, 112)); // all I ever had that are 0 when doing &=0xf
+ qb.orderDesc(MiBandActivitySampleDao.Properties.Timestamp);
+ qb.limit(1);
+ List<MiBandActivitySample> result = qb.build().list();
+ if (!result.isEmpty()) {
+ return result.get(0).getRawKind() & 0xf;
+ }
+ return TYPE_UNSET;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return HuamiConst.toActivityKind(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return HuamiConst.toRawActivityType(activityKind);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandConst.java
new file mode 100644
index 0000000..fd21bd2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandConst.java
@@ -0,0 +1,95 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Christian
+ Fischer, Damien Gaignon, Daniele Gobbetti, Jos茅 Rebelo, Michal Novotny,
+ Szymon Tomasz Stefanek
+
+ 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.devices.miband;
+
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import nodomain.freeyourgadget.gadgetbridge.util.Version;
+
+public final class MiBandConst {
+ public static final String PREF_MIBAND_ALARMS = "mi_alarms";
+ public static final String PREF_MIBAND_DEVICE_TIME_OFFSET_HOURS = "device_time_offset_hours";
+ public static final String PREF_MI2_DATEFORMAT = "mi2_dateformat";
+ public static final String PREF_MI2_DISPLAY_ITEM_CLOCK = "clock";
+ public static final String PREF_MI2_DISPLAY_ITEM_STEPS = "steps";
+ public static final String PREF_MI2_DISPLAY_ITEM_DISTANCE = "distance";
+ public static final String PREF_MI2_DISPLAY_ITEM_CALORIES = "calories";
+ public static final String PREF_MI2_DISPLAY_ITEM_HEART_RATE = "heart_rate";
+ public static final String PREF_MI2_DISPLAY_ITEM_BATTERY = "battery";
+ public static final String PREF_MI2_ROTATE_WRIST_TO_SWITCH_INFO = "rotate_wrist_to_cycle_info";
+ public static final String PREF_MI2_ENABLE_TEXT_NOTIFICATIONS = "mi2_enable_text_notifications";
+
+ public static final String PREF_SWIPE_UNLOCK = "swipe_unlock";
+ public static final String PREF_NIGHT_MODE = "night_mode";
+ public static final String PREF_NIGHT_MODE_START = "night_mode_start";
+ public static final String PREF_NIGHT_MODE_END = "night_mode_end";
+ public static final String PREF_NIGHT_MODE_OFF = "off";
+ public static final String PREF_NIGHT_MODE_SUNSET = "sunset";
+ public static final String PREF_NIGHT_MODE_SCHEDULED = "scheduled";
+
+ public static final String ORIGIN_INCOMING_CALL = "incoming_call";
+ public static final String ORIGIN_ALARM_CLOCK = "alarm_clock";
+ public static final String MI_GENERAL_NAME_PREFIX = "MI";
+ public static final String MI_1 = "1";
+ public static final String MI_1A = "1A";
+ public static final String MI_1S = "1S";
+ public static final String MI_AMAZFIT = "Amazfit";
+ public static final String MI_PRO = "2";
+
+ public static final Version MI2_FW_VERSION_MIN_TEXT_NOTIFICATIONS = new Version("1.0.1.28");
+ public static final Version MI2_FW_VERSION_INTERMEDIATE_UPGRADE_53 = new Version("1.0.0.53");
+
+ public enum DistanceUnit {
+ METRIC,
+ IMPERIAL
+ }
+
+ public static int getNotificationPrefIntValue(String pref, String origin, Prefs prefs, int defaultValue) {
+ String key = getNotificationPrefKey(pref, origin);
+ return prefs.getInt(key, defaultValue);
+ }
+
+ public static String getNotificationPrefStringValue(String pref, String origin, Prefs prefs, String defaultValue) {
+ String key = getNotificationPrefKey(pref, origin);
+ return prefs.getString(key, defaultValue);
+ }
+
+ public static String getNotificationPrefKey(String pref, String origin) {
+ return pref + '_' + origin;
+ }
+
+ public static final String VIBRATION_PROFILE = "mi_vibration_profile";
+ public static final String VIBRATION_COUNT = "mi_vibration_count";
+ public static final String VIBRATION_DURATION = "mi_vibration_duration";
+ public static final String VIBRATION_PAUSE = "mi_vibration_pause";
+ public static final String FLASH_COUNT = "mi_flash_count";
+ public static final String FLASH_DURATION = "mi_flash_duration";
+ public static final String FLASH_PAUSE = "mi_flash_pause";
+ public static final String FLASH_COLOUR = "mi_flash_colour";
+ public static final String FLASH_ORIGINAL_COLOUR = "mi_flash_original_colour";
+
+ public static final String DEFAULT_VALUE_VIBRATION_PROFILE = "short";
+ public static final int DEFAULT_VALUE_VIBRATION_COUNT = 3;
+ public static final int DEFAULT_VALUE_VIBRATION_DURATION = 500; // ms
+ public static final int DEFAULT_VALUE_VIBRATION_PAUSE = 500; // ms
+ public static final int DEFAULT_VALUE_FLASH_COUNT = 10; // ms
+ public static final int DEFAULT_VALUE_FLASH_DURATION = 500; // ms
+ public static final int DEFAULT_VALUE_FLASH_PAUSE = 500; // ms
+ public static final int DEFAULT_VALUE_FLASH_COLOUR = 1; // TODO: colour!
+ public static final int DEFAULT_VALUE_FLASH_ORIGINAL_COLOUR = 1; // TODO: colour!
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandCoordinator.java
new file mode 100644
index 0000000..e6719c9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandCoordinator.java
@@ -0,0 +1,309 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Christian
+ Fischer, Damien Gaignon, Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo,
+ Petr Van臎k, Szymon Tomasz Stefanek
+
+ 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.devices.miband;
+
+import android.app.Activity;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.MiBandActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.MiBandSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_NAME;
+
+public class MiBandCoordinator extends AbstractBLEDeviceCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(MiBandCoordinator.class);
+
+ public MiBandCoordinator() {
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid mi1Service = new ParcelUuid(MiBandService.UUID_SERVICE_MIBAND_SERVICE);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(mi1Service).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ String macAddress = candidate.getMacAddress().toUpperCase();
+ if (macAddress.startsWith(MiBandService.MAC_ADDRESS_FILTER_1_1A)
+ || macAddress.startsWith(MiBandService.MAC_ADDRESS_FILTER_1S)) {
+ return true;
+ }
+ if (candidate.supportsService(MiBandService.UUID_SERVICE_MIBAND_SERVICE)
+ && !candidate.supportsService(MiBandService.UUID_SERVICE_MIBAND2_SERVICE)) {
+ return true;
+ }
+ // and a heuristic
+ try {
+ BluetoothDevice device = candidate.getDevice();
+ if (isHealthWearable(device)) {
+ String name = candidate.getName();
+ if (name != null && name.toUpperCase().startsWith(MiBandConst.MI_GENERAL_NAME_PREFIX.toUpperCase())) {
+ return true;
+ }
+ }
+ } catch (Exception ex) {
+ LOG.error("unable to check device support", ex);
+ }
+ return false;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getMiBandActivitySampleDao().queryBuilder();
+ qb.where(MiBandActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return MiBandPairingActivity.class;
+ }
+
+ @Override
+ public SampleProvider<? extends AbstractActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new MiBandSampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ MiBandFWInstallHandler handler = new MiBandFWInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 3;
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(GBDevice device, int position) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Xiaomi";
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ public static boolean hasValidUserInfo() {
+ String dummyMacAddress = MiBandService.MAC_ADDRESS_FILTER_1_1A + ":00:00:00";
+ try {
+ UserInfo userInfo = getConfiguredUserInfo(dummyMacAddress);
+ return true;
+ } catch (IllegalArgumentException ex) {
+ return false;
+ }
+ }
+
+ /**
+ * Returns the configured user info, or, if that is not available or invalid,
+ * a default user info.
+ *
+ * @param miBandAddress
+ */
+ public static UserInfo getAnyUserInfo(String miBandAddress) {
+ try {
+ return getConfiguredUserInfo(miBandAddress);
+ } catch (Exception ex) {
+ LOG.error("Error creating user info from settings, using default user instead: " + ex);
+ return UserInfo.getDefault(miBandAddress);
+ }
+ }
+
+ /**
+ * Returns the user info from the user configured data in the preferences.
+ *
+ * @param miBandAddress
+ * @throws IllegalArgumentException when the user info can not be created
+ */
+ public static UserInfo getConfiguredUserInfo(String miBandAddress) throws IllegalArgumentException {
+ ActivityUser activityUser = new ActivityUser();
+ Prefs prefs = GBApplication.getPrefs();
+
+ UserInfo info = UserInfo.create(
+ miBandAddress,
+ prefs.getString(PREF_USER_NAME, null),
+ activityUser.getGender(),
+ activityUser.getAge(),
+ activityUser.getHeightCm(),
+ activityUser.getWeightKg(),
+ 0
+ );
+ return info;
+ }
+
+ public static int getWearLocation(String deviceAddress) throws IllegalArgumentException {
+ int location = 0; //left hand
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ if ("right".equals(prefs.getString(DeviceSettingsPreferenceConst.PREF_WEARLOCATION, "left"))) {
+ location = 1; // right hand
+ }
+ return location;
+ }
+
+ public static int getDeviceTimeOffsetHours(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getInt(MiBandConst.PREF_MIBAND_DEVICE_TIME_OFFSET_HOURS, 0);
+ }
+
+ public static boolean getHeartrateSleepSupport(String deviceAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress));
+ return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION, false);
+ }
+
+ public static int getReservedAlarmSlots(String miBandAddress) throws IllegalArgumentException {
+ Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(miBandAddress));
+ return prefs.getInt(DeviceSettingsPreferenceConst.PREF_RESERVER_ALARMS_CALENDAR, 0);
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ String hwVersion = device.getModel();
+ return isMi1S(hwVersion) || isMiPro(hwVersion);
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_wearlocation);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_heartrate_sleep);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_miband_vibrationpatterns);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_reserve_alarms_calendar);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_fake_timeoffset);
+
+ final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
+ connection.add(R.xml.devicesettings_lowlatency_fwupdate);
+
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_keep_activity_data_on_device);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new MiBandSettingsCustomizer(device);
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MiBandSupport.class;
+ }
+
+ private boolean isMi1S(String hardwareVersion) {
+ return MiBandConst.MI_1S.equals(hardwareVersion);
+ }
+
+ private boolean isMiPro(String hardwareVersion) {
+ return MiBandConst.MI_PRO.equals(hardwareVersion);
+ }
+
+ @Override
+ public EnumSet<ServiceDeviceSupport.Flags> getInitialFlags() {
+ return EnumSet.of(ServiceDeviceSupport.Flags.THROTTLING, ServiceDeviceSupport.Flags.BUSY_CHECKING);
+ }
+
+ @Override
+ public int getOrderPriority(){
+ // all Coordinators have a priority of 0, thus get checked before this one
+ // PLEASE DO NOT EXTEND THE PRIORITIES
+ // PLEASE BUILD NEW COORDINATORS ORDER INDEPENDENT
+ // this ordering mechanism is a temporary hack and will hopefully be gone soon...
+ return 1;
+ }
+
+ @Override
+ @StringRes
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband;
+ }
+
+ @Override
+ @DrawableRes
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband;
+ }
+
+ @Override
+ @DrawableRes
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandDateConverter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandDateConverter.java
new file mode 100644
index 0000000..e46275d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandDateConverter.java
@@ -0,0 +1,98 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Szymon Tomasz Stefanek
+
+ 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.devices.miband;
+
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+
+public class MiBandDateConverter {
+ /**
+ * Creates a calendar object representing the current date and time.
+ */
+ public static GregorianCalendar createCalendar() {
+ return new GregorianCalendar();
+ }
+
+ /**
+ * uses the standard algorithm to convert bytes received from the MiBand to a Calendar object
+ *
+ * @param value
+ * @return
+ */
+ public static GregorianCalendar rawBytesToCalendar(byte[] value, String deviceAddress) {
+ if (value.length == 6) {
+ return rawBytesToCalendar(value, 0, deviceAddress);
+ }
+ return createCalendar();
+ }
+
+ /**
+ * uses the standard algorithm to convert bytes received from the MiBand to a Calendar object
+ *
+ * @param value
+ * @return
+ */
+ public static GregorianCalendar rawBytesToCalendar(byte[] value, int offset, String deviceAddress) {
+ if (value.length - offset >= 6) {
+ GregorianCalendar timestamp = new GregorianCalendar(
+ value[offset] + 2000,
+ value[offset + 1],
+ value[offset + 2],
+ value[offset + 3],
+ value[offset + 4],
+ value[offset + 5]);
+
+ int offsetInHours = MiBandCoordinator.getDeviceTimeOffsetHours(deviceAddress);
+ if (offsetInHours != 0)
+ timestamp.add(Calendar.HOUR_OF_DAY, -offsetInHours);
+
+ return timestamp;
+ }
+
+ return createCalendar();
+ }
+
+ /**
+ * uses the standard algorithm to convert a Calendar object to a byte array to send to MiBand
+ *
+ * @param timestamp
+ * @return
+ */
+ public static byte[] calendarToRawBytes(Calendar timestamp, String deviceAddress) {
+
+ // The mi-band device currently records sleep
+ // only if it happens after 10pm and before 7am.
+ // The offset is used to trick the device to record sleep
+ // in non-standard hours.
+ // If you usually sleep, say, from 6am to 2pm, set the
+ // shift to -8, so at 6am the device thinks it's still 10pm
+ // of the day before.
+ int offsetInHours = MiBandCoordinator.getDeviceTimeOffsetHours(deviceAddress);
+ if (offsetInHours != 0)
+ timestamp.add(Calendar.HOUR_OF_DAY, offsetInHours);
+
+ return new byte[]{
+ (byte) (timestamp.get(Calendar.YEAR) - 2000),
+ (byte) timestamp.get(Calendar.MONTH),
+ (byte) timestamp.get(Calendar.DATE),
+ (byte) timestamp.get(Calendar.HOUR_OF_DAY),
+ (byte) timestamp.get(Calendar.MINUTE),
+ (byte) timestamp.get(Calendar.SECOND)
+ };
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandFWHelper.java
new file mode 100644
index 0000000..1108c4c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandFWHelper.java
@@ -0,0 +1,151 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Taavi Eom盲e
+
+ 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.devices.miband;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.AbstractMiFirmwareInfo;
+
+/**
+ * Also see Mi1SFirmwareInfo.
+ */
+public class MiBandFWHelper extends AbstractMiBandFWHelper {
+ private static final Logger LOG = LoggerFactory.getLogger(MiBandFWHelper.class);
+
+ /**
+ * The backing firmware info instance, which in general supports the provided
+ * given firmware. You must call AbstractMiFirmwareInfo#checkValid() before
+ * attempting to flash it.
+ */
+ @NonNull
+ private AbstractMiFirmwareInfo firmwareInfo;
+
+ /**
+ * Provides a different notification API which is also used on Mi1A devices.
+ */
+ public static final int FW_16779790 = 16779790;
+
+ private final int[] whitelistedFirmwareVersion = {
+ 16779534, // 1.0.9.14 tested by developer
+ 16779547, //1.0.9.27 tested by developer
+ 16779568, //1.0.9.48 tested by developer
+ 16779585, //1.0.9.65 tested by developer
+ 16779779, //1.0.10.3 reported on the wiki
+ 16779782, //1.0.10.6 reported on the wiki
+ 16779787, //1.0.10.11 tested by developer
+ //FW_16779790, //1.0.10.14 reported on the wiki (vibration does not work currently)
+ 68094986, // 4.15.12.10 tested by developer
+ 68158215, // 4.16.3.7 tested by developer
+ 68158486, // 4.16.4.22 tested by developer and user
+ 68160271, // 4.16.11.15 tested by developer
+ 84870926, // 5.15.7.14 tested by developer
+ };
+
+ public MiBandFWHelper(Uri uri, Context context) throws IOException {
+ super(uri, context);
+ }
+
+ @NonNull
+ @Override
+ public String getFirmwareKind() {
+ return GBApplication.getContext().getString(R.string.kind_firmware);
+ }
+
+ @Override
+ public int getFirmwareVersion() {
+ // FIXME: UnsupportedOperationException!
+ return firmwareInfo.getFirst().getFirmwareVersion();
+ }
+
+ @Override
+ public int getFirmware2Version() {
+ return firmwareInfo.getFirst().getFirmwareVersion();
+ }
+
+ @Override
+ public String getHumanFirmwareVersion2() {
+ return format(firmwareInfo.getSecond().getFirmwareVersion());
+ }
+
+ @Override
+ protected int[] getWhitelistedFirmwareVersions() {
+ return whitelistedFirmwareVersion;
+ }
+
+ @Override
+ public boolean isFirmwareGenerallyCompatibleWith(GBDevice device) {
+ return firmwareInfo.isGenerallyCompatibleWith(device);
+ }
+
+ @Override
+ public boolean isSingleFirmware() {
+ return firmwareInfo.isSingleMiBandFirmware();
+ }
+
+ /**
+ * @param wholeFirmwareBytes
+ * @return
+ * @throws IllegalArgumentException when the data is not recognized as firmware data
+ */
+ @Override
+ protected void determineFirmwareInfo(byte[] wholeFirmwareBytes) {
+ firmwareInfo = AbstractMiFirmwareInfo.determineFirmwareInfoFor(wholeFirmwareBytes);
+ }
+
+ @Override
+ public void checkValid() throws IllegalArgumentException {
+ firmwareInfo.checkValid();
+ }
+
+ @Override
+ public HuamiFirmwareType getFirmwareType() {
+ return null;
+ }
+
+ /**
+ * @param wholeFirmwareBytes
+ * @return
+ * @throws IllegalArgumentException when the data is not recognized as firmware data
+ */
+ @NonNull
+ public static AbstractMiFirmwareInfo determineFirmwareInfoFor(byte[] wholeFirmwareBytes) {
+ return AbstractMiFirmwareInfo.determineFirmwareInfoFor(wholeFirmwareBytes);
+ }
+
+ /**
+ * The backing firmware info instance, which in general supports the provided
+ * given firmware. You MUST call AbstractMiFirmwareInfo#checkValid() AND
+ * isGenerallyCompatibleWithDevice() before attempting to flash it.
+ */
+ @NonNull
+ public AbstractMiFirmwareInfo getFirmwareInfo() {
+ return firmwareInfo;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandFWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandFWInstallHandler.java
new file mode 100644
index 0000000..c2196cc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandFWInstallHandler.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.devices.miband;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class MiBandFWInstallHandler extends AbstractMiBandFWInstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(MiBandFWInstallHandler.class);
+
+ public MiBandFWInstallHandler(Uri uri, Context context) {
+ super(uri, context);
+ }
+
+ @Override
+ protected AbstractMiBandFWHelper createHelper(Uri uri, Context context) throws IOException {
+ return new MiBandFWHelper(uri, context);
+ }
+
+ @Override
+ protected boolean isSupportedDeviceType(GBDevice device) {
+ return device.getType() == DeviceType.MIBAND;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandPairingActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandPairingActivity.java
new file mode 100644
index 0000000..9c34e62
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandPairingActivity.java
@@ -0,0 +1,266 @@
+/* Copyright (C) 2015-2024 Andreas B枚hler, Andreas Shimokawa, Carsten
+ Pfeiffer, Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k,
+ Taavi Eom盲e
+
+ 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.devices.miband;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.BondingUtil.STATE_DEVICE_CANDIDATE;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.core.content.ContextCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.apache.commons.lang3.RandomStringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AboutUserPreferencesActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2;
+import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.BondingInterface;
+import nodomain.freeyourgadget.gadgetbridge.util.BondingUtil;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class MiBandPairingActivity extends AbstractGBActivity implements BondingInterface {
+ private static final Logger LOG = LoggerFactory.getLogger(MiBandPairingActivity.class);
+
+ private static final int REQ_CODE_USER_SETTINGS = 52;
+
+ private final BroadcastReceiver pairingReceiver = BondingUtil.getPairingReceiver(this);
+ private final BroadcastReceiver bondingReceiver = BondingUtil.getBondingReceiver(this);
+ private TextView message;
+ private boolean isPairing;
+ private GBDeviceCandidate deviceCandidate;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_mi_band_pairing);
+
+ message = findViewById(R.id.miband_pair_message);
+ this.deviceCandidate = getIntent().getParcelableExtra(DeviceCoordinator.EXTRA_DEVICE_CANDIDATE);
+ if (deviceCandidate == null && savedInstanceState != null) {
+ this.deviceCandidate = savedInstanceState.getParcelable(STATE_DEVICE_CANDIDATE);
+ }
+
+ if (deviceCandidate == null) {
+ Toast.makeText(this, getString(R.string.message_cannot_pair_no_mac), Toast.LENGTH_SHORT).show();
+ startActivity(new Intent(this, DiscoveryActivityV2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
+
+ finish();
+ return;
+ }
+
+ DeviceCoordinator coordinator = DeviceHelper.getInstance().resolveCoordinator(deviceCandidate);
+ GBDevice device = DeviceHelper.getInstance().toSupportedDevice(deviceCandidate);
+
+ if (coordinator.getSupportedDeviceSpecificAuthenticationSettings() != null) { // FIXME: this will no longer be sane in the future
+ SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
+ String authKey = sharedPrefs.getString("authkey", null);
+ if (authKey == null || authKey.isEmpty()) {
+ SharedPreferences.Editor editor = sharedPrefs.edit();
+
+ String randomAuthkey = RandomStringUtils.random(16, true, true);
+ editor.putString("authkey", randomAuthkey);
+ editor.apply();
+ }
+ }
+
+ if (!MiBandCoordinator.hasValidUserInfo()) {
+ Intent userSettingsIntent = new Intent(this, AboutUserPreferencesActivity.class);
+ startActivityForResult(userSettingsIntent, REQ_CODE_USER_SETTINGS, null);
+ return;
+ }
+
+ // already valid user info available, use that and pair
+ startPairing();
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelable(STATE_DEVICE_CANDIDATE, deviceCandidate);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ deviceCandidate = savedInstanceState.getParcelable(STATE_DEVICE_CANDIDATE);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ // start pairing immediately when we return from the user settings
+ if (requestCode == REQ_CODE_USER_SETTINGS) {
+ if (!MiBandCoordinator.hasValidUserInfo()) {
+ GB.toast(this, getString(R.string.miband_pairing_using_dummy_userdata), Toast.LENGTH_LONG, GB.WARN);
+ }
+ startPairing();
+ }
+
+ BondingUtil.handleActivityResult(this, requestCode, resultCode, data);
+ }
+
+
+ private void startPairing() {
+ isPairing = true;
+ message.setText(getString(R.string.pairing, deviceCandidate));
+
+ final BondingInterface thiz = this;
+ new MaterialAlertDialogBuilder(getContext())
+ .setCancelable(true)
+ .setTitle(getContext().getString(R.string.discovery_pair_title, deviceCandidate.getName()))
+ .setMessage(getContext().getString(R.string.discovery_pair_question))
+ .setNegativeButton(R.string.discovery_dont_pair, (dialog, which) -> {
+ BondingUtil.attemptToFirstConnect(getCurrentTarget().getDevice());
+ })
+ .setPositiveButton(getContext().getString(R.string.discovery_yes_pair), (dialog, which) -> {
+ BondingUtil.tryBondThenComplete(thiz, deviceCandidate.getDevice(), deviceCandidate.getMacAddress());
+ })
+ .show();
+ }
+
+
+ private void stopPairing() {
+ isPairing = false;
+ BondingUtil.stopBluetoothBonding(deviceCandidate.getDevice());
+ }
+
+ @Override
+ public void onBondingComplete(boolean success) {
+ LOG.debug("pairingFinished: " + success);
+ if (!isPairing) {
+ // already gone?
+ return;
+ } else {
+ isPairing = false;
+ }
+
+ if (success) {
+ // remember the device since we do not necessarily pair... temporary -- we probably need
+ // to query the db for available devices in ControlCenter. But only remember un-bonded
+ // devices, as bonded devices are displayed anyway.
+ String macAddress = deviceCandidate.getMacAddress();
+ BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(macAddress);
+ if (device != null && device.getBondState() == BluetoothDevice.BOND_NONE) {
+ // Persist the device directly to the database
+ try (DBHandler db = GBApplication.acquireDB()) {
+ final DaoSession daoSession = db.getDaoSession();
+ final GBDevice gbDevice = DeviceHelper.getInstance().toSupportedDevice(deviceCandidate);
+ DBHelper.getDevice(gbDevice, daoSession);
+ gbDevice.sendDeviceUpdateIntent(this);
+ } catch (final Exception e) {
+ GB.log("Error accessing database", GB.ERROR, e);
+ }
+ }
+ Intent intent = new Intent(this, ControlCenterv2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ startActivity(intent);
+ }
+ finish();
+ }
+
+ @Override
+ public GBDeviceCandidate getCurrentTarget() {
+ return this.deviceCandidate;
+ }
+
+ @Override
+ protected void onResume() {
+ registerBroadcastReceivers();
+ super.onResume();
+ }
+
+ @Override
+ public String getMacAddress() {
+ return deviceCandidate.getDevice().getAddress();
+ }
+
+ @Override
+ public boolean getAttemptToConnect() {
+ return true;
+ }
+
+ @Override
+ protected void onStart() {
+ registerBroadcastReceivers();
+ super.onStart();
+ }
+
+ @Override
+ protected void onDestroy() {
+ unregisterBroadcastReceivers();
+ if (isPairing) {
+ stopPairing();
+ }
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onStop() {
+ unregisterBroadcastReceivers();
+ if (isPairing) {
+ stopPairing();
+ }
+ super.onStop();
+ }
+
+ @Override
+ protected void onPause() {
+ // WARN: Do not stop pairing or unregister receivers pause!
+ // Bonding process can pause the activity and you might miss broadcasts
+ super.onPause();
+ }
+
+ public void unregisterBroadcastReceivers() {
+ AndroidUtils.safeUnregisterBroadcastReceiver(LocalBroadcastManager.getInstance(this), pairingReceiver);
+ AndroidUtils.safeUnregisterBroadcastReceiver(this, bondingReceiver);
+ }
+
+ public void registerBroadcastReceivers() {
+ LocalBroadcastManager.getInstance(this).registerReceiver(pairingReceiver, new IntentFilter(GBDevice.ACTION_DEVICE_CHANGED));
+ ContextCompat.registerReceiver(this, bondingReceiver, new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED), ContextCompat.RECEIVER_EXPORTED);
+ }
+
+ public Context getContext() {
+ return this;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandSampleProvider.java
new file mode 100644
index 0000000..0f74749
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandSampleProvider.java
@@ -0,0 +1,79 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti
+
+ 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.devices.miband;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class MiBandSampleProvider extends AbstractMiBandSampleProvider {
+ public static final int TYPE_DEEP_SLEEP = 4;
+ public static final int TYPE_LIGHT_SLEEP = 5;
+ public static final int TYPE_ACTIVITY = -1;
+ public static final int TYPE_UNKNOWN = -1;
+ public static final int TYPE_NONWEAR = 3;
+ public static final int TYPE_CHARGING = 6;
+
+// public static final byte TYPE_NREM = 5; // DEEP SLEEP
+// public static final byte TYPE_ONBED = 7;
+// public static final byte TYPE_REM = 4; // LIGHT SLEEP
+// public static final byte TYPE_RUNNING = 2;
+// public static final byte TYPE_SLIENT = 0;
+// public static final byte TYPE_USER = 100;
+// public static final byte TYPE_WALKING = 1;
+
+ public MiBandSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ switch (rawType) {
+ case TYPE_DEEP_SLEEP:
+ return ActivityKind.DEEP_SLEEP;
+ case TYPE_LIGHT_SLEEP:
+ return ActivityKind.LIGHT_SLEEP;
+ case TYPE_ACTIVITY:
+ return ActivityKind.ACTIVITY;
+ case TYPE_NONWEAR:
+ return ActivityKind.NOT_WORN;
+ case TYPE_CHARGING:
+ return ActivityKind.NOT_WORN; //I believe it's a safe assumption
+ default:
+// case TYPE_UNKNOWN: // fall through
+ return ActivityKind.UNKNOWN;
+ }
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ switch (activityKind) {
+ case ACTIVITY:
+ return TYPE_ACTIVITY;
+ case DEEP_SLEEP:
+ return TYPE_DEEP_SLEEP;
+ case LIGHT_SLEEP:
+ return TYPE_LIGHT_SLEEP;
+ case NOT_WORN:
+ return TYPE_NONWEAR;
+ case UNKNOWN: // fall through
+ default:
+ return TYPE_UNKNOWN;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandService.java
new file mode 100644
index 0000000..6f0f6a5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandService.java
@@ -0,0 +1,275 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Kasha
+
+ 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.devices.miband;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport.BASE_UUID;
+
+public class MiBandService {
+
+
+ public static final String MAC_ADDRESS_FILTER_1_1A = "88:0F:10";
+ public static final String MAC_ADDRESS_FILTER_1S = "C8:0F:10";
+
+ public static final UUID UUID_SERVICE_MIBAND_SERVICE = UUID.fromString(String.format(BASE_UUID, "FEE0"));
+ public static final UUID UUID_SERVICE_MIBAND2_SERVICE = UUID.fromString(String.format(BASE_UUID, "FEE1"));
+ public static final UUID UUID_SERVICE_HEART_RATE = GattService.UUID_SERVICE_HEART_RATE;
+ public static final String UUID_SERVICE_WEIGHT_SERVICE = "00001530-0000-3512-2118-0009af100700";
+
+ public static final UUID UUID_CHARACTERISTIC_DEVICE_INFO = UUID.fromString(String.format(BASE_UUID, "FF01"));
+
+ public static final UUID UUID_CHARACTERISTIC_DEVICE_NAME = UUID.fromString(String.format(BASE_UUID, "FF02"));
+
+ public static final UUID UUID_CHARACTERISTIC_NOTIFICATION = UUID.fromString(String.format(BASE_UUID, "FF03"));
+
+ public static final UUID UUID_CHARACTERISTIC_USER_INFO = UUID.fromString(String.format(BASE_UUID, "FF04"));
+
+ public static final UUID UUID_CHARACTERISTIC_CONTROL_POINT = UUID.fromString(String.format(BASE_UUID, "FF05"));
+
+ public static final UUID UUID_CHARACTERISTIC_REALTIME_STEPS = UUID.fromString(String.format(BASE_UUID, "FF06"));
+
+ public static final UUID UUID_CHARACTERISTIC_ACTIVITY_DATA = UUID.fromString(String.format(BASE_UUID, "FF07"));
+
+ public static final UUID UUID_CHARACTERISTIC_FIRMWARE_DATA = UUID.fromString(String.format(BASE_UUID, "FF08"));
+
+ public static final UUID UUID_CHARACTERISTIC_LE_PARAMS = UUID.fromString(String.format(BASE_UUID, "FF09"));
+
+ public static final UUID UUID_CHARACTERISTIC_DATE_TIME = UUID.fromString(String.format(BASE_UUID, "FF0A"));
+
+ public static final UUID UUID_CHARACTERISTIC_STATISTICS = UUID.fromString(String.format(BASE_UUID, "FF0B"));
+
+ public static final UUID UUID_CHARACTERISTIC_BATTERY = UUID.fromString(String.format(BASE_UUID, "FF0C"));
+
+ public static final UUID UUID_CHARACTERISTIC_TEST = UUID.fromString(String.format(BASE_UUID, "FF0D"));
+
+ public static final UUID UUID_CHARACTERISTIC_SENSOR_DATA = UUID.fromString(String.format(BASE_UUID, "FF0E"));
+
+ public static final UUID UUID_CHARACTERISTIC_PAIR = UUID.fromString(String.format(BASE_UUID, "FF0F"));
+
+ public static final UUID UUID_CHARACTERISTIC_HEART_RATE_CONTROL_POINT = GattCharacteristic.UUID_CHARACTERISTIC_HEART_RATE_CONTROL_POINT;
+ public static final UUID UUID_CHARACTERISTIC_HEART_RATE_MEASUREMENT = GattCharacteristic.UUID_CHARACTERISTIC_HEART_RATE_MEASUREMENT;
+
+
+
+ /* FURTHER UUIDS that were mixed with the other params below. The base UUID for these is unknown */
+
+ public static final byte ALIAS_LEN = 0xa;
+
+ /*NOTIFICATIONS: usually received on the UUID_CHARACTERISTIC_NOTIFICATION characteristic */
+
+ public static final byte NOTIFY_NORMAL = 0x0;
+
+ public static final byte NOTIFY_FIRMWARE_UPDATE_FAILED = 0x1;
+
+ public static final byte NOTIFY_FIRMWARE_UPDATE_SUCCESS = 0x2;
+
+ public static final byte NOTIFY_CONN_PARAM_UPDATE_FAILED = 0x3;
+
+ public static final byte NOTIFY_CONN_PARAM_UPDATE_SUCCESS = 0x4;
+
+ public static final byte NOTIFY_AUTHENTICATION_SUCCESS = 0x5;
+
+ public static final byte NOTIFY_AUTHENTICATION_FAILED = 0x6;
+
+ public static final byte NOTIFY_FITNESS_GOAL_ACHIEVED = 0x7;
+
+ public static final byte NOTIFY_SET_LATENCY_SUCCESS = 0x8;
+
+ public static final byte NOTIFY_RESET_AUTHENTICATION_FAILED = 0x9;
+
+ public static final byte NOTIFY_RESET_AUTHENTICATION_SUCCESS = 0xa;
+
+ public static final byte NOTIFY_FW_CHECK_FAILED = 0xb;
+
+ public static final byte NOTIFY_FW_CHECK_SUCCESS = 0xc;
+
+ public static final byte NOTIFY_STATUS_MOTOR_NOTIFY = 0xd;
+
+ public static final byte NOTIFY_STATUS_MOTOR_CALL = 0xe;
+
+ public static final byte NOTIFY_STATUS_MOTOR_DISCONNECT = 0xf;
+
+ public static final byte NOTIFY_STATUS_MOTOR_SMART_ALARM = 0x10;
+
+ public static final byte NOTIFY_STATUS_MOTOR_ALARM = 0x11;
+
+ public static final byte NOTIFY_STATUS_MOTOR_GOAL = 0x12;
+
+ public static final byte NOTIFY_STATUS_MOTOR_AUTH = 0x13;
+
+ public static final byte NOTIFY_STATUS_MOTOR_SHUTDOWN = 0x14;
+
+ public static final byte NOTIFY_STATUS_MOTOR_AUTH_SUCCESS = 0x15;
+
+ public static final byte NOTIFY_STATUS_MOTOR_TEST = 0x16;
+
+ // 0x18 is returned when we cancel data sync, perhaps is an ack for this message
+
+ public static final byte NOTIFY_UNKNOWN = -0x1;
+
+ public static final int NOTIFY_PAIR_CANCEL = 0xef;
+
+ public static final int NOTIFY_DEVICE_MALFUNCTION = 0xff;
+
+
+ /* MESSAGES: unknown */
+
+ public static final byte MSG_CONNECTED = 0x0;
+
+ public static final byte MSG_DISCONNECTED = 0x1;
+
+ public static final byte MSG_CONNECTION_FAILED = 0x2;
+
+ public static final byte MSG_INITIALIZATION_FAILED = 0x3;
+
+ public static final byte MSG_INITIALIZATION_SUCCESS = 0x4;
+
+ public static final byte MSG_STEPS_CHANGED = 0x5;
+
+ public static final byte MSG_DEVICE_STATUS_CHANGED = 0x6;
+
+ public static final byte MSG_BATTERY_STATUS_CHANGED = 0x7;
+
+ /* COMMANDS: usually sent to UUID_CHARACTERISTIC_CONTROL_POINT characteristic */
+
+ public static final byte COMMAND_SET_TIMER = 0x4;
+
+ public static final byte COMMAND_SET_FITNESS_GOAL = 0x5;
+
+ public static final byte COMMAND_FETCH_DATA = 0x6;
+
+ public static final byte COMMAND_SEND_FIRMWARE_INFO = 0x7;
+
+ public static final byte COMMAND_SEND_NOTIFICATION = 0x8;
+
+ public static final byte COMMAND_FACTORYRESET = 0x9;
+
+ public static final byte COMMAND_CONFIRM_ACTIVITY_DATA_TRANSFER_COMPLETE = 0xa;
+
+ public static final byte COMMAND_SYNC = 0xb;
+
+ public static final byte COMMAND_REBOOT = 0xc;
+
+ public static final byte COMMAND_SET_WEAR_LOCATION = 0xf;
+
+ public static final byte COMMAND_STOP_SYNC_DATA = 0x11;
+
+ public static final byte COMMAND_STOP_MOTOR_VIBRATE = 0x13;
+
+ public static final byte COMMAND_SET_REALTIME_STEPS_NOTIFICATION = 0x3;
+
+ public static final byte COMMAND_SET_REALTIME_STEP = 0x10;
+
+ // Test HR
+ public static final byte COMMAND_SET_HR_SLEEP = 0x0;
+ public static final byte COMMAND_SET__HR_CONTINUOUS = 0x1;
+ public static final byte COMMAND_SET_HR_MANUAL = 0x2;
+
+ public static final byte COMMAND_GET_SENSOR_DATA = 0x12;
+
+ /* FURTHER COMMANDS: unchecked therefore left commented
+
+
+ public static final byte COMMAND_FACTORY_RESET = 0x9t;
+
+ public static final int COMMAND_SET_COLOR_THEME = et;
+
+ */
+
+ /* CONNECTION: unknown
+
+ public static final CONNECTION_LATENCY_LEVEL_LOW = 0x0t;
+
+ public static final CONNECTION_LATENCY_LEVEL_MEDIUM = 0x1t;
+
+ public static final CONNECTION_LATENCY_LEVEL_HIGH = 0x2t;
+
+ */
+
+ /* MODES: probably related to the sample data structure
+ */
+
+ public static final byte MODE_REGULAR_DATA_LEN_BYTE = 0x0;
+
+ // was MODE_REGULAR_DATA_LEN_MINITE
+ public static final byte MODE_REGULAR_DATA_LEN_MINUTE = 0x1;
+
+ /* PROFILE: unknown
+
+ public static final PROFILE_STATE_UNKNOWN:I = 0x0
+
+ public static final PROFILE_STATE_INITIALIZATION_SUCCESS:I = 0x1
+
+ public static final PROFILE_STATE_INITIALIZATION_FAILED:I = 0x2
+
+ public static final PROFILE_STATE_AUTHENTICATION_SUCCESS:I = 0x3
+
+ public static final PROFILE_STATE_AUTHENTICATION_FAILED:I = 0x4
+
+ */
+
+ // TEST_*: sent to UUID_CHARACTERISTIC_TEST characteristic
+
+ public static final byte TEST_DISCONNECTED_REMINDER = 0x5;
+
+ public static final byte TEST_NOTIFICATION = 0x3;
+
+ public static final byte TEST_REMOTE_DISCONNECT = 0x1;
+
+ public static final byte TEST_SELFTEST = 0x2;
+
+ private static final Map<UUID, String> MIBAND_DEBUG;
+
+ static {
+ MIBAND_DEBUG = new HashMap<>();
+ MIBAND_DEBUG.put(UUID_SERVICE_MIBAND_SERVICE, "MiBand Service");
+ MIBAND_DEBUG.put(UUID_SERVICE_HEART_RATE, "MiBand HR Service");
+
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_DEVICE_INFO, "Device Info");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_DEVICE_NAME, "Device Name");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_NOTIFICATION, "Notification");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_USER_INFO, "User Info");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_CONTROL_POINT, "Control Point");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_REALTIME_STEPS, "Realtime Steps");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_ACTIVITY_DATA, "Activity Data");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_FIRMWARE_DATA, "Firmware Data");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_LE_PARAMS, "LE Params");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_DATE_TIME, "Date/Time");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_STATISTICS, "Statistics");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_BATTERY, "Battery");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_TEST, "Test");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_SENSOR_DATA, "Sensor Data");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_PAIR, "Pair");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_HEART_RATE_CONTROL_POINT, "Heart Rate Control Point");
+ MIBAND_DEBUG.put(UUID_CHARACTERISTIC_HEART_RATE_MEASUREMENT, "Heart Rate Measure");
+ }
+
+ public static String lookup(UUID uuid, String fallback) {
+ String name = MIBAND_DEBUG.get(uuid);
+ if (name == null) {
+ name = fallback;
+ }
+ return name;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandSettingsCustomizer.java
new file mode 100644
index 0000000..213b948
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/MiBandSettingsCustomizer.java
@@ -0,0 +1,96 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.miband;
+
+import android.os.Parcel;
+import android.text.InputType;
+
+import androidx.preference.Preference;
+
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class MiBandSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ private final GBDevice device;
+
+ public MiBandSettingsCustomizer(final GBDevice device) {
+ this.device = device;
+ }
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ for (final NotificationType type : NotificationType.values()) {
+ String countPrefKey = "mi_vibration_count_" + type.getGenericType();
+ handler.setInputTypeFor(countPrefKey, InputType.TYPE_CLASS_NUMBER);
+ String tryPrefKey = "mi_try_" + type.getGenericType();
+ final Preference tryPref = handler.findPreference(tryPrefKey);
+ if (tryPref != null) {
+ tryPref.setOnPreferenceClickListener(preference -> {
+ tryVibration(type);
+ return true;
+ });
+ }
+ }
+ }
+
+ private void tryVibration(NotificationType type) {
+ NotificationSpec spec = new NotificationSpec();
+ spec.type = type;
+ GBApplication.deviceService(device).onNotification(spec);
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ public static final Creator<MiBandSettingsCustomizer> CREATOR = new Creator<MiBandSettingsCustomizer>() {
+ @Override
+ public MiBandSettingsCustomizer createFromParcel(final Parcel in) {
+ final GBDevice device = in.readParcelable(MiBandSettingsCustomizer.class.getClassLoader());
+ return new MiBandSettingsCustomizer(device);
+ }
+
+ @Override
+ public MiBandSettingsCustomizer[] newArray(final int size) {
+ return new MiBandSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeParcelable(device, 0);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/UserInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/UserInfo.java
new file mode 100644
index 0000000..e6d8cc8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/UserInfo.java
@@ -0,0 +1,119 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Sergey Trofimov
+
+ 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.devices.miband;
+
+import java.util.Arrays;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.DeviceInfo;
+import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
+
+public class UserInfo {
+
+ private final String btAddress;
+ private final String alias;
+ private final int gender;
+ private final int age;
+ private final int height;
+ private final int weight;
+ private final int type;
+
+ private byte[] data = new byte[20];
+
+ /**
+ * Creates a default user info.
+ *
+ * @param btAddress the address of the MI Band to connect to.
+ */
+ public static UserInfo getDefault(String btAddress) {
+ return new UserInfo(btAddress, "1550050550", ActivityUser.defaultUserGender, ActivityUser.defaultUserAge, ActivityUser.defaultUserHeightCm, ActivityUser.defaultUserWeightKg, 0);
+ }
+
+ /**
+ * Creates a user info with the given data
+ *
+ * @param address the address of the MI Band to connect to.
+ * @throws IllegalArgumentException when the given values are not valid
+ */
+ public static UserInfo create(String address, String alias, int gender, int age, int height, int weight, int type) throws IllegalArgumentException {
+ if (address == null || address.length() == 0 || alias == null || alias.length() == 0 || gender < 0 || age <= 0 || weight <= 0 || type < 0) {
+ throw new IllegalArgumentException("Invalid parameters");
+ }
+ try {
+ return new UserInfo(address, alias, gender, age, height, weight, type);
+ } catch (Exception ex) {
+ throw new IllegalArgumentException("Illegal user info data", ex);
+ }
+ }
+
+ /**
+ * Creates a user info with the given data
+ *
+ * @param address the address of the MI Band to connect to.
+ */
+ private UserInfo(String address, String alias, int gender, int age, int height, int weight, int type) {
+ this.btAddress = address;
+ this.alias = alias;
+ this.gender = gender;
+ this.age = age;
+ this.height = height;
+ this.weight = weight;
+ this.type = type;
+ }
+
+ private int calculateUidFrom(String alias) {
+ int uid;
+ try {
+ uid = Integer.parseInt(alias);
+ } catch (NumberFormatException ex) {
+ uid = alias.hashCode(); // simple as that
+ }
+ return uid;
+ }
+
+ public byte[] getData(DeviceInfo mDeviceInfo) {
+ byte[] sequence = new byte[20];
+ int uid = calculateUidFrom(alias);
+
+ sequence[0] = (byte) uid;
+ sequence[1] = (byte) (uid >>> 8);
+ sequence[2] = (byte) (uid >>> 16);
+ sequence[3] = (byte) (uid >>> 24);
+
+ sequence[4] = (byte) (gender & 0xff);
+ sequence[5] = (byte) (age & 0xff);
+ sequence[6] = (byte) (height & 0xff);
+ sequence[7] = (byte) (weight & 0xff);
+ sequence[8] = (byte) (type & 0xff);
+
+ int aliasFrom = 9;
+ if (!mDeviceInfo.isMili1()) {
+ sequence[9] = (byte) (mDeviceInfo.feature & 255);
+ sequence[10] = (byte) (mDeviceInfo.appearance & 255);
+ aliasFrom = 11;
+ }
+
+ byte[] aliasBytes = alias.substring(0, Math.min(alias.length(), 19 - aliasFrom)).getBytes();
+ System.arraycopy(aliasBytes, 0, sequence, aliasFrom, aliasBytes.length);
+
+ byte[] crcSequence = Arrays.copyOf(sequence, 19);
+ sequence[19] = (byte) ((CheckSums.getCRC8(crcSequence) ^ Integer.parseInt(this.btAddress.substring(this.btAddress.length() - 2), 16)) & 0xff);
+
+ return sequence;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/VibrationProfile.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/VibrationProfile.java
new file mode 100644
index 0000000..26428cb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/VibrationProfile.java
@@ -0,0 +1,110 @@
+/* Copyright (C) 2015-2024 Carsten Pfeiffer, Jos茅 Rebelo, Uwe Hermann
+
+ 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.devices.miband;
+
+import android.content.Context;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.alertnotification.AlertLevel;
+
+public class VibrationProfile {
+ public static final String ID_STACCATO;
+ public static final String ID_SHORT;
+ public static final String ID_MEDIUM;
+ public static final String ID_LONG;
+ public static final String ID_WATERDROP;
+ public static final String ID_RING;
+ public static final String ID_ALARM_CLOCK;
+
+ static {
+ Context CONTEXT = GBApplication.getContext();
+ ID_STACCATO = CONTEXT.getString(R.string.p_staccato);
+ ID_SHORT = CONTEXT.getString(R.string.p_short);
+ ID_MEDIUM = CONTEXT.getString(R.string.p_medium);
+ ID_LONG = CONTEXT.getString(R.string.p_long);
+ ID_WATERDROP = CONTEXT.getString(R.string.p_waterdrop);
+ ID_RING = CONTEXT.getString(R.string.p_ring);
+ ID_ALARM_CLOCK = CONTEXT.getString(R.string.p_alarm_clock);
+ }
+
+ public static VibrationProfile getProfile(String id, short repeat) {
+ if (ID_STACCATO.equals(id)) {
+ return new VibrationProfile(id, new int[]{100, 0}, repeat);
+ }
+ if (ID_SHORT.equals(id)) {
+ return new VibrationProfile(id, new int[]{200, 200}, repeat);
+ }
+ if (ID_LONG.equals(id)) {
+ return new VibrationProfile(id, new int[]{500, 1000}, repeat);
+ }
+ if (ID_WATERDROP.equals(id)) {
+ return new VibrationProfile(id, new int[]{100, 1500}, repeat);
+ }
+ if (ID_RING.equals(id)) {
+ return new VibrationProfile(id, new int[]{300, 200, 600, 2000}, repeat);
+ }
+ if (ID_ALARM_CLOCK.equals(id)) {
+ return new VibrationProfile(id, new int[]{30, 35, 30, 35, 30, 35, 30, 800}, repeat);
+ }
+ // medium
+ return new VibrationProfile(id, new int[]{300, 600}, repeat);
+ }
+
+ private final String id;
+
+ private final int[] onOffSequence;
+ private final short repeat;
+ private int alertLevel = AlertLevel.MildAlert.getId();
+
+ /**
+ * Creates a new profile instance.
+ *
+ * @param id the ID, used as preference key.
+ * @param onOffSequence a sequence of alternating on and off durations, in milliseconds
+ * @param repeat how often the sequence shall be repeated
+ */
+ public VibrationProfile(String id, int[] onOffSequence, short repeat) {
+ if (onOffSequence.length % 2 != 0) {
+ throw new IllegalArgumentException("Each on duration must have a subsequent off duration");
+ }
+
+ this.id = id;
+ this.repeat = repeat;
+ this.onOffSequence = onOffSequence;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public int[] getOnOffSequence() {
+ return onOffSequence;
+ }
+
+ public short getRepeat() {
+ return repeat;
+ }
+
+ public void setAlertLevel(int alertLevel) {
+ this.alertLevel = alertLevel;
+ }
+
+ public int getAlertLevel() {
+ return alertLevel;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/miligatt.txt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/miligatt.txt
new file mode 100644
index 0000000..8398d52
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/miligatt.txt
@@ -0,0 +1,34 @@
+00001800-0000-1000-8000-00805f9b34fb:Generic Access Service
+- 00002a00-0000-1000-8000-00805f9b34fb:Device Name
+- 00002a01-0000-1000-8000-00805f9b34fb:Appearance
+- 00002a02-0000-1000-8000-00805f9b34fb:Peripheral Privacy Flag
+- 00002a04-0000-1000-8000-00805f9b34fb:Peripheral Preferred Connection Parameters
+
+00001801-0000-1000-8000-00805f9b34fb:Generic Attribute Service
+- 00002a05-0000-1000-8000-00805f9b34fb:Service Changed
+
+0000fee0-0000-1000-8000-00805f9b34fb:MiBand Service
+- 0000ff01-0000-1000-8000-00805f9b34fb:Device Info
+- 0000ff02-0000-1000-8000-00805f9b34fb:Device Name
+- 0000ff03-0000-1000-8000-00805f9b34fb:Notification
+- 0000ff04-0000-1000-8000-00805f9b34fb:User Info
+- 0000ff05-0000-1000-8000-00805f9b34fb:Control Point
+- 0000ff06-0000-1000-8000-00805f9b34fb:Realtime Steps
+- 0000ff07-0000-1000-8000-00805f9b34fb:Activity Data
+- 0000ff08-0000-1000-8000-00805f9b34fb:Firmware Data
+- 0000ff09-0000-1000-8000-00805f9b34fb:LE Params
+- 0000ff0a-0000-1000-8000-00805f9b34fb:Date/Time
+- 0000ff0b-0000-1000-8000-00805f9b34fb:Statistics
+- 0000ff0c-0000-1000-8000-00805f9b34fb:Battery
+- 0000ff0d-0000-1000-8000-00805f9b34fb:Test
+- 0000ff0e-0000-1000-8000-00805f9b34fb:Sensor Data
+
+0000fee1-0000-1000-8000-00805f9b34fb:Unknown service
+- 0000fedd-0000-1000-8000-00805f9b34fb:Unknown characteristic
+- 0000fede-0000-1000-8000-00805f9b34fb:Unknown characteristic
+- 0000fedf-0000-1000-8000-00805f9b34fb:Unknown characteristic
+
+0000fee7-0000-1000-8000-00805f9b34fb:Unknown service
+- 0000fec7-0000-1000-8000-00805f9b34fb:Unknown characteristic
+- 0000fec8-0000-1000-8000-00805f9b34fb:Unknown characteristic
+- 0000fec9-0000-1000-8000-00805f9b34fb:Unknown characteristic
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/AbstractMijiaLywsdCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/AbstractMijiaLywsdCoordinator.java
new file mode 100644
index 0000000..0cad253
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/AbstractMijiaLywsdCoordinator.java
@@ -0,0 +1,80 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+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.mijia_lywsd.MijiaLywsdSupport;
+
+public abstract class AbstractMijiaLywsdCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Xiaomi";
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new MijiaLywsdSettingsCustomizer();
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MijiaLywsdSupport.class;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_mijia_lywsd,
+ R.xml.devicesettings_temperature_scale_cf,
+ };
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ // nothing to delete, yet
+ }
+
+ public abstract boolean supportsSetTime();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsd02Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsd02Coordinator.java
new file mode 100644
index 0000000..4e5ead2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsd02Coordinator.java
@@ -0,0 +1,48 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class MijiaLywsd02Coordinator extends AbstractMijiaLywsdCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("LYWSD02|LYWSD02MMC");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_mijia_lywsd02;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_pebble;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_pebble_disabled;
+ }
+
+ @Override
+ public boolean supportsSetTime() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsd03Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsd03Coordinator.java
new file mode 100644
index 0000000..8bcbb15
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsd03Coordinator.java
@@ -0,0 +1,48 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class MijiaLywsd03Coordinator extends AbstractMijiaLywsdCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("LYWSD03MMC");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_mijia_lywsd03;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_thermometer;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_thermometer_disabled;
+ }
+
+ @Override
+ public boolean supportsSetTime() {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdSettingsCustomizer.java
new file mode 100644
index 0000000..ce20175
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdSettingsCustomizer.java
@@ -0,0 +1,95 @@
+/* Copyright (C) 2024 Severin von Wnuck-Lipinski
+
+ 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.devices.mijia_lywsd;
+
+import android.os.Parcel;
+import androidx.preference.Preference;
+import androidx.preference.SeekBarPreference;
+
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.mijia_lywsd.MijiaLywsdSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.*;
+
+public class MijiaLywsdSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ public static final Creator<MijiaLywsdSettingsCustomizer> CREATOR = new Creator<MijiaLywsdSettingsCustomizer>() {
+ @Override
+ public MijiaLywsdSettingsCustomizer createFromParcel(final Parcel in) {
+ return new MijiaLywsdSettingsCustomizer();
+ }
+
+ @Override
+ public MijiaLywsdSettingsCustomizer[] newArray(final int size) {
+ return new MijiaLywsdSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ String key = preference.getKey();
+
+ if (!key.equals(PREF_MIJIA_LYWSD_COMFORT_TEMPERATURE_LOWER) && !key.equals(PREF_MIJIA_LYWSD_COMFORT_TEMPERATURE_UPPER) &&
+ !key.equals(PREF_MIJIA_LYWSD_COMFORT_HUMIDITY_LOWER) && !key.equals(PREF_MIJIA_LYWSD_COMFORT_HUMIDITY_UPPER))
+ return;
+
+ SeekBarPreference temperatureLower = handler.findPreference(PREF_MIJIA_LYWSD_COMFORT_TEMPERATURE_LOWER);
+ SeekBarPreference temperatureUpper = handler.findPreference(PREF_MIJIA_LYWSD_COMFORT_TEMPERATURE_UPPER);
+ SeekBarPreference humidityLower = handler.findPreference(PREF_MIJIA_LYWSD_COMFORT_HUMIDITY_LOWER);
+ SeekBarPreference humidityUpper = handler.findPreference(PREF_MIJIA_LYWSD_COMFORT_HUMIDITY_UPPER);
+
+ if (temperatureLower == null || temperatureUpper == null || humidityLower == null || humidityUpper == null)
+ return;
+
+ // Clamp minimum value of upper limit to lower limit
+ if (temperatureLower.getValue() > temperatureUpper.getValue())
+ temperatureUpper.setValue(temperatureLower.getValue());
+
+ if (humidityLower.getValue() > humidityUpper.getValue())
+ humidityUpper.setValue(humidityLower.getValue());
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ Preference comfortLevel = handler.findPreference(PREF_MIJIA_LYWSD_COMFORT_LEVEL);
+
+ if (comfortLevel != null) {
+ int length = prefs.getInt(PREF_MIJIA_LYWSD_COMFORT_CHARACTERISTIC_LENGTH, 0);
+
+ // Hide comfort level for unknown characteristic length
+ comfortLevel.setVisible(length == MijiaLywsdSupport.COMFORT_LEVEL_LENGTH_LYWSD03 ||
+ length == MijiaLywsdSupport.COMFORT_LEVEL_LENGTH_XMWSDJ04);
+ }
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {}
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaMhoC303Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaMhoC303Coordinator.java
new file mode 100644
index 0000000..faa9966
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaMhoC303Coordinator.java
@@ -0,0 +1,48 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class MijiaMhoC303Coordinator extends AbstractMijiaLywsdCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("MHO-C303");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_mijia_mho_c303;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_pebble;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_pebble_disabled;
+ }
+
+ @Override
+ public boolean supportsSetTime() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaXmwsdj04Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaXmwsdj04Coordinator.java
new file mode 100644
index 0000000..7a87e2f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaXmwsdj04Coordinator.java
@@ -0,0 +1,48 @@
+/* Copyright (C) 2024 Severin von Wnuck-Lipinski
+
+ 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.devices.mijia_lywsd;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class MijiaXmwsdj04Coordinator extends AbstractMijiaLywsdCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("XMWSDJ04MMC");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_mijia_xmwsdj04;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_thermometer;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_thermometer_disabled;
+ }
+
+ @Override
+ public boolean supportsSetTime() {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiCompositionScaleCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiCompositionScaleCoordinator.java
new file mode 100644
index 0000000..2ae348d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiCompositionScaleCoordinator.java
@@ -0,0 +1,142 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Jean-Fran莽ois Greffier, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.miscale;
+
+import android.bluetooth.le.ScanFilter;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.regex.Pattern;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.MiScaleWeightSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.WeightSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.miscale.MiCompositionScaleDeviceSupport;
+
+public class MiCompositionScaleCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull final GBDevice gbDevice, @NonNull final Device device, @NonNull final DaoSession session) throws GBException {
+ final Long deviceId = device.getId();
+ final QueryBuilder<?> qb = session.getMiScaleWeightSampleDao().queryBuilder();
+
+ qb.where(MiScaleWeightSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("MIBCS|MIBFS", Pattern.CASE_INSENSITIVE);
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ final ParcelUuid bodyCompositionService = new ParcelUuid(GattService.UUID_SERVICE_BODY_COMPOSITION);
+
+ final ScanFilter.Builder builder = new ScanFilter.Builder();
+ builder.setServiceUuid(bodyCompositionService);
+
+ final int manufacturerId = 0x0157; // Huami
+ builder.setManufacturerData(manufacturerId, new byte[6], new byte[6]);
+
+ return Collections.singletonList(builder.build());
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 0;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return super.BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Xiaomi";
+ }
+
+ @Override
+ public TimeSampleProvider<? extends WeightSample> getWeightSampleProvider(final GBDevice device, final DaoSession session) {
+ return new MiScaleSampleProvider(device, session);
+ }
+
+ @Override
+ public boolean supportsWeightMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTabs() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSleepMeasurement() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsStepCounter() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSpeedzones() {
+ return false;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MiCompositionScaleDeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_micompositionscale;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miscale;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miscale_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiScaleSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiScaleSampleProvider.java
new file mode 100644
index 0000000..9de9b70
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiScaleSampleProvider.java
@@ -0,0 +1,54 @@
+/* Copyright (C) 2024 Severin von Wnuck-Lipinski
+
+ 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.devices.miscale;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.MiScaleWeightSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.MiScaleWeightSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class MiScaleSampleProvider extends AbstractTimeSampleProvider<MiScaleWeightSample> {
+ public MiScaleSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ @NonNull
+ public AbstractDao<MiScaleWeightSample, ?> getSampleDao() {
+ return getSession().getMiScaleWeightSampleDao();
+ }
+
+ @NonNull
+ protected Property getTimestampSampleProperty() {
+ return MiScaleWeightSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ protected Property getDeviceIdentifierSampleProperty() {
+ return MiScaleWeightSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public MiScaleWeightSample createSample() {
+ return new MiScaleWeightSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiSmartScaleCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiSmartScaleCoordinator.java
new file mode 100644
index 0000000..96b8848
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miscale/MiSmartScaleCoordinator.java
@@ -0,0 +1,131 @@
+/* Copyright (C) 2024 Severin von Wnuck-Lipinski
+
+ 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.devices.miscale;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.MiScaleWeightSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.WeightSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.miscale.MiSmartScaleDeviceSupport;
+
+public class MiSmartScaleCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ public String getManufacturer() {
+ // Actual manufacturer is Huami
+ return "Xiaomi";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("MI SCALE2");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_mismartscale;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miscale;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miscale_disabled;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 0;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull final GBDevice gbDevice, @NonNull final Device device, @NonNull final DaoSession session) throws GBException {
+ final Long deviceId = device.getId();
+ final QueryBuilder<?> qb = session.getMiScaleWeightSampleDao().queryBuilder();
+
+ qb.where(MiScaleWeightSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings settings = new DeviceSpecificSettings();
+
+ settings.addRootScreen(R.xml.devicesettings_mismartscale);
+
+ return settings;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends WeightSample> getWeightSampleProvider(final GBDevice device, final DaoSession session) {
+ return new MiScaleSampleProvider(device, session);
+ }
+
+ @Override
+ public boolean supportsWeightMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTabs() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSleepMeasurement() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsStepCounter() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSpeedzones() {
+ return false;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MiSmartScaleDeviceSupport.class;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moondrop/MoondropSpaceTravelCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moondrop/MoondropSpaceTravelCoordinator.java
new file mode 100644
index 0000000..8456745
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moondrop/MoondropSpaceTravelCoordinator.java
@@ -0,0 +1,82 @@
+/* Copyright (C) 2024 Severin von Wnuck-Lipinski
+
+ 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.devices.moondrop;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+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.AbstractBLClassicDeviceCoordinator;
+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.moondrop.MoondropSpaceTravelDeviceSupport;
+
+public class MoondropSpaceTravelCoordinator extends AbstractBLClassicDeviceCoordinator {
+ @Override
+ public String getManufacturer() {
+ return "Moondrop";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Moondrop Space Travel");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_moondrop_space_travel;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 0;
+ }
+
+ @Override
+ protected void deleteDevice(
+ @NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session)
+ throws GBException {}
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings settings = new DeviceSpecificSettings();
+
+ settings.addRootScreen(R.xml.devicesettings_moondrop_space_travel_equalizer);
+ settings.addRootScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS);
+ settings.addSubScreen(
+ DeviceSpecificSettingsScreen.TOUCH_OPTIONS,
+ R.xml.devicesettings_moondrop_space_travel_touch);
+ settings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
+ settings.addSubScreen(
+ DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS,
+ R.xml.devicesettings_headphones);
+
+ return settings;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return MoondropSpaceTravelDeviceSupport.class;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1Constants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1Constants.java
new file mode 100644
index 0000000..dcb0ca3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1Constants.java
@@ -0,0 +1,52 @@
+/* Copyright (C) 2017-2024 protomors
+
+ 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.devices.no1f1;
+
+import java.util.UUID;
+
+public final class No1F1Constants {
+
+ public static final UUID UUID_CHARACTERISTIC_CONTROL = UUID.fromString("000033f1-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_MEASURE = UUID.fromString("000033f2-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_SERVICE_NO1 = UUID.fromString("000055ff-0000-1000-8000-00805f9b34fb");
+
+ public static final byte CMD_DISPLAY_SETTINGS = (byte) 0xa0;
+ public static final byte CMD_FIRMWARE_VERSION = (byte) 0xa1;
+ public static final byte CMD_BATTERY = (byte) 0xa2;
+ public static final byte CMD_DATETIME = (byte) 0xa3;
+ public static final byte CMD_USER_DATA = (byte) 0xa9;
+ public static final byte CMD_ALARM = (byte) 0xab;
+ public static final byte CMD_FACTORY_RESET = (byte) 0xad;
+ public static final byte CMD_REALTIME_STEPS = (byte) 0xb1;
+ public static final byte CMD_FETCH_STEPS = (byte) 0xb2;
+ public static final byte CMD_FETCH_SLEEP = (byte) 0xb3;
+ public static final byte CMD_REALTIME_HEARTRATE = (byte) 0xe5;
+ public static final byte CMD_FETCH_HEARTRATE = (byte) 0xe6;
+ public static final byte CMD_NOTIFICATION = (byte) 0xc1;
+ public static final byte CMD_ICON = (byte) 0xc3;
+ public static final byte CMD_DEVICE_SETTINGS = (byte) 0xd3;
+ public static final byte CMD_HEARTRATE_SETTINGS = (byte) 0xd6;
+
+ public static final byte NOTIFICATION_HEADER = (byte) 0x01;
+ public static final byte NOTIFICATION_CALL = (byte) 0x02;
+ public static final byte NOTIFICATION_SMS = (byte) 0x03;
+ public static final byte NOTIFICATION_STOP = (byte) 0x04; // to stop showing incoming call
+
+ public static final byte ICON_QQ = (byte) 0x01;
+ public static final byte ICON_WECHAT = (byte) 0x02;
+ public static final byte ICON_ALARM = (byte) 0x04;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1Coordinator.java
new file mode 100644
index 0000000..99c168f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1Coordinator.java
@@ -0,0 +1,173 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien
+ Gaignon, Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo, Petr Kadlec, Petr
+ Van臎k, protomors
+
+ 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.devices.no1f1;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.regex.Pattern;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.No1F1ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.no1f1.No1F1Support;
+
+public class No1F1Coordinator extends AbstractBLEDeviceCoordinator {
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid hpService = new ParcelUuid(No1F1Constants.UUID_SERVICE_NO1);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(hpService).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("X-RUN.*|MH30.*");
+ }
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new No1F1SampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 3;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "NO1";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return No1F1Support.class;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getNo1F1ActivitySampleDao().queryBuilder();
+ qb.where(No1F1ActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_no1_f1;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_hplus;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_hplus_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1SampleProvider.java
new file mode 100644
index 0000000..8110fba
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/no1f1/No1F1SampleProvider.java
@@ -0,0 +1,84 @@
+/* Copyright (C) 2017-2024 Daniele Gobbetti, protomors
+
+ 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.devices.no1f1;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.No1F1ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.No1F1ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class No1F1SampleProvider extends AbstractSampleProvider<No1F1ActivitySample> {
+
+ private GBDevice mDevice;
+ private DaoSession mSession;
+
+ public No1F1SampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+
+ mSession = session;
+ mDevice = device;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / (float) 8000.0;
+ }
+
+ @Override
+ public No1F1ActivitySample createActivitySample() {
+ return new No1F1ActivitySample();
+ }
+
+ @Override
+ public AbstractDao<No1F1ActivitySample, ?> getSampleDao() {
+ return getSession().getNo1F1ActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return No1F1ActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return No1F1ActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return No1F1ActivitySampleDao.Properties.DeviceId;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/AbstractEarCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/AbstractEarCoordinator.java
new file mode 100644
index 0000000..5ffca1c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/AbstractEarCoordinator.java
@@ -0,0 +1,103 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.nothing;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.nothing.Ear1Support;
+
+public abstract class AbstractEarCoordinator extends AbstractBLClassicDeviceCoordinator {
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Nothing";
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_tws_case, R.string.battery_case);
+ BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_nothing_ear_l, R.string.left_earbud);
+ BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_nothing_ear_r, R.string.right_earbud);
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return Ear1Support.class;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_nothing_ear1);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_headphones);
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_nothingear;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_nothingear_disabled;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new EarSettingsCustomizer();
+ }
+
+ public abstract boolean incrementCounter();
+
+ public abstract boolean supportsLightAncAndTransparency();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/CmfBudsPro2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/CmfBudsPro2Coordinator.java
new file mode 100644
index 0000000..6f2f3e4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/CmfBudsPro2Coordinator.java
@@ -0,0 +1,43 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.nothing;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class CmfBudsPro2Coordinator extends AbstractEarCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^CMF Buds Pro 2$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_nothing_cmf_buds_pro_2;
+ }
+
+ @Override
+ public boolean incrementCounter() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsLightAncAndTransparency() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/Ear1Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/Ear1Coordinator.java
new file mode 100644
index 0000000..6707d89
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/Ear1Coordinator.java
@@ -0,0 +1,44 @@
+/* Copyright (C) 2021-2024 Damien Gaignon, Daniel Dakhno, Daniele Gobbetti,
+ Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.nothing;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class Ear1Coordinator extends AbstractEarCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Nothing ear (1)", Pattern.LITERAL);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_nothingear1;
+ }
+
+ @Override
+ public boolean incrementCounter() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsLightAncAndTransparency() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/Ear2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/Ear2Coordinator.java
new file mode 100644
index 0000000..e5b1a0e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/Ear2Coordinator.java
@@ -0,0 +1,43 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.nothing;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class Ear2Coordinator extends AbstractEarCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Ear (2)", Pattern.LITERAL);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_nothingear2;
+ }
+
+ @Override
+ public boolean incrementCounter() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsLightAncAndTransparency() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/EarSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/EarSettingsCustomizer.java
new file mode 100644
index 0000000..0d5564b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/EarSettingsCustomizer.java
@@ -0,0 +1,92 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.nothing;
+
+import android.os.Parcel;
+
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class EarSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ public static final Creator<EarSettingsCustomizer> CREATOR = new Creator<EarSettingsCustomizer>() {
+ @Override
+ public EarSettingsCustomizer createFromParcel(final Parcel in) {
+ return new EarSettingsCustomizer();
+ }
+
+ @Override
+ public EarSettingsCustomizer[] newArray(final int size) {
+ return new EarSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ final AbstractEarCoordinator earCoordinator = (AbstractEarCoordinator) handler.getDevice().getDeviceCoordinator();
+
+ if (!earCoordinator.supportsLightAncAndTransparency()) {
+ // If light anc and transparency is not supported, remove the values from the preference
+ final Preference audioModePref = handler.findPreference(DeviceSettingsPreferenceConst.PREF_NOTHING_EAR1_AUDIOMODE);
+
+ if (audioModePref != null) {
+ final CharSequence[] originalEntries = ((ListPreference) audioModePref).getEntries();
+ final CharSequence[] originalEntryValues = ((ListPreference) audioModePref).getEntryValues();
+
+ final List<CharSequence> entries = new ArrayList<>();
+ final List<CharSequence> entryValues = new ArrayList<>();
+
+ for (int i = 0; i < originalEntries.length; i++) {
+ if ("anc".equals(originalEntryValues[i].toString()) || "off".equals(originalEntryValues[i].toString())) {
+ entries.add(originalEntries[i]);
+ entryValues.add(originalEntryValues[i]);
+ }
+ }
+
+ ((ListPreference) audioModePref).setEntries(entries.toArray(new CharSequence[0]));
+ ((ListPreference) audioModePref).setEntryValues(entryValues.toArray(new CharSequence[0]));
+ }
+ }
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/EarStickCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/EarStickCoordinator.java
new file mode 100644
index 0000000..1200ec4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nothing/EarStickCoordinator.java
@@ -0,0 +1,43 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.nothing;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class EarStickCoordinator extends AbstractEarCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Ear (stick)", Pattern.LITERAL);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_nothingearstick;
+ }
+
+ @Override
+ public boolean incrementCounter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsLightAncAndTransparency() {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutConstants.java
new file mode 100644
index 0000000..5f0d4b5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutConstants.java
@@ -0,0 +1,113 @@
+/* Copyright (C) 2020-2024 Taavi Eom盲e
+
+ 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.devices.nut;
+
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService;
+
+public class NutConstants {
+ /**
+ * Just battery info
+ */
+ public static final UUID SERVICE_BATTERY = GattService.UUID_SERVICE_BATTERY_SERVICE;
+ public static final UUID CHARAC_BATTERY_INFO = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb");
+
+ /**
+ * Device info available.
+ **/
+ public static final UUID SERVICE_DEVICE_INFO = GattService.UUID_SERVICE_DEVICE_INFORMATION;
+ /**
+ * Firmware version.
+ * Used with {@link NutConstants#SERVICE_DEVICE_INFO}
+ */
+ public static final UUID CHARAC_FIRMWARE_VERSION = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb");
+ /**
+ * System ID.
+ * Used with {@link NutConstants#SERVICE_DEVICE_INFO}
+ */
+ public static final UUID CHARAC_SYSTEM_ID = UUID.fromString("00002a23-0000-1000-8000-00805f9b34fb");
+ /**
+ * Hardware version.
+ * Used with {@link NutConstants#SERVICE_DEVICE_INFO}
+ */
+ public static final UUID CHARAC_HARDWARE_VERSION = UUID.fromString("00002a27-0000-1000-8000-00805f9b34fb");
+ /**
+ * Manufacturer name.
+ * Used with {@link NutConstants#SERVICE_DEVICE_INFO}
+ */
+ public static final UUID CHARAC_MANUFACTURER_NAME = UUID.fromString("00002a29-0000-1000-8000-00805f9b34fb");
+
+
+ /**
+ * Link loss alert service.
+ */
+ public static final UUID SERVICE_LINK_LOSS = UUID.fromString("00001803-0000-1000-8000-00805f9b34fb");
+
+
+ /**
+ * Immediate alert service.
+ */
+ public static final UUID SERVICE_IMMEDIATE_ALERT = UUID.fromString("00001802-0000-1000-8000-00805f9b34fb");
+ /**
+ * Immediate alert level
+ * Used with {@link NutConstants#SERVICE_IMMEDIATE_ALERT}
+ */
+ public static final UUID CHARAC_LINK_LOSS_ALERT_LEVEL = UUID.fromString("00002a06-0000-1000-8000-00805f9b34fb");
+
+
+ /**
+ * Proprietary command endpoint.
+ * TODO: Anything else in this service on other devices?
+ */
+ public static final UUID SERVICE_PROPRIETARY_NUT = UUID.fromString("0000ff00-0000-1000-8000-00805f9b34fb");
+ /**
+ * Shutdown or reset.
+ * Used with {@link NutConstants#SERVICE_PROPRIETARY_NUT}
+ */
+ public static final UUID CHARAC_CHANGE_POWER = UUID.fromString("0000ff01-0000-1000-8000-00805f9b34fb");
+ /**
+ * Commands for proprietary service.
+ * Used with {@link NutConstants#SERVICE_PROPRIETARY_NUT}
+ */
+ public static final UUID CHARAC_DFU_PW = UUID.fromString("0000ff02-0000-1000-8000-00805f9b34fb");
+ /**
+ * Authentication using 16-byte key?
+ * Used with {@link NutConstants#SERVICE_PROPRIETARY_NUT}
+ * TODO: Exists only on Nut Mini?
+ */
+ public static final UUID CHARAC_AUTH_STATUS = UUID.fromString("0000ff05-0000-1000-8000-00805f9b34fb");
+
+
+ /**
+ * Ringing configuration.
+ * TODO: Exact purpose?
+ */
+ public static final UUID SERVICE_UNKNOWN_2 = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb");
+ /**
+ * Ringing configuration.
+ * Used with {@link NutConstants#SERVICE_UNKNOWN_2}
+ * TODO: Something else on other devices?
+ */
+ public static final UUID CHARAC_UNKNOWN_2 = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb");
+
+
+ /**
+ * Very little mention online, specific to Nut devices?
+ */
+ public static final UUID UNKNOWN_3 = UUID.fromString("00001530-0000-1000-8000-00805f9b34fb");
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutCoordinator.java
new file mode 100644
index 0000000..5953502
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutCoordinator.java
@@ -0,0 +1,172 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Jos茅 Rebelo, Petr Van臎k, Taavi Eom盲e
+
+ 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.devices.nut;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.nut.NutSupport;
+
+public class NutCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("nut.*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_ASK;
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ScanFilter filter = new ScanFilter.Builder()
+ .setDeviceName("nut") // Nut Mini
+ .build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_nutmini,
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return NutSupport.class;
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Nut";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false; //TODO: RRSI
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_nut_mini;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_itag;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_itag_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutKey.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutKey.java
new file mode 100644
index 0000000..b1e8b1b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutKey.java
@@ -0,0 +1,265 @@
+/* Copyright (C) 2020-2024 Taavi Eom盲e
+
+ 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.devices.nut;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.math.BigInteger;
+import java.util.AbstractMap;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class NutKey {
+ private static final Logger LOG = LoggerFactory.getLogger(NutKey.class);
+
+ /**
+ * Different from {@link GB#hexStringToByteArray} because
+ * it returns an array of zero bytes when it's given zero bytes
+ * <p>
+ * https://stackoverflow.com/a/140430/4636860
+ *
+ * @param encoded hexadecimal string like "0xcafebabe", "DEADBEEF" or "feeddead"
+ * @return resulting byte array
+ */
+ public static byte[] hexStringToByteArrayNut(String encoded) {
+ if (encoded.startsWith("0x")) {
+ encoded = encoded.substring(2);
+ }
+
+ if ((encoded.length() % 2) != 0) {
+ throw new IllegalArgumentException("Input string must contain an even number of characters");
+ }
+
+ final byte[] result = new byte[encoded.length() / 2];
+ final char[] enc = encoded.toCharArray();
+ for (int i = 0; i < enc.length; i += 2) {
+ result[i / 2] = (byte) Integer.parseInt(String.valueOf(enc[i]) + enc[i + 1], 16);
+ }
+ return result;
+ }
+
+ /**
+ * Returns the array as hexadecimal string space delimited
+ *
+ * @param bytes bytes to return
+ * @return returns
+ */
+ public static String bytesToHex2(byte[] bytes) {
+ char[] hexChars = new char[bytes.length * 3];
+ for (int j = 0; j < bytes.length; j++) {
+ hexChars[j * 3] = GB.HEX_CHARS[(bytes[j] & 0xFF) >>> 4];
+ hexChars[j * 3 + 1] = GB.HEX_CHARS[(bytes[j] & 0xFF) & 0x0F];
+ hexChars[j * 3 + 2] = " ".toCharArray()[0];
+ }
+ return new String(hexChars).toLowerCase();
+ }
+
+ /**
+ * Generates an assembled packet based on inputs
+ *
+ * @param mac the mac of the target device (AA:BB:CC:DD:EE:FF)
+ * @param challenge the received challenge, THE ENTIRE PAYLOAD WITH PREAMBLE!
+ * @param key1 first key
+ * @param key2 second key
+ * @return assembled packet (without the preamble!)
+ */
+ public static byte[] passwordGeneration(String mac, byte[] challenge, BigInteger key1, BigInteger key2) {
+ if (challenge[0] != 0x01) {
+ throw new IllegalArgumentException("Challenge must be given with the preamble");
+ }
+
+ byte[] mac_as_bytes = macAsByteArray(mac);
+ byte[] correct_challenge = new byte[challenge.length - 1];
+ System.arraycopy(challenge, 1, correct_challenge, 0, challenge.length - 1);
+ ArrayUtils.reverse(correct_challenge);
+ BigInteger c = new BigInteger(1, mac_as_bytes).add(new BigInteger(1, correct_challenge));
+
+ BigInteger max64 = BigInteger.ONE.add(BigInteger.ONE).pow(64).subtract(BigInteger.ONE);
+ BigInteger tmp1 = key2.xor(max64);
+ BigInteger result1;
+ if (c.compareTo(tmp1) > 0) {
+ result1 = c.add(key1).subtract(tmp1);
+ } else {
+ result1 = key1;
+ }
+
+ BigInteger result2;
+ if (key2.remainder(BigInteger.ONE.add(BigInteger.ONE)).compareTo(BigInteger.ONE) == 0) {
+ result2 = key2.add(c);
+ } else {
+ result2 = key2.multiply(BigInteger.ONE.add(BigInteger.ONE)).add(c);
+ }
+
+ return byteArraysConcatReverseWithPad(result1.toByteArray(), result2.toByteArray());
+ }
+
+ /**
+ * Reverses the password generation into keys
+ * <p>
+ * This assumes you have:
+ * The MAC of the device, the challenge and response payload
+ * <p>
+ * See also {@link NutKey#passwordGeneration}
+ *
+ * @param challenge the RECEIVED and COMPLETE payload (truncated accordingly)
+ * @param response the SENT and COMPLETE payload
+ * @param deviceMac colon-separated MAC address of the Nut as a string
+ */
+ public static Map.Entry<BigInteger, BigInteger> reversePasswordGeneration(byte[] challenge,
+ byte[] response,
+ String deviceMac) {
+ // The two arrays that were concat. with byteArraysConcatReverseWithPad(orig1, orig2)
+ byte[] original1 = new byte[8];
+ byte[] original2 = new byte[8];
+
+ // The response without preamble
+ if (response[0] != 0x02) {
+ throw new IllegalArgumentException("Response always begins with 0x02");
+ }
+ byte[] cleanResponse = new byte[16];
+ System.arraycopy(response, 1, cleanResponse, 0, cleanResponse.length);
+
+ // The challenge without preamble
+ byte[] cleanChall = new byte[4];
+ System.arraycopy(challenge, 1, cleanChall, 0, cleanChall.length);
+
+ // Reverse the two arrays sent as a response
+ byteArraysDeConcatReverseWithPad(cleanResponse, original1, original2);
+
+ // Two common components in the equation
+ BigInteger a = new BigInteger(1, macAsByteArray(deviceMac));
+ byte[] cleanChallTmp = cleanChall.clone();
+ ArrayUtils.reverse(cleanChallTmp);
+ BigInteger b = new BigInteger(1, cleanChallTmp);
+ BigInteger c = a.add(b);
+ BigInteger max64 = BigInteger.ONE.add(BigInteger.ONE).pow(64).subtract(BigInteger.ONE);
+
+ // We don't know actual keys used yet
+ // There's two possibilities,
+ // either it's directly what's in the packet
+ // orig1 = key1
+ BigInteger key1a = new BigInteger(1, original1);
+ // Or it's derived from key2 using this formula :S
+ // orig1 = c + key1 - (key2 XOR (2^64 - 1)
+ // see below when it might be needed
+
+ // It's either just
+ // orig2 = key2 + c
+ BigInteger key2a = new BigInteger(1, original2).subtract(c);
+ // alternatively
+ // orig2 = 2 * key2 + c
+ BigInteger key2b = new BigInteger(1, original2).multiply(BigInteger.ONE.add(BigInteger.ONE)).subtract(c);
+
+ // Now we have key2a, key2b, key1a,
+ // trying to determine if we can do with just those,
+ // or need to continue
+ byte[] key1a2aresult = passwordGeneration(deviceMac, challenge, key1a, key2a);
+ LOG.debug("Result1a2a:02 %s\n", bytesToHex2(key1a2aresult));
+
+ if (java.util.Arrays.equals(key1a2aresult, cleanResponse)) {
+ LOG.debug("Found key1a & key2a are correct, DONE!");
+ return new AbstractMap.SimpleEntry<>(key1a, key2a);
+ }
+ // Unsuccessful, let's try key1a with key2b
+
+ byte[] key1a2bresult = passwordGeneration(deviceMac, challenge, key1a, key2b);
+ LOG.debug("Result1a2b:02 %s\n", bytesToHex2(key1a2bresult));
+
+ if (java.util.Arrays.equals(key1a2bresult, cleanResponse)) {
+ LOG.debug("Found key1a & key2b are correct, DONE!");
+ return new AbstractMap.SimpleEntry<>(key1a, key2b);
+ }
+
+ // If we're still not done, we have to calculate two possible key1b-s
+ // one for key2a, other for key2b
+ // key1 = c + (key2 XOR (2^64 - 1) + orig1
+ BigInteger key1b2a = c.add(key2a.xor(max64)).add(new BigInteger(1, original1));
+ byte[] key1b2aresult = passwordGeneration(deviceMac, challenge, key1b2a, key2a);
+ LOG.debug("Result1b2a:02 %s\n", bytesToHex2(key1b2aresult));
+
+ if (java.util.Arrays.equals(key1b2aresult, cleanResponse)) {
+ LOG.debug("Found key1b2a & key2b are correct, DONE!");
+ return new AbstractMap.SimpleEntry<>(key1b2a, key2b);
+ }
+
+ BigInteger key1b2b = c.add(key2b.xor(max64)).add(new BigInteger(1, original1));
+ byte[] key1b2bresult = passwordGeneration(deviceMac, challenge, key1b2b, key2b);
+ LOG.debug("Result1b2b:02 %s\n", bytesToHex2(key1b2bresult));
+
+ if (java.util.Arrays.equals(key1b2bresult, cleanResponse)) {
+ LOG.debug("Found key1b2b & key2b are correct, DONE!");
+ return new AbstractMap.SimpleEntry<>(key1b2b, key2b);
+ }
+
+ LOG.warn("Input might be incorrect, a correct key was not found");
+ return null;
+ }
+
+ /**
+ * Turns the MAC address into an array of bytes
+ *
+ * @param address MAC address
+ * @return byte[] containing the MAC address bytes
+ */
+ public static byte[] macAsByteArray(String address) {
+ //noinspection DynamicRegexReplaceableByCompiledPattern
+ return hexStringToByteArrayNut(address.replace(":", ""));
+ }
+
+ /**
+ * Concatenates two byte arrays in reverse and pads with zeros
+ *
+ * @param arr1 first array to concatenate
+ * @param arr2 second array to concatenate
+ * @return 16 bytes that contain the array in reverse, zeros if any parameter is empty
+ */
+ public static byte[] byteArraysConcatReverseWithPad(byte[] arr1, byte[] arr2) {
+ byte[] result = new byte[16];
+ for (int i = 0; i < Math.min(arr2.length, 8); i++) {
+ // Reverse the array - 0-indexed - start shorter arrays from "0" - byte index to handle
+ result[8 - 1 - (8 - Math.min(arr2.length, 8)) - i] = arr2[i + Math.max((arr2.length - 8), 0)];
+ }
+ for (int i = 0; i < Math.min(arr1.length, 8); i++) {
+ result[16 - 1 - (8 - Math.min(arr1.length, 8)) - i] = arr1[i + Math.max((arr1.length - 8), 0)];
+ }
+ return result;
+ }
+
+ /**
+ * De-concatenates two byte arrays in reverse,
+ * places them in specified destinations,
+ * <p>
+ * 16 bytes that contain the array in reverse,
+ * zeros if any parameter is empty
+ *
+ * @param input array to de-concatenate, 16 bytes
+ */
+ public static void byteArraysDeConcatReverseWithPad(byte[] input, byte[] dest1, byte[] dest2) {
+ if (input.length != 16) {
+ throw new IllegalArgumentException("Input must be 16 bytes!");
+ }
+ for (int i = 0; i < 8; i++) {
+ dest2[8 - 1 - i] = input[i];
+ }
+ for (int i = 8; i < 16; i++) {
+ dest1[16 - 1 - i] = input[i];
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoEncoAir2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoEncoAir2Coordinator.java
new file mode 100644
index 0000000..698c328
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoEncoAir2Coordinator.java
@@ -0,0 +1,33 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.oppo;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class OppoEncoAir2Coordinator extends OppoEncoAirCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("OPPO Enco Air2", Pattern.LITERAL);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_oppo_enco_air2;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoEncoAirCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoEncoAirCoordinator.java
new file mode 100644
index 0000000..937a8d8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoEncoAirCoordinator.java
@@ -0,0 +1,75 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.oppo;
+
+import android.util.Pair;
+
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigSide;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigValue;
+
+public class OppoEncoAirCoordinator extends OppoHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("OPPO Enco Air", Pattern.LITERAL);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_oppo_enco_air;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ protected Map<Pair<TouchConfigSide, TouchConfigType>, List<TouchConfigValue>> getTouchOptions() {
+ return new LinkedHashMap<Pair<TouchConfigSide, TouchConfigType>, List<TouchConfigValue>>() {{
+ put(Pair.create(TouchConfigSide.LEFT, TouchConfigType.TAP_2), Arrays.asList(
+ TouchConfigValue.OFF,
+ TouchConfigValue.PLAY_PAUSE,
+ TouchConfigValue.PREVIOUS,
+ TouchConfigValue.NEXT,
+ TouchConfigValue.VOICE_ASSISTANT
+ ));
+ put(Pair.create(TouchConfigSide.LEFT, TouchConfigType.TAP_3), Arrays.asList(
+ TouchConfigValue.OFF,
+ TouchConfigValue.VOICE_ASSISTANT,
+ TouchConfigValue.GAME_MODE
+ ));
+ put(Pair.create(TouchConfigSide.LEFT, TouchConfigType.HOLD), Arrays.asList(
+ TouchConfigValue.OFF,
+ TouchConfigValue.VOLUME_UP,
+ TouchConfigValue.VOLUME_DOWN
+ ));
+
+ // Right side is the same
+ put(Pair.create(TouchConfigSide.RIGHT, TouchConfigType.TAP_2), get(Pair.create(TouchConfigSide.LEFT, TouchConfigType.TAP_2)));
+ put(Pair.create(TouchConfigSide.RIGHT, TouchConfigType.TAP_3), get(Pair.create(TouchConfigSide.LEFT, TouchConfigType.TAP_3)));
+ put(Pair.create(TouchConfigSide.RIGHT, TouchConfigType.HOLD), get(Pair.create(TouchConfigSide.LEFT, TouchConfigType.HOLD)));
+ }};
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesCoordinator.java
new file mode 100644
index 0000000..a05297d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesCoordinator.java
@@ -0,0 +1,101 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.oppo;
+
+import android.util.Pair;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.OppoHeadphonesSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigSide;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigValue;
+
+public abstract class OppoHeadphonesCoordinator extends AbstractBLClassicDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull final GBDevice gbDevice, @NonNull final Device device, @NonNull final DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Oppo";
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return OppoHeadphonesSupport.class;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_nothingear;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_nothingear_disabled;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ final BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_nothing_ear_l, R.string.left_earbud);
+ final BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_nothing_ear_r, R.string.right_earbud);
+ final BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_tws_case, R.string.battery_case);
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings settings = new DeviceSpecificSettings();
+
+ settings.addRootScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS);
+ settings.addSubScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS, R.xml.devicesettings_oppo_headphones_touch_options);
+
+ settings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
+ settings.addSubScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS, R.xml.devicesettings_headphones);
+
+ return settings;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new OppoHeadphonesSettingsCustomizer(getTouchOptions());
+ }
+
+ protected abstract Map<Pair<TouchConfigSide, TouchConfigType>, List<TouchConfigValue>> getTouchOptions();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesPreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesPreferences.java
new file mode 100644
index 0000000..c5538e8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesPreferences.java
@@ -0,0 +1,33 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.oppo;
+
+import java.util.Locale;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigSide;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigType;
+
+public class OppoHeadphonesPreferences {
+ public static String getKey(final TouchConfigSide side, final TouchConfigType type) {
+ return String.format(
+ Locale.ROOT,
+ "oppo_touch__%s__%s",
+ side.name().toLowerCase(Locale.ROOT),
+ type.name().toLowerCase(Locale.ROOT)
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesSettingsCustomizer.java
new file mode 100644
index 0000000..c081027
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/oppo/OppoHeadphonesSettingsCustomizer.java
@@ -0,0 +1,152 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.oppo;
+
+import android.os.Parcel;
+import android.util.Pair;
+
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigSide;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigValue;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class OppoHeadphonesSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ private final Map<Pair<TouchConfigSide, TouchConfigType>, List<TouchConfigValue>> touchOptions;
+
+ public static final Creator<OppoHeadphonesSettingsCustomizer> CREATOR = new Creator<OppoHeadphonesSettingsCustomizer>() {
+ @Override
+ public OppoHeadphonesSettingsCustomizer createFromParcel(final Parcel in) {
+ final Map<Pair<TouchConfigSide, TouchConfigType>, List<TouchConfigValue>> touchOptions = new LinkedHashMap<>();
+ final int numOptions = in.readInt();
+ for (int i = 0; i < numOptions; i++) {
+ final TouchConfigSide touchConfigSide = TouchConfigSide.valueOf(in.readString());
+ final TouchConfigType touchConfigType = TouchConfigType.valueOf(in.readString());
+ final List<TouchConfigValue> values = new ArrayList<>();
+ in.readList(values, TouchConfigValue.class.getClassLoader());
+ touchOptions.put(Pair.create(touchConfigSide, touchConfigType), values);
+ }
+ return new OppoHeadphonesSettingsCustomizer(touchOptions);
+ }
+
+ @Override
+ public OppoHeadphonesSettingsCustomizer[] newArray(final int size) {
+ return new OppoHeadphonesSettingsCustomizer[size];
+ }
+ };
+
+ public OppoHeadphonesSettingsCustomizer(final Map<Pair<TouchConfigSide, TouchConfigType>, List<TouchConfigValue>> touchOptions) {
+ this.touchOptions = touchOptions;
+ }
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ final Set<TouchConfigSide> knownSides = new HashSet<>();
+ final Set<TouchConfigType> knownTypes = new HashSet<>();
+
+ for (final Map.Entry<Pair<TouchConfigSide, TouchConfigType>, List<TouchConfigValue>> e : touchOptions.entrySet()) {
+ final TouchConfigSide side = e.getKey().first;
+ final TouchConfigType type = e.getKey().second;
+ final Set<TouchConfigValue> possibleValues = new HashSet<>(e.getValue());
+
+ knownSides.add(side);
+ knownTypes.add(type);
+
+ final String key = OppoHeadphonesPreferences.getKey(side, type);
+ final ListPreference pref = handler.findPreference(key);
+ if (pref == null) {
+ continue;
+ }
+
+ final CharSequence[] originalEntries = pref.getEntries();
+ final CharSequence[] originalValues = pref.getEntryValues();
+ final CharSequence[] entries = new CharSequence[possibleValues.size()];
+ final CharSequence[] values = new CharSequence[possibleValues.size()];
+ int j = 0;
+ for (int i = 0; i < originalValues.length; i++) {
+ if (possibleValues.contains(TouchConfigValue.valueOf(originalValues[i].toString().toUpperCase(Locale.ROOT)))) {
+ entries[j] = originalEntries[i];
+ values[j] = originalValues[i];
+ j++;
+ }
+ }
+
+ pref.setEntries(entries);
+ pref.setEntryValues(values);
+
+ handler.addPreferenceHandlerFor(key);
+ }
+
+ for (final TouchConfigSide side : TouchConfigSide.values()) {
+ if (!knownSides.contains(side)) {
+ // Side not configurable, hide it completely
+ final Preference header = handler.findPreference("oppo_touch_header_" + side.name().toLowerCase(Locale.ROOT));
+ if (header != null) {
+ header.setVisible(false);
+ continue;
+ }
+ }
+
+ for (final TouchConfigType type : TouchConfigType.values()) {
+ if (!knownTypes.contains(type)) {
+ final String key = OppoHeadphonesPreferences.getKey(side, type);
+ final Preference pref = handler.findPreference(key);
+ if (pref != null) {
+ pref.setVisible(false);
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeInt(touchOptions.size());
+ for (final Map.Entry<Pair<TouchConfigSide, TouchConfigType>, List<TouchConfigValue>> e : touchOptions.entrySet()) {
+ dest.writeString(e.getKey().first.name());
+ dest.writeString(e.getKey().second.name());
+ dest.writeList(e.getValue());
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PBWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PBWInstallHandler.java
new file mode 100644
index 0000000..9fa65d9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PBWInstallHandler.java
@@ -0,0 +1,213 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Uwe Hermann
+
+ 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.devices.pebble;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Writer;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.PebbleUtils;
+
+public class PBWInstallHandler implements InstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(PBWInstallHandler.class);
+
+ private final Context mContext;
+ private PBWReader mPBWReader;
+ private final Uri mUri;
+
+ public PBWInstallHandler(Uri uri, Context context) {
+ mContext = context;
+ mUri = uri;
+ }
+
+ @Override
+ public void validateInstallation(InstallActivity installActivity, GBDevice device) {
+ if (device.isBusy()) {
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ if (device.getType() != DeviceType.PEBBLE || !device.isConnected()) {
+ installActivity.setInfoText("Element cannot be installed");
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ String platformName = PebbleUtils.getPlatformName(device.getModel());
+
+ try {
+ mPBWReader = new PBWReader(mUri, mContext, platformName);
+ } catch (FileNotFoundException e) {
+ installActivity.setInfoText("file not found");
+ installActivity.setInstallEnabled(false);
+ return;
+ } catch (IOException e) {
+ installActivity.setInfoText("error reading file");
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!mPBWReader.isValid()) {
+ installActivity.setInfoText("pbw/pbz is broken or incompatible with your Hardware or Firmware.");
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ GenericItem installItem = new GenericItem();
+ installItem.setIcon(R.drawable.ic_watchapp); // FIXME: do not set twice
+
+ if (mPBWReader.isFirmware()) {
+ installItem.setIcon(R.drawable.ic_firmware);
+
+ String hwRevision = mPBWReader.getHWRevision();
+ if (hwRevision != null && hwRevision.equals(device.getModel())) {
+ installItem.setName(mContext.getString(R.string.pbw_installhandler_pebble_firmware, ""));
+ installItem.setDetails(mContext.getString(R.string.pbwinstallhandler_correct_hw_revision));
+
+ installActivity.setInfoText(mContext.getString(R.string.firmware_install_warning, hwRevision));
+ installActivity.setInstallEnabled(true);
+ } else {
+ if (hwRevision != null) {
+ installItem.setName(mContext.getString(R.string.pbw_installhandler_pebble_firmware, hwRevision));
+ installItem.setDetails(mContext.getString(R.string.pbwinstallhandler_incorrect_hw_revision));
+ }
+ installActivity.setInfoText(mContext.getString(R.string.pbw_install_handler_hw_revision_mismatch));
+ installActivity.setInstallEnabled(false);
+ }
+ } else {
+ GBDeviceApp app = mPBWReader.getGBDeviceApp();
+ if (app != null) {
+ installItem.setName(app.getName());
+ installItem.setDetails(mContext.getString(R.string.pbwinstallhandler_app_item, app.getCreator(), app.getVersion()));
+ int drawable;
+ if (mPBWReader.isLanguage()) {
+ drawable = R.drawable.ic_languagepack;
+ } else {
+ switch (app.getType()) {
+ case WATCHFACE:
+ drawable = R.drawable.ic_watchface;
+ break;
+ case APP_ACTIVITYTRACKER:
+ drawable = R.drawable.ic_activitytracker;
+ break;
+ default:
+ drawable = R.drawable.ic_watchapp;
+ }
+ }
+ installItem.setIcon(drawable);
+
+ installActivity.setInfoText(mContext.getString(R.string.app_install_info, app.getName(), app.getVersion(), app.getCreator()));
+ installActivity.setInstallEnabled(true);
+ } else {
+ installActivity.setInfoText(mContext.getString(R.string.pbw_install_handler_unable_to_install, mUri.getPath()));
+ installActivity.setInstallEnabled(false);
+ }
+ }
+
+ if (installItem.getName() != null) {
+ installActivity.setInstallItem(installItem);
+ }
+ }
+
+ @Override
+ public void onStartInstall(GBDevice device) {
+ if (mPBWReader.isFirmware() || mPBWReader.isLanguage()) {
+ return;
+ }
+
+ File destDir;
+ GBDeviceApp app = mPBWReader.getGBDeviceApp();
+ try {
+ destDir = PebbleUtils.getPbwCacheDir();
+ destDir.mkdirs();
+ FileUtils.copyURItoFile(mContext, mUri, new File(destDir, app.getUUID().toString() + ".pbw"));
+
+ AppManagerActivity.addToAppOrderFile("pbwcacheorder.txt", app.getUUID());
+ } catch (IOException e) {
+ LOG.error("Installation failed: " + e.getMessage(), e);
+ return;
+ }
+
+ File outputFile = new File(destDir, app.getUUID().toString() + ".json");
+ Writer writer;
+ try {
+ writer = new BufferedWriter(new FileWriter(outputFile));
+ } catch (IOException e) {
+ LOG.error("Failed to open output file: " + e.getMessage(), e);
+ return;
+ }
+ try {
+ LOG.info(app.getJSON().toString());
+ JSONObject appJSON = app.getJSON();
+ JSONObject appKeysJSON = mPBWReader.getAppKeysJSON();
+ if (appKeysJSON != null) {
+ appJSON.put("appKeys", appKeysJSON);
+ }
+ writer.write(appJSON.toString());
+
+ writer.close();
+ } catch (IOException e) {
+ LOG.error("Failed to write to output file: " + e.getMessage(), e);
+ } catch (JSONException e) {
+ LOG.error(e.getMessage(), e);
+ }
+
+ InputStream jsConfigFile = mPBWReader.getInputStreamFile("pebble-js-app.js");
+ if (jsConfigFile != null) {
+ try {
+ outputFile = new File(destDir, app.getUUID().toString() + "_config.js");
+ FileUtils.copyStreamToFile(jsConfigFile, outputFile);
+ } catch (IOException e) {
+ LOG.error("Failed to open output file: " + e.getMessage(), e);
+ } finally {
+ try {
+ jsConfigFile.close();
+ } catch (IOException e) {
+ }
+ }
+ }
+ }
+
+ @Override
+ public boolean isValid() {
+ // always pretend it is valid, as we can't know yet about hw/fw version
+ return true;
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PBWReader.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PBWReader.java
new file mode 100644
index 0000000..d4f5cdc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PBWReader.java
@@ -0,0 +1,358 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Uwe Hermann
+
+ 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.devices.pebble;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.PebbleProtocol;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+
+public class PBWReader {
+ private static final Logger LOG = LoggerFactory.getLogger(PBWReader.class);
+ private static final HashMap<String, Byte> appFileTypesMap;
+ private static final HashMap<String, Byte> fwFileTypesMap;
+
+ static {
+ appFileTypesMap = new HashMap<>();
+ appFileTypesMap.put("application", PebbleProtocol.PUTBYTES_TYPE_BINARY);
+ appFileTypesMap.put("resources", PebbleProtocol.PUTBYTES_TYPE_RESOURCES);
+ appFileTypesMap.put("worker", PebbleProtocol.PUTBYTES_TYPE_WORKER);
+ }
+
+ static {
+ fwFileTypesMap = new HashMap<>();
+ fwFileTypesMap.put("firmware", PebbleProtocol.PUTBYTES_TYPE_FIRMWARE);
+ fwFileTypesMap.put("resources", PebbleProtocol.PUTBYTES_TYPE_SYSRESOURCES);
+ }
+
+ private final UriHelper uriHelper;
+ private GBDeviceApp app;
+ private ArrayList<PebbleInstallable> pebbleInstallables = null;
+ private boolean isFirmware = false;
+ private boolean isLanguage = false;
+ private boolean isValid = false;
+ private String hwRevision = null;
+ private short mSdkVersion;
+ private short mAppVersion;
+ private int mIconId;
+ private int mFlags;
+
+ private JSONObject mAppKeys = null;
+
+ public PBWReader(Uri uri, Context context, String platform) throws IOException {
+ uriHelper = UriHelper.get(uri, context);
+
+ if (uriHelper.getFileName().endsWith(".pbl")) {
+ STM32CRC stm32crc = new STM32CRC();
+ try (InputStream fin = uriHelper.openInputStream()) {
+ byte[] buf = new byte[2000];
+ while (fin.available() > 0) {
+ int count = fin.read(buf);
+ stm32crc.addData(buf, count);
+ }
+ }
+ int crc = stm32crc.getResult();
+ // language file
+ app = new GBDeviceApp(UUID.randomUUID(), "Language File", "unknown", "unknown", GBDeviceApp.Type.UNKNOWN);
+ pebbleInstallables = new ArrayList<>();
+ pebbleInstallables.add(new PebbleInstallable("lang", (int) uriHelper.getFileSize(), crc, PebbleProtocol.PUTBYTES_TYPE_FILE));
+
+ isValid = true;
+ isLanguage = true;
+ return;
+ }
+
+ String platformDir = "";
+ if (!uriHelper.getFileName().endsWith(".pbz")) {
+ platformDir = determinePlatformDir(uriHelper, platform);
+
+ if (platform.equals("chalk") && platformDir.equals("")) {
+ return;
+ }
+ }
+
+ LOG.info("using platformdir: '" + platformDir + "'");
+ String appName = null;
+ String appCreator = null;
+ String appVersion = null;
+ UUID appUUID = null;
+
+ ZipEntry ze;
+ pebbleInstallables = new ArrayList<>();
+ byte[] buffer = new byte[1024];
+ int count;
+ try (ZipInputStream zis = new ZipInputStream(uriHelper.openInputStream())) {
+ while ((ze = zis.getNextEntry()) != null) {
+ String fileName = ze.getName();
+ if (fileName.equals(platformDir + "manifest.json")) {
+ long bytes = ze.getSize();
+ if (bytes > 8192) // that should be too much
+ break;
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ while ((count = zis.read(buffer)) != -1) {
+ baos.write(buffer, 0, count);
+ }
+
+ String jsonString = baos.toString();
+ try {
+ JSONObject json = new JSONObject(jsonString);
+ HashMap<String, Byte> fileTypeMap;
+
+ try {
+ JSONObject firmware = json.getJSONObject("firmware");
+ fileTypeMap = fwFileTypesMap;
+ isFirmware = true;
+ hwRevision = firmware.getString("hwrev");
+ } catch (JSONException e) {
+ fileTypeMap = appFileTypesMap;
+ isFirmware = false;
+ }
+ for (Map.Entry<String, Byte> entry : fileTypeMap.entrySet()) {
+ try {
+ JSONObject jo = json.getJSONObject(entry.getKey());
+ String name = jo.getString("name");
+ int size = jo.getInt("size");
+ long crc = jo.getLong("crc");
+ byte type = entry.getValue();
+ pebbleInstallables.add(new PebbleInstallable(platformDir + name, size, (int) crc, type));
+ LOG.info("found file to install: " + platformDir + name);
+ isValid = true;
+ } catch (JSONException e) {
+ // not fatal
+ }
+ }
+ } catch (JSONException e) {
+ // no JSON at all that is a problem
+ isValid = false;
+ e.printStackTrace();
+ break;
+ }
+
+ } else if (fileName.equals("appinfo.json")) {
+ long bytes = ze.getSize();
+ if (bytes > 500000) {
+ LOG.warn(fileName + " exeeds maximum of 500000 bytes");
+ // that should be too much
+ break;
+ }
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ while ((count = zis.read(buffer)) != -1) {
+ baos.write(buffer, 0, count);
+ }
+
+ String jsonString = baos.toString();
+ try {
+ JSONObject json = new JSONObject(jsonString);
+ appName = json.getString("shortName");
+ appCreator = json.getString("companyName");
+ appVersion = json.getString("versionLabel");
+ appUUID = UUID.fromString(json.getString("uuid"));
+ if (json.has("appKeys")) {
+ mAppKeys = json.getJSONObject("appKeys");
+ LOG.info("found appKeys:" + mAppKeys.toString());
+ }
+ } catch (JSONException e) {
+ isValid = false;
+ e.printStackTrace();
+ break;
+ }
+ } else if (fileName.equals(platformDir + "pebble-app.bin")) {
+ zis.read(buffer, 0, 108);
+ byte[] tmp_buf = new byte[32];
+ ByteBuffer buf = ByteBuffer.wrap(buffer);
+ buf.order(ByteOrder.LITTLE_ENDIAN);
+ buf.getLong(); // header, TODO: verify
+ buf.getShort(); // struct version, TODO: verify
+ mSdkVersion = buf.getShort();
+ mAppVersion = buf.getShort();
+ buf.getShort(); // size
+ buf.getInt(); // offset
+ buf.getInt(); // crc
+ buf.get(tmp_buf, 0, 32); // app name
+ buf.get(tmp_buf, 0, 32); // author
+ mIconId = buf.getInt();
+ LOG.info("got icon id from pebble-app.bin: " + mIconId);
+ buf.getInt(); // symbol table addr
+ mFlags = buf.getInt();
+ LOG.info("got flags from pebble-app.bin: " + mFlags);
+ // more follows but, not interesting for us
+ }
+ }
+ if (appUUID != null && appName != null && appCreator != null && appVersion != null) {
+ GBDeviceApp.Type appType = GBDeviceApp.Type.APP_GENERIC;
+
+ if ((mFlags & 16) == 16) {
+ appType = GBDeviceApp.Type.APP_ACTIVITYTRACKER;
+ } else if ((mFlags & 1) == 1) {
+ appType = GBDeviceApp.Type.WATCHFACE;
+ }
+ app = new GBDeviceApp(appUUID, appName, appCreator, appVersion, appType);
+ }
+ else if (!isFirmware) {
+ isValid = false;
+ }
+ }
+ }
+
+ /**
+ * Determines the platform dir to use for the given uri and platform.
+ * @param uriHelper
+ * @param platform
+ * @return the platform dir to use
+ * @throws IOException
+ */
+ private String determinePlatformDir(UriHelper uriHelper, String platform) throws IOException {
+ String platformDir = "";
+
+ /*
+ * for aplite and basalt it is possible to install 2.x apps which have no subfolder
+ * we still prefer the subfolders if present.
+ * chalk needs to be its subfolder
+ */
+ String[] platformDirs;
+ switch (platform) {
+ case "basalt":
+ platformDirs = new String[]{"basalt/"};
+ break;
+ case "chalk":
+ platformDirs = new String[]{"chalk/"};
+ break;
+ case "diorite":
+ platformDirs = new String[]{"diorite/", "aplite/"};
+ break;
+ case "emery":
+ platformDirs = new String[]{"emery/", "basalt/"};
+ break;
+ default:
+ platformDirs = new String[]{"aplite/"};
+ }
+
+ for (String dir : platformDirs) {
+ try (ZipInputStream zis = new ZipInputStream(uriHelper.openInputStream())) {
+ ZipEntry ze;
+ while ((ze = zis.getNextEntry()) != null) {
+ if (ze.getName().startsWith(dir)) {
+ return dir;
+ }
+ }
+ }
+ }
+ return platformDir;
+ }
+
+ public boolean isFirmware() {
+ return isFirmware;
+ }
+
+ public boolean isLanguage() {
+ return isLanguage;
+ }
+
+ public boolean isValid() {
+ return isValid;
+ }
+
+ public GBDeviceApp getGBDeviceApp() {
+ return app;
+ }
+
+ public InputStream getInputStreamFile(String filename) {
+ if (isLanguage) {
+ try {
+ return uriHelper.openInputStream();
+ } catch (FileNotFoundException e) {
+ LOG.warn("file not found: " + e);
+ return null;
+ }
+ }
+ ZipInputStream zis = null;
+ ZipEntry ze;
+ try {
+ zis = new ZipInputStream(uriHelper.openInputStream());
+ while ((ze = zis.getNextEntry()) != null) {
+ if (ze.getName().equals(filename)) {
+ return zis; // return WITHOUT closing the stream!
+ }
+ }
+ zis.close();
+ } catch (Throwable e) {
+ try {
+ if (zis != null) {
+ zis.close();
+ }
+ } catch (IOException e1) {
+ // ignore
+ }
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ public PebbleInstallable[] getPebbleInstallables() {
+ if (pebbleInstallables == null) {
+ return null;
+ }
+ return pebbleInstallables.toArray(new PebbleInstallable[pebbleInstallables.size()]);
+ }
+
+ public String getHWRevision() {
+ return hwRevision;
+ }
+
+ public short getSdkVersion() {
+ return mSdkVersion;
+ }
+
+ public short getAppVersion() {
+ return mAppVersion;
+ }
+
+ public int getFlags() {
+ return mFlags;
+ }
+
+ public int getIconId() {
+ return mIconId;
+ }
+
+ public JSONObject getAppKeysJSON() {
+ return mAppKeys;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleColor.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleColor.java
new file mode 100644
index 0000000..4e9fa9a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleColor.java
@@ -0,0 +1,88 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa
+
+ 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.devices.pebble;
+
+public final class PebbleColor {
+
+ public static final byte Black = (byte) 0b11000000;
+ public static final byte OxfordBlue = (byte) 0b11000001;
+ public static final byte DukeBlue = (byte) 0b11000010;
+ public static final byte Blue = (byte) 0b11000011;
+ public static final byte DarkGreen = (byte) 0b11000100;
+ public static final byte MidnightGreen = (byte) 0b11000101;
+ public static final byte CobaltBlue = (byte) 0b11000110;
+ public static final byte BlueMoon = (byte) 0b11000111;
+ public static final byte IslamicGreen = (byte) 0b11001000;
+ public static final byte JaegerGreen = (byte) 0b11001001;
+ public static final byte TiffanyBlue = (byte) 0b11001010;
+ public static final byte VividCerulean = (byte) 0b11001011;
+ public static final byte Green = (byte) 0b11001100;
+ public static final byte Malachite = (byte) 0b11001101;
+ public static final byte MediumSpringGreen = (byte) 0b11001110;
+ public static final byte Cyan = (byte) 0b11001111;
+ public static final byte BulgarianRose = (byte) 0b11010000;
+ public static final byte ImperialPurple = (byte) 0b11010001;
+ public static final byte Indigo = (byte) 0b11010010;
+ public static final byte ElectricUltramarine = (byte) 0b11010011;
+ public static final byte ArmyGreen = (byte) 0b11010100;
+ public static final byte DarkGray = (byte) 0b11010101;
+ public static final byte Liberty = (byte) 0b11010110;
+ public static final byte VeryLightBlue = (byte) 0b11010111;
+ public static final byte KellyGreen = (byte) 0b11011000;
+ public static final byte MayGreen = (byte) 0b11011001;
+ public static final byte CadetBlue = (byte) 0b11011010;
+ public static final byte PictonBlue = (byte) 0b11011011;
+ public static final byte BrightGreen = (byte) 0b11011100;
+ public static final byte ScreaminGreen = (byte) 0b11011101;
+ public static final byte MediumAquamarine = (byte) 0b11011110;
+ public static final byte ElectricBlue = (byte) 0b11011111;
+ public static final byte DarkCandyAppleRed = (byte) 0b11100000;
+ public static final byte JazzberryJam = (byte) 0b11100001;
+ public static final byte Purple = (byte) 0b11100010;
+ public static final byte VividViolet = (byte) 0b11100011;
+ public static final byte WindsorTan = (byte) 0b11100100;
+ public static final byte RoseVale = (byte) 0b11100101;
+ public static final byte Purpureus = (byte) 0b11100110;
+ public static final byte LavenderIndigo = (byte) 0b11100111;
+ public static final byte Limerick = (byte) 0b11101000;
+ public static final byte Brass = (byte) 0b11101001;
+ public static final byte LightGray = (byte) 0b11101010;
+ public static final byte BabyBlueEyes = (byte) 0b11101011;
+ public static final byte SpringBud = (byte) 0b11101100;
+ public static final byte Inchworm = (byte) 0b11101101;
+ public static final byte MintGreen = (byte) 0b11101110;
+ public static final byte Celeste = (byte) 0b11101111;
+ public static final byte Red = (byte) 0b11110000;
+ public static final byte Folly = (byte) 0b11110001;
+ public static final byte FashionMagenta = (byte) 0b11110010;
+ public static final byte Magenta = (byte) 0b11110011;
+ public static final byte Orange = (byte) 0b11110100;
+ public static final byte SunsetOrange = (byte) 0b11110101;
+ public static final byte BrilliantRose = (byte) 0b11110110;
+ public static final byte ShockingPink = (byte) 0b11110111;
+ public static final byte ChromeYellow = (byte) 0b11111000;
+ public static final byte Rajah = (byte) 0b11111001;
+ public static final byte Melon = (byte) 0b11111010;
+ public static final byte RichBrilliantLavender = (byte) 0b11111011;
+ public static final byte Yellow = (byte) 0b11111100;
+ public static final byte Icterine = (byte) 0b11111101;
+ public static final byte PastelYellow = (byte) 0b11111110;
+ public static final byte White = (byte) 0b11111111;
+ public static final byte Clear = (byte) 0b00000000;
+
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleCoordinator.java
new file mode 100644
index 0000000..ac07249
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleCoordinator.java
@@ -0,0 +1,292 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Damien Gaignon, Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo,
+ Matthieu Baerts, Petr Van臎k
+
+ 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.devices.pebble;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleHealthActivityOverlayDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleHealthActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleMisfitSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleMorpheuzSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.PebbleSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.PebbleUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs;
+
+public class PebbleCoordinator extends AbstractBLClassicDeviceCoordinator {
+ private static final String BG_JS_ENABLED = "pebble_enable_background_javascript";
+ private static final boolean BG_JS_ENABLED_DEFAULT = false;
+
+ public PebbleCoordinator() {
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Pebble.*");
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return PebblePairingActivity.class;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getPebbleHealthActivitySampleDao().queryBuilder();
+ qb.where(PebbleHealthActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getPebbleHealthActivityOverlayDao().queryBuilder();
+ qb.where(PebbleHealthActivityOverlayDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getPebbleMisfitSampleDao().queryBuilder();
+ qb.where(PebbleMisfitSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getPebbleMorpheuzSampleDao().queryBuilder();
+ qb.where(PebbleMorpheuzSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public SampleProvider<? extends AbstractActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ DevicePrefs prefs = GBApplication.getDevicePrefs(device);
+ int activityTracker = prefs.getInt("pebble_activitytracker", SampleProvider.PROVIDER_PEBBLE_HEALTH);
+ switch (activityTracker) {
+ case SampleProvider.PROVIDER_PEBBLE_HEALTH:
+ return new PebbleHealthSampleProvider(device, session);
+ case SampleProvider.PROVIDER_PEBBLE_MISFIT:
+ return new PebbleMisfitSampleProvider(device, session);
+ case SampleProvider.PROVIDER_PEBBLE_MORPHEUZ:
+ return new PebbleMorpheuzSampleProvider(device, session);
+ default:
+ return new PebbleHealthSampleProvider(device, session);
+ }
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ PBWInstallHandler installHandler = new PBWInstallHandler(uri, context);
+ return installHandler.isValid() ? installHandler : null;
+ }
+
+ @Override
+ public boolean supportsFlashing() { return true; }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public int getCannedRepliesSlotCount(final GBDevice device) {
+ return 16;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return PebbleUtils.hasHRM(device.getModel());
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Pebble";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return AppManagerActivity.class;
+ }
+
+ @Override
+ public File getAppCacheDir() throws IOException {
+ return PebbleUtils.getPbwCacheDir();
+ }
+
+ @Override
+ public String getAppCacheSortFilename() {
+ return "pbwcacheorder.txt";
+ }
+
+ @Override
+ public String getAppFileExtension() {
+ return ".pbw";
+ }
+
+ @Override
+ public boolean supportsAppListFetching() {
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ for(GBDevice device : devices){
+ if(device.getType() == DeviceType.PEBBLE){
+ if (device.getFirmwareVersion() != null) {
+ return PebbleUtils.getFwMajor(device.getFirmwareVersion()) < 3;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean supportsAppReordering() {
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ for(GBDevice device : devices){
+ if(device.getType() == DeviceType.PEBBLE){
+ if (device.getFirmwareVersion() != null) {
+ return PebbleUtils.getFwMajor(device.getFirmwareVersion()) >= 3;
+ }
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @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_autoremove_notifications);
+ notifications.add(R.xml.devicesettings_pebble_calls_notifications);
+ notifications.add(R.xml.devicesettings_canned_reply_16);
+ notifications.add(R.xml.devicesettings_canned_dismisscall_16);
+ notifications.add(R.xml.devicesettings_transliteration);
+
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+ calendar.add(R.xml.devicesettings_pebble_calendar);
+
+ final List<Integer> activity = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.ACTIVITY_INFO);
+ activity.add(R.xml.devicesettings_pebble_activity);
+
+ final List<Integer> dev = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ dev.add(R.xml.devicesettings_pebble_developer);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(GBDevice device) {
+ return new PebbleSettingsCustomizer();
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return PebbleSupport.class;
+ }
+
+
+ @Override
+ @StringRes
+ public int getDeviceNameResource() {
+ return R.string.devicetype_pebble;
+ }
+
+
+ @Override
+ @DrawableRes
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_pebble;
+ }
+
+ @Override
+ @DrawableRes
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_pebble_disabled;
+ }
+
+ public boolean isBackgroundJsEnabled(final GBDevice device) {
+ DevicePrefs deviceSpecificPreferences = GBApplication.getDevicePrefs(device);
+ return deviceSpecificPreferences.getBoolean(BG_JS_ENABLED, BG_JS_ENABLED_DEFAULT);
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleHealthSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleHealthSampleProvider.java
new file mode 100644
index 0000000..21013b1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleHealthSampleProvider.java
@@ -0,0 +1,149 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti
+
+ 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.devices.pebble;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collections;
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleHealthActivityOverlay;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleHealthActivityOverlayDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleHealthActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleHealthActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class PebbleHealthSampleProvider extends AbstractSampleProvider<PebbleHealthActivitySample> {
+ public static final int TYPE_LIGHT_SLEEP = 1;
+ public static final int TYPE_DEEP_SLEEP = 2;
+ public static final int TYPE_LIGHT_NAP = 3;
+ public static final int TYPE_DEEP_NAP = 4;
+ public static final int TYPE_WALK = 5;
+ public static final int TYPE_RUN = 6;
+ public static final int TYPE_ACTIVITY = -1;
+
+
+ protected final float movementDivisor = 8000f;
+
+ public PebbleHealthSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public List<PebbleHealthActivitySample> getAllActivitySamples(int timestamp_from, int timestamp_to) {
+ List<PebbleHealthActivitySample> samples = super.getGBActivitySamples(timestamp_from, timestamp_to);
+
+ Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no samples
+ return Collections.emptyList();
+ }
+
+ QueryBuilder<PebbleHealthActivityOverlay> qb = getSession().getPebbleHealthActivityOverlayDao().queryBuilder();
+
+ // I assume it returns the records by id ascending ... (last overlay is dominant)
+ qb.where(PebbleHealthActivityOverlayDao.Properties.DeviceId.eq(dbDevice.getId()), PebbleHealthActivityOverlayDao.Properties.TimestampTo.ge(timestamp_from))
+ .where(PebbleHealthActivityOverlayDao.Properties.TimestampFrom.le(timestamp_to));
+ List<PebbleHealthActivityOverlay> overlayRecords = qb.build().list();
+
+ for (PebbleHealthActivityOverlay overlay : overlayRecords) {
+ for (PebbleHealthActivitySample sample : samples) {
+ if (overlay.getTimestampFrom() <= sample.getTimestamp() && sample.getTimestamp() < overlay.getTimestampTo()) {
+ // patch in the raw kind
+ sample.setRawKind(overlay.getRawKind());
+ }
+ }
+ }
+ detachFromSession();
+ return samples;
+ }
+
+ @Override
+ public AbstractDao<PebbleHealthActivitySample, ?> getSampleDao() {
+ return getSession().getPebbleHealthActivitySampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return PebbleHealthActivitySampleDao.Properties.Timestamp;
+ }
+
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return null;
+ // it is still in the database just hide it for now. remove these two commented lines later
+ //return PebbleHealthActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return PebbleHealthActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public PebbleHealthActivitySample createActivitySample() {
+ return new PebbleHealthActivitySample();
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ switch (rawType) {
+ case TYPE_DEEP_NAP:
+ case TYPE_DEEP_SLEEP:
+ return ActivityKind.DEEP_SLEEP;
+ case TYPE_LIGHT_NAP:
+ case TYPE_LIGHT_SLEEP:
+ return ActivityKind.LIGHT_SLEEP;
+ case TYPE_ACTIVITY:
+ case TYPE_WALK:
+ case TYPE_RUN:
+ return ActivityKind.ACTIVITY;
+ default:
+ return ActivityKind.UNKNOWN;
+ }
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ switch (activityKind) {
+ case DEEP_SLEEP:
+ return TYPE_DEEP_SLEEP;
+ case LIGHT_SLEEP:
+ return TYPE_LIGHT_SLEEP;
+ case ACTIVITY:
+ default:
+ return TYPE_ACTIVITY;
+ }
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / movementDivisor;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleIconID.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleIconID.java
new file mode 100644
index 0000000..59ff076
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleIconID.java
@@ -0,0 +1,125 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa
+
+ 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.devices.pebble;
+
+public final class PebbleIconID {
+
+ public static final int NOTIFICATION_GENERIC = 1;
+ public static final int TIMELINE_MISSED_CALL = 2;
+ public static final int NOTIFICATION_REMINDER = 3;
+ public static final int NOTIFICATION_FLAG = 4;
+ public static final int NOTIFICATION_WHATSAPP = 5;
+ public static final int NOTIFICATION_TWITTER = 6;
+ public static final int NOTIFICATION_TELEGRAM = 7;
+ public static final int NOTIFICATION_GOOGLE_HANGOUTS = 8;
+ public static final int NOTIFICATION_GMAIL = 9;
+ public static final int NOTIFICATION_FACEBOOK_MESSENGER = 10;
+ public static final int NOTIFICATION_FACEBOOK = 11;
+ public static final int AUDIO_CASSETTE = 12;
+ public static final int ALARM_CLOCK = 13;
+ public static final int TIMELINE_WEATHER = 14;
+ public static final int TIMELINE_SUN = 16;
+ public static final int TIMELINE_SPORTS = 17;
+ public static final int GENERIC_EMAIL = 19;
+ public static final int AMERICAN_FOOTBALL = 20;
+ public static final int TIMELINE_CALENDAR = 21;
+ public static final int TIMELINE_BASEBALL = 22;
+ public static final int BIRTHDAY_EVENT = 23;
+ public static final int CAR_RENTAL = 24;
+ public static final int CLOUDY_DAY = 25;
+ public static final int CRICKET_GAME = 26;
+ public static final int DINNER_RESERVATION = 27;
+ public static final int GENERIC_WARNING = 28;
+ public static final int GLUCOSE_MONITOR = 29;
+ public static final int HOCKEY_GAME = 30;
+ public static final int HOTEL_RESERVATION = 31;
+ public static final int LIGHT_RAIN = 32;
+ public static final int LIGHT_SNOW = 33;
+ public static final int MOVIE_EVENT = 34;
+ public static final int MUSIC_EVENT = 35;
+ public static final int NEWS_EVENT = 36;
+ public static final int PARTLY_CLOUDY = 37;
+ public static final int PAY_BILL = 38;
+ public static final int RADIO_SHOW = 39;
+ public static final int SCHEDULED_EVENT = 40;
+ public static final int SOCCER_GAME = 41;
+ public static final int STOCKS_EVENT = 42;
+ public static final int RESULT_DELETED = 43;
+ public static final int CHECK_INTERNET_CONNECTION = 44;
+ public static final int GENERIC_SMS = 45;
+ public static final int RESULT_MUTE = 46;
+ public static final int RESULT_SENT = 47;
+ public static final int WATCH_DISCONNECTED = 48;
+ public static final int DURING_PHONE_CALL = 49;
+ public static final int TIDE_IS_HIGH = 50;
+ public static final int RESULT_DISMISSED = 51;
+ public static final int HEAVY_RAIN = 52;
+ public static final int HEAVY_SNOW = 53;
+ public static final int SCHEDULED_FLIGHT = 54;
+ public static final int GENERIC_CONFIRMATION = 55;
+ public static final int DAY_SEPARATOR = 56;
+ public static final int NO_EVENTS = 57;
+ public static final int NOTIFICATION_BLACKBERRY_MESSENGER = 58;
+ public static final int NOTIFICATION_INSTAGRAM = 59;
+ public static final int NOTIFICATION_MAILBOX = 60;
+ public static final int NOTIFICATION_GOOGLE_INBOX = 61;
+ public static final int RESULT_FAILED = 62;
+ public static final int GENERIC_QUESTION = 63;
+ public static final int NOTIFICATION_OUTLOOK = 64;
+ public static final int RAINING_AND_SNOWING = 65;
+ public static final int REACHED_FITNESS_GOAL = 66;
+ public static final int NOTIFICATION_LINE = 67;
+ public static final int NOTIFICATION_SKYPE = 68;
+ public static final int NOTIFICATION_SNAPCHAT = 69;
+ public static final int NOTIFICATION_VIBER = 70;
+ public static final int NOTIFICATION_WECHAT = 71;
+ public static final int NOTIFICATION_YAHOO_MAIL = 72;
+ public static final int TV_SHOW = 73;
+ public static final int BASKETBALL = 74;
+ public static final int DISMISSED_PHONE_CALL = 75;
+ public static final int NOTIFICATION_GOOGLE_MESSENGER = 76;
+ public static final int NOTIFICATION_HIPCHAT = 77;
+ public static final int INCOMING_PHONE_CALL = 78;
+ public static final int NOTIFICATION_KAKAOTALK = 79;
+ public static final int NOTIFICATION_KIK = 80;
+ public static final int NOTIFICATION_LIGHTHOUSE = 81;
+ public static final int LOCATION = 82;
+ public static final int SETTINGS = 83;
+ public static final int SUNRISE = 84;
+ public static final int SUNSET = 85;
+ public static final int RESULT_UNMUTE = 86;
+ public static final int RESULT_UNMUTE_ALT = 94;
+ public static final int DURING_PHONE_CALL_CENTERED = 95;
+ public static final int TIMELINE_EMPTY_CALENDAR = 96;
+ public static final int THUMBS_UP = 97;
+ public static final int ARROW_UP = 98;
+ public static final int ARROW_DOWN = 99;
+ public static final int ACTIVITY = 100;
+ public static final int SLEEP = 101;
+ public static final int REWARD_BAD = 102;
+ public static final int REWARD_GOOD = 103;
+ public static final int REWARD_AVERAGE = 104;
+ public static final int NOTIFICATION_FACETIME = 110;
+
+ // 4.x only from here
+ public static final int NOTIFICATION_AMAZON = 111;
+ public static final int NOTIFICATION_GOOGLE_MAPS = 112;
+ public static final int NOTIFICATION_GOOGLE_PHOTOS = 113;
+ public static final int NOTIFICATION_IOS_PHOTOS = 114;
+ public static final int NOTIFICATION_LINKEDIN = 115;
+ public static final int NOTIFICATION_SLACK = 116;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleInstallable.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleInstallable.java
new file mode 100644
index 0000000..3c8a204
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleInstallable.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2015-2024 Carsten Pfeiffer
+
+ 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.devices.pebble;
+
+public class PebbleInstallable {
+ final private byte type;
+ final private int crc;
+ final private String fileName;
+ final private int fileSize;
+
+ public PebbleInstallable(String fileName, int fileSize, int crc, byte type) {
+ this.fileName = fileName;
+ this.fileSize = fileSize;
+ this.crc = crc;
+ this.type = type;
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+
+ public int getFileSize() {
+ return fileSize;
+ }
+
+ public byte getType() {
+ return type;
+ }
+
+ public int getCRC() {
+ return crc;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleMisfitSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleMisfitSampleProvider.java
new file mode 100644
index 0000000..cbcb13a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleMisfitSampleProvider.java
@@ -0,0 +1,77 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa
+
+ 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.devices.pebble;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleMisfitSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleMisfitSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class PebbleMisfitSampleProvider extends AbstractSampleProvider<PebbleMisfitSample> {
+
+ protected final float movementDivisor = 300f;
+
+ public PebbleMisfitSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / movementDivisor;
+ }
+
+ @Override
+ public PebbleMisfitSample createActivitySample() {
+ return new PebbleMisfitSample();
+ }
+
+ @Override
+ public AbstractDao<PebbleMisfitSample, ?> getSampleDao() {
+ return getSession().getPebbleMisfitSampleDao();
+ }
+
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return null;
+ }
+
+ @NonNull
+ protected Property getTimestampSampleProperty() {
+ return PebbleMisfitSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ protected Property getDeviceIdentifierSampleProperty() {
+ return PebbleMisfitSampleDao.Properties.DeviceId;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleMorpheuzSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleMorpheuzSampleProvider.java
new file mode 100644
index 0000000..a316284
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleMorpheuzSampleProvider.java
@@ -0,0 +1,80 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa
+
+ 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.devices.pebble;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleMorpheuzSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.PebbleMorpheuzSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class PebbleMorpheuzSampleProvider extends AbstractSampleProvider<PebbleMorpheuzSample> {
+
+ protected float movementDivisor = 5000f;
+
+ public PebbleMorpheuzSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<PebbleMorpheuzSample, ?> getSampleDao() {
+ return getSession().getPebbleMorpheuzSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return PebbleMorpheuzSampleDao.Properties.Timestamp;
+ }
+
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return null; // not supported
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return PebbleMorpheuzSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public PebbleMorpheuzSample createActivitySample() {
+ return new PebbleMorpheuzSample();
+ }
+
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / movementDivisor;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java
new file mode 100644
index 0000000..b3b1849
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebblePairingActivity.java
@@ -0,0 +1,269 @@
+/* Copyright (C) 2016-2024 Andreas B枚hler, Andreas Shimokawa, Carsten
+ Pfeiffer, Daniele Gobbetti, Jos茅 Rebelo, Taavi Eom盲e
+
+ 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.devices.pebble;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.BondingUtil.STATE_DEVICE_CANDIDATE;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+import de.greenrobot.dao.query.Query;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2;
+import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.DeviceDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.BondingInterface;
+import nodomain.freeyourgadget.gadgetbridge.util.BondingUtil;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+
+public class PebblePairingActivity extends AbstractGBActivity implements BondingInterface {
+ private static final Logger LOG = LoggerFactory.getLogger(PebblePairingActivity.class);
+ private final BroadcastReceiver pairingReceiver = BondingUtil.getPairingReceiver(this);
+ private final BroadcastReceiver bondingReceiver = BondingUtil.getBondingReceiver(this);
+
+ private TextView message;
+ private boolean isPairing;
+
+ private GBDeviceCandidate deviceCandidate;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_pebble_pairing);
+
+ message = findViewById(R.id.pebble_pair_message);
+ Intent intent = getIntent();
+ intent.setExtrasClassLoader(GBDeviceCandidate.class.getClassLoader());
+ deviceCandidate = intent.getParcelableExtra(DeviceCoordinator.EXTRA_DEVICE_CANDIDATE);
+
+ String macAddress = null;
+ if (deviceCandidate != null) {
+ macAddress = deviceCandidate.getMacAddress();
+ }
+
+ if (macAddress == null) {
+ Toast.makeText(this, getString(R.string.message_cannot_pair_no_mac), Toast.LENGTH_SHORT).show();
+ onBondingComplete(false);
+ return;
+ }
+
+ BluetoothDevice btDevice = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(macAddress);
+ if (btDevice == null) {
+ GB.toast(this, "No such Bluetooth Device: " + macAddress, Toast.LENGTH_LONG, GB.ERROR);
+ onBondingComplete(false);
+ return;
+ }
+
+ startBonding(btDevice);
+ }
+
+ private void startBonding(BluetoothDevice btDevice) {
+ isPairing = true;
+ message.setText(getString(R.string.pairing, btDevice.getAddress()));
+
+ GBDevice device;
+ if (BondingUtil.isLePebble(btDevice)) {
+ if (!GBApplication.getPrefs().getBoolean("pebble_force_le", false)) {
+ GB.toast(this, "Please switch on \"Always prefer BLE\" option in Pebble settings before pairing you Pebble LE", Toast.LENGTH_LONG, GB.ERROR);
+ onBondingComplete(false);
+ return;
+ }
+
+ device = getMatchingParentDeviceFromDBAndSetVolatileAddress(btDevice);
+ if (device == null) {
+ onBondingComplete(false);
+ return;
+ }
+
+ registerBroadcastReceivers();
+ BondingUtil.connectThenComplete(this, device);
+ return;
+ }
+
+ if (btDevice.getBondState() == BluetoothDevice.BOND_BONDED ||
+ btDevice.getBondState() == BluetoothDevice.BOND_BONDING) {
+ BondingUtil.connectThenComplete(this, deviceCandidate);
+ } else {
+ BondingUtil.tryBondThenComplete(this, deviceCandidate.getDevice(), deviceCandidate.getDevice().getAddress());
+ }
+ }
+
+ private void stopBonding() {
+ isPairing = false;
+ BondingUtil.stopBluetoothBonding(deviceCandidate.getDevice());
+ }
+
+ private GBDevice getMatchingParentDeviceFromDBAndSetVolatileAddress(BluetoothDevice btDevice) {
+ String expectedSuffix = btDevice.getName();
+ expectedSuffix = expectedSuffix.replace("Pebble-LE ", "");
+ expectedSuffix = expectedSuffix.replace("Pebble Time LE ", "");
+ expectedSuffix = expectedSuffix.substring(0, 2) + ":" + expectedSuffix.substring(2);
+ LOG.info("Trying to find a Pebble with BT address suffix " + expectedSuffix);
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ DaoSession session = dbHandler.getDaoSession();
+ DeviceDao deviceDao = session.getDeviceDao();
+ Query<Device> query = deviceDao.queryBuilder().where(DeviceDao.Properties.TypeName.eq("PEBBLE"), DeviceDao.Properties.Identifier.like("%" + expectedSuffix)).build();
+
+ List<Device> devices = query.list();
+ if (devices.size() == 0) {
+ GB.toast("Please pair your non-LE Pebble before pairing the LE one", Toast.LENGTH_SHORT, GB.INFO);
+ onBondingComplete(false);
+ return null;
+ } else if (devices.size() > 1) {
+ GB.toast("Can not match this Pebble LE to a unique device", Toast.LENGTH_SHORT, GB.INFO);
+ onBondingComplete(false);
+ return null;
+ }
+
+ GBDevice gbDevice = DeviceHelper.getInstance().toGBDevice(devices.get(0));
+ gbDevice.setVolatileAddress(btDevice.getAddress());
+ return gbDevice;
+ } catch (Exception e) {
+ GB.toast(getString(R.string.error_retrieving_devices_database), Toast.LENGTH_SHORT, GB.ERROR, e);
+ onBondingComplete(false);
+ return null;
+ }
+ }
+
+ @Override
+ public void onBondingComplete(boolean success) {
+ unregisterBroadcastReceivers();
+ if (success) {
+ startActivity(new Intent(this, ControlCenterv2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
+ } else {
+ startActivity(new Intent(this, DiscoveryActivityV2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
+ }
+
+ // If it's not a LE Pebble, initiate a connection when bonding is complete
+ if (!BondingUtil.isLePebble(getCurrentTarget().getDevice()) && success) {
+ BondingUtil.attemptToFirstConnect(getCurrentTarget().getDevice());
+ }
+ finish();
+ }
+
+ @Override
+ public GBDeviceCandidate getCurrentTarget() {
+ return this.deviceCandidate;
+ }
+
+ @Override
+ public String getMacAddress() {
+ return deviceCandidate.getDevice().getAddress();
+ }
+
+ @Override
+ public boolean getAttemptToConnect() {
+ return true;
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ BondingUtil.handleActivityResult(this, requestCode, resultCode, data);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelable(STATE_DEVICE_CANDIDATE, deviceCandidate);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ deviceCandidate = savedInstanceState.getParcelable(STATE_DEVICE_CANDIDATE);
+ }
+
+ @Override
+ protected void onStart() {
+ registerBroadcastReceivers();
+ super.onStart();
+ }
+
+ @Override
+ protected void onResume() {
+ registerBroadcastReceivers();
+ super.onResume();
+ }
+
+ @Override
+ protected void onDestroy() {
+ unregisterBroadcastReceivers();
+ if (isPairing) {
+ stopBonding();
+ }
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onStop() {
+ unregisterBroadcastReceivers();
+ if (isPairing) {
+ stopBonding();
+ }
+ super.onStop();
+ }
+
+ @Override
+ protected void onPause() {
+ // WARN: Do not stop bonding process during pause!
+ // Bonding process can pause the activity and you might miss broadcasts
+ super.onPause();
+ }
+
+ public void unregisterBroadcastReceivers() {
+ AndroidUtils.safeUnregisterBroadcastReceiver(LocalBroadcastManager.getInstance(this), pairingReceiver);
+ AndroidUtils.safeUnregisterBroadcastReceiver(this, bondingReceiver);
+ }
+
+ public void registerBroadcastReceivers() {
+ LocalBroadcastManager.getInstance(this).registerReceiver(pairingReceiver, new IntentFilter(GBDevice.ACTION_DEVICE_CHANGED));
+ registerReceiver(bondingReceiver, new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED));
+ }
+
+ @Override
+ public Context getContext() {
+ return this;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleSettingsCustomizer.java
new file mode 100644
index 0000000..b55735e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleSettingsCustomizer.java
@@ -0,0 +1,94 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.pebble;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Parcel;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.text.TextWatcher;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.documentfile.provider.DocumentFile;
+import androidx.preference.EditTextPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.SimpleDateFormat;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsUtils;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminRealtimeSettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps.GarminAgpsStatus;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.GB.toast;
+
+public class PebbleSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ public static final Creator<PebbleSettingsCustomizer> CREATOR = new Creator<PebbleSettingsCustomizer>() {
+ @Override
+ public PebbleSettingsCustomizer createFromParcel(final Parcel in) {
+ return new PebbleSettingsCustomizer();
+ }
+
+ @Override
+ public PebbleSettingsCustomizer[] newArray(final int size) {
+ return new PebbleSettingsCustomizer[size];
+ }
+ };
+ private static final Logger LOG = LoggerFactory.getLogger(PebbleSettingsCustomizer.class);
+ private final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ final EditTextPreference pref = handler.findPreference("pebble_mtu_limit");
+ if (pref == null) {
+ return;
+ }
+ pref.setOnBindEditTextListener(p -> {
+ p.setInputType(InputType.TYPE_CLASS_NUMBER);
+ p.setSelection(p.getText().length());
+ });
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/STM32CRC.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/STM32CRC.java
new file mode 100644
index 0000000..b40c2f0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/STM32CRC.java
@@ -0,0 +1,180 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.devices.pebble;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+/*
+ * This code was ported to java from the following cpp source file
+ * https://github.com/smokku/pebble/blob/8f0905197f7cead299c00006ada482095fe9d1a4/daemon/stm32crc.cpp
+ * (Unofficial Pebble watch support for SailfishOS/Jolla)
+ *
+ * Tail code was (hopefully) fixed by @ashimokawa on 7th October 2015
+ */
+
+public class STM32CRC {
+ private int crc;
+ private byte rem;
+ private final byte[] buffer = {0, 0, 0, 0};
+
+ private static final int[] crc_table = {
+ 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9,
+ 0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005,
+ 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61,
+ 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd,
+ 0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9,
+ 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75,
+ 0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011,
+ 0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd,
+ 0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039,
+ 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5,
+ 0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81,
+ 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d,
+ 0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49,
+ 0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95,
+ 0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1,
+ 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d,
+ 0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae,
+ 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072,
+ 0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16,
+ 0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca,
+ 0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde,
+ 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02,
+ 0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066,
+ 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba,
+ 0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e,
+ 0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692,
+ 0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6,
+ 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a,
+ 0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e,
+ 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2,
+ 0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686,
+ 0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a,
+ 0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637,
+ 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb,
+ 0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f,
+ 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53,
+ 0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47,
+ 0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b,
+ 0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff,
+ 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623,
+ 0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7,
+ 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b,
+ 0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f,
+ 0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3,
+ 0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7,
+ 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b,
+ 0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f,
+ 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3,
+ 0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640,
+ 0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c,
+ 0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8,
+ 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24,
+ 0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30,
+ 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec,
+ 0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088,
+ 0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654,
+ 0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0,
+ 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c,
+ 0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18,
+ 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4,
+ 0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0,
+ 0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c,
+ 0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668,
+ 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4,
+ };
+
+ public void reset() {
+ crc = 0xffffffff;
+ rem = 0;
+ buffer[0] = 0;
+ buffer[1] = 0;
+ buffer[2] = 0;
+ buffer[3] = 0;
+ }
+
+ public STM32CRC() {
+ reset();
+ }
+
+ private int calc_crc(int crc, int word) {
+ crc ^= word;
+ crc = (crc << 8) ^ crc_table[(crc >>> 24) & 0xFF];
+ crc = (crc << 8) ^ crc_table[(crc >>> 24) & 0xFF];
+ crc = (crc << 8) ^ crc_table[(crc >>> 24) & 0xFF];
+ crc = (crc << 8) ^ crc_table[(crc >>> 24) & 0xFF];
+ return crc;
+ }
+
+ public void addData(byte[] data, int length) {
+ ByteBuffer buf = ByteBuffer.wrap(data, 0, length);
+ buf.order(ByteOrder.LITTLE_ENDIAN);
+
+ if (rem > 0) {
+ for (; rem < 4 && buf.hasRemaining(); ++rem) {
+ buffer[rem] = buf.get();
+ }
+
+ if (rem == 4) {
+ int word = (buffer[0] & 0xff) |
+ ((buffer[1] & 0xff) << 8) |
+ ((buffer[2] & 0xff) << 16) |
+ ((buffer[3] & 0xff) << 24);
+ crc = calc_crc(crc, word);
+ buffer[0] = 0;
+ buffer[1] = 0;
+ buffer[2] = 0;
+ buffer[3] = 0;
+ rem = 0;
+ }
+ }
+
+
+ while (buf.remaining() >= 4) {
+ int word = buf.getInt();
+ crc = calc_crc(crc, word);
+ }
+
+ while (buf.hasRemaining()) {
+ buffer[rem++] = buf.get();
+ }
+ }
+
+
+ public int getResult() {
+ if (rem > 0) {
+ int word = 0;
+ switch (rem) {
+ case 3:
+ word = (buffer[2] & 0xff) |
+ ((buffer[1] & 0xff) << 8) |
+ ((buffer[0] & 0xff) << 16);
+ break;
+ case 2:
+ word = ((buffer[1] & 0xff)) |
+ ((buffer[0] & 0xff) << 8);
+ break;
+ case 1:
+ word = buffer[0] & 0xff;
+ break;
+ }
+ crc = calc_crc(crc, word);
+ }
+ return crc;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/InfiniTimeDFUPackage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/InfiniTimeDFUPackage.java
new file mode 100644
index 0000000..52ac08c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/InfiniTimeDFUPackage.java
@@ -0,0 +1,59 @@
+/* Copyright (C) 2021-2024 Taavi Eom盲e
+
+ 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.devices.pinetime;
+
+import com.google.gson.annotations.SerializedName;
+
+import java.math.BigInteger;
+import java.util.List;
+
+/**
+ * If this or any of the classes below are renamed, update the proguard rules.
+ */
+public class InfiniTimeDFUPackage {
+ @SerializedName("manifest")
+ InfiniTimeDFUPackageManifest manifest;
+}
+
+class InfiniTimeDFUPackageManifest {
+ @SerializedName("application")
+ InfiniTimeDFUPackageApplication application;
+ @SerializedName("dfu_version")
+ Float dfu_version;
+}
+
+class InfiniTimeDFUPackageApplication {
+ @SerializedName("bin_file")
+ String bin_file;
+ @SerializedName("dat_file")
+ String dat_file;
+ @SerializedName("init_packet_data")
+ InfiniTimeDFUPackagePacketData init_packet_data;
+}
+
+class InfiniTimeDFUPackagePacketData {
+ @SerializedName("application_version")
+ BigInteger application_version;
+ @SerializedName("device_revision")
+ BigInteger device_revision;
+ @SerializedName("device_type")
+ BigInteger device_type;
+ @SerializedName("firmware_crc16")
+ BigInteger firmware_crc16;
+ @SerializedName("softdevice_req")
+ List<Integer> softdevice_req;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeActivitySampleProvider.java
new file mode 100644
index 0000000..a71bc54
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeActivitySampleProvider.java
@@ -0,0 +1,94 @@
+/* Copyright (C) 2021-2024 ITCactus, Patric Gruber
+
+ 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.devices.pinetime;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.List;
+import java.util.Optional;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.PineTimeActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.PineTimeActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class PineTimeActivitySampleProvider extends AbstractSampleProvider<PineTimeActivitySample> {
+ public PineTimeActivitySampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<PineTimeActivitySample, ?> getSampleDao() {
+ return getSession().getPineTimeActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return PineTimeActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return PineTimeActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return PineTimeActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity;
+ }
+
+ /**
+ * Factory method to creates an empty sample of the correct type for this sample provider
+ *
+ * @return the newly created "empty" sample
+ */
+ @Override
+ public PineTimeActivitySample createActivitySample() {
+ return new PineTimeActivitySample();
+ }
+
+ public Optional<PineTimeActivitySample> getSampleForTimestamp(int timestamp) {
+ List<PineTimeActivitySample> foundSamples = this.getGBActivitySamples(timestamp, timestamp);
+ if (foundSamples.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(foundSamples.get(0));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeDFUService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeDFUService.java
new file mode 100644
index 0000000..bf2787e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeDFUService.java
@@ -0,0 +1,35 @@
+/* Copyright (C) 2020-2024 Taavi Eom盲e
+
+ 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.devices.pinetime;
+
+import android.app.Activity;
+
+import no.nordicsemi.android.dfu.DfuBaseService;
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.activities.FwAppInstallerActivity;
+
+public class PineTimeDFUService extends DfuBaseService {
+ @Override
+ protected Class<? extends Activity> getNotificationTarget() {
+ return FwAppInstallerActivity.class;
+ }
+
+ @Override
+ protected boolean isDebug() {
+ return BuildConfig.DEBUG;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeInstallHandler.java
new file mode 100644
index 0000000..5bbfcdc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeInstallHandler.java
@@ -0,0 +1,132 @@
+/* Copyright (C) 2020-2024 MPeter, Taavi Eom盲e
+
+ 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.devices.pinetime;
+
+import android.content.Context;
+import android.net.Uri;
+
+import com.google.gson.Gson;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GBZipFile;
+import nodomain.freeyourgadget.gadgetbridge.util.ZipFileException;
+
+public class PineTimeInstallHandler implements InstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(PineTimeInstallHandler.class);
+ private static final Pattern binNameVersionPattern = Pattern.compile(".*-((?:\\d+\\.){2}\\d+).bin$");
+
+ private final Context context;
+
+ private InfiniTimeDFUPackage dfuPackageManifest;
+
+ public PineTimeInstallHandler(Uri uri, Context context) {
+ this.context = context;
+
+ UriHelper uriHelper;
+
+ try {
+ uriHelper = UriHelper.get(uri, this.context);
+
+ GBZipFile dfuPackage = new GBZipFile(uriHelper.openInputStream());
+ String manifest = new String(dfuPackage.getFileFromZip("manifest.json"));
+
+ if (!manifest.trim().isEmpty()) {
+ dfuPackageManifest = new Gson().fromJson(manifest.trim(), InfiniTimeDFUPackage.class);
+ }
+
+ } catch (ZipFileException e) {
+ LOG.error("Unable to read manifest file.", e);
+ } catch (FileNotFoundException e) {
+ LOG.error("The DFU file was not found.", e);
+ } catch (IOException e) {
+ LOG.error("General IO error occurred.", e);
+ } catch (Exception e) {
+ LOG.error("Unknown error occurred.", e);
+ }
+ }
+
+ @Override
+ public void validateInstallation(InstallActivity installActivity, GBDevice device) {
+ installActivity.setInstallEnabled(true);
+
+ if (device.isBusy()) {
+ LOG.error("Firmware cannot be installed (device busy)");
+ installActivity.setInfoText("Firmware cannot be installed (device busy)");
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (device.getType() != DeviceType.PINETIME_JF || !device.isConnected()) {
+ LOG.error("Firmware cannot be installed (not connected or wrong device)");
+ installActivity.setInfoText("Firmware cannot be installed (not connected or wrong device)");
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!isValid()) {
+ LOG.error("Firmware cannot be installed (not valid)");
+ installActivity.setInfoText("Firmware cannot be installed (not valid)");
+ installActivity.setInstallEnabled(false);
+ }
+
+ GenericItem installItem = new GenericItem();
+ installItem.setIcon(R.drawable.ic_firmware);
+ installItem.setName("PineTime firmware");
+ installItem.setDetails(getVersion());
+
+ installActivity.setInfoText(context.getString(R.string.firmware_install_warning, "(unknown)"));
+ installActivity.setInstallItem(installItem);
+ LOG.debug("Initialized PineTimeInstallHandler");
+ }
+
+ @Override
+ public void onStartInstall(GBDevice device) {
+ }
+
+ @Override
+ public boolean isValid() {
+ return dfuPackageManifest != null &&
+ dfuPackageManifest.manifest != null &&
+ dfuPackageManifest.manifest.application != null &&
+ dfuPackageManifest.manifest.application.bin_file != null;
+ }
+
+ // TODO: obtain version information from manifest file instead
+ private String getVersion() {
+ String binFileName = dfuPackageManifest.manifest.application.bin_file;
+ Matcher regexMatcher = binNameVersionPattern.matcher(binFileName);
+
+ if (regexMatcher.matches())
+ return regexMatcher.group(1);
+ return "(Unknown version)";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeJFConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeJFConstants.java
new file mode 100644
index 0000000..0b0d893
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeJFConstants.java
@@ -0,0 +1,66 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, FintasticMan, ITCactus,
+ Patric Gruber, Stephan Lachnit, Taavi Eom盲e, uli
+
+ 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.devices.pinetime;
+
+import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService;
+import java.util.UUID;
+
+public class PineTimeJFConstants {
+ public static final UUID UUID_SERVICE_MUSIC_CONTROL = UUID.fromString("00000000-78fc-48fe-8e23-433b3a1942d0");
+
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_EVENT = UUID.fromString("00000001-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_STATUS = UUID.fromString("00000002-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_ARTIST = UUID.fromString("00000003-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_TRACK = UUID.fromString("00000004-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_ALBUM = UUID.fromString("00000005-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_POSITION = UUID.fromString("00000006-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_LENGTH_TOTAL = UUID.fromString("00000007-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_TRACK_NUMBER = UUID.fromString("00000008-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_TRACK_TOTAL = UUID.fromString("00000009-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_PLAYBACK_SPEED = UUID.fromString("0000000a-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_REPEAT = UUID.fromString("0000000b-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_MUSIC_SHUFFLE = UUID.fromString("0000000c-78fc-48fe-8e23-433b3a1942d0");
+
+ public static final UUID UUID_SERVICE_NAVIGATION = UUID.fromString("00010000-78fc-48fe-8e23-433b3a1942d0");
+
+ public static final UUID UUID_CHARACTERISTICS_NAVIGATION_FLAGS = UUID.fromString("00010001-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_NAVIGATION_NARRATIVE = UUID.fromString("00010002-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_NAVIGATION_MAN_DISTANCE = UUID.fromString("00010003-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTICS_NAVIGATION_PROGRESS = UUID.fromString("00010004-78fc-48fe-8e23-433b3a1942d0");
+
+ public static final UUID UUID_CHARACTERISTIC_ALERT_NOTIFICATION_EVENT = UUID.fromString("00020001-78fc-48fe-8e23-433b3a1942d0");
+
+ public static final UUID UUID_SERVICE_WEATHER = UUID.fromString("00040000-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTIC_WEATHER_DATA = UUID.fromString("00040001-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTIC_WEATHER_CONTROL = UUID.fromString("00040002-78fc-48fe-8e23-433b3a1942d0");
+
+ public static final UUID UUID_SERVICE_SIMPLE_WEATHER = UUID.fromString("00050000-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTIC_SIMPLE_WEATHER_DATA = UUID.fromString("00050001-78fc-48fe-8e23-433b3a1942d0");
+
+ // since 1.7. https://github.com/InfiniTimeOrg/InfiniTime/blob/develop/doc/MotionService.md
+ public static final UUID UUID_SERVICE_MOTION = UUID.fromString("00030000-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTIC_MOTION_STEP_COUNT = UUID.fromString("00030001-78fc-48fe-8e23-433b3a1942d0");
+ public static final UUID UUID_CHARACTERISTIC_MOTION_RAW_XYZ_VALUES = UUID.fromString("00030002-78fc-48fe-8e23-433b3a1942d0");
+
+ // since https://github.com/InfiniTimeOrg/InfiniTime/pull/1454
+ public static final UUID UUID_CHARACTERISTIC_WORLD_TIME = UUID.fromString("00050001-78fc-48fe-8e23-433b3a1942d0");
+
+ public static final UUID UUID_SERVICE_HEART_RATE = GattService.UUID_SERVICE_HEART_RATE;
+ public static final UUID UUID_CHARACTERISTIC_HEART_RATE_MEASUREMENT = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb");
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeJFCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeJFCoordinator.java
new file mode 100644
index 0000000..9bce7b0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeJFCoordinator.java
@@ -0,0 +1,186 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Davis Mosenkovs, ITCactus, Jos茅 Rebelo, Patric Gruber, Petr Van臎k, Taavi
+ Eom盲e, uli
+
+ 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.devices.pinetime;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.pinetime.PineTimeJFSupport;
+
+public class PineTimeJFCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Pinetime-JF.*|InfiniTime.*");
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ PineTimeInstallHandler handler = new PineTimeInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ public boolean supportsFlashing() { return true; }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new PineTimeActivitySampleProvider(device, session);
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsManualHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Pine64";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 4;
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ return 8;
+ }
+
+ public boolean supportsNavigation() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return PineTimeJFSupport.class;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ // nothing to delete, yet
+ }
+
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_transliteration,
+ R.xml.devicesettings_world_clocks,
+ R.xml.devicesettings_prefix_notification_with_app
+ };
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_pinetime_jf;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_pinetime;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_pinetime_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/weather/WeatherData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/weather/WeatherData.java
new file mode 100644
index 0000000..6918cdd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/weather/WeatherData.java
@@ -0,0 +1,1059 @@
+/* Copyright (C) 2022-2024 Andreas Shimokawa, FintasticMan, Taavi Eom盲e
+
+ 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.devices.pinetime.weather;
+
+/**
+ * Implemented based on and other material:
+ * https://en.wikipedia.org/wiki/METAR
+ * https://www.weather.gov/jetstream/obscurationtypes
+ * http://www.faraim.org/aim/aim-4-03-14-493.html
+ */
+public class WeatherData {
+ /**
+ * OpenWeather's condition codes are a bit annoying,
+ * they've mixed precipitation with other weather events.
+ * <p>
+ * Even if they're absolutely not mutually exclusive.
+ * <p>
+ * So, this function only returns PrecipitationType
+ */
+ public static final PrecipitationType mapOpenWeatherConditionToPineTimePrecipitation(int openWeatherMapCondition) {
+ switch (openWeatherMapCondition) {
+ // Group 2xx: Thunderstorm
+ case 200: // Thunderstorm with light rain
+ case 201: // Thunderstorm with rain
+ case 202: // Thunderstorm with heavy rain
+ case 210: // Light thunderstorm
+ case 211: // Thunderstorm
+ case 230: // Thunderstorm with light drizzle
+ case 231: // Thunderstorm with drizzle
+ case 232: // Thunderstorm with heavy drizzle
+ case 212: // Heavy thunderstorm
+ case 221: // Ragged thunderstorm
+ return PrecipitationType.Rain;
+ // Group 3xx: Drizzle
+ case 300: // Light intensity drizzle
+ case 301: // Drizzle
+ case 302: // Heavy intensity drizzle
+ case 310: // Light intensity drizzle rain
+ case 311: // Drizzle rain
+ case 312: // Heavy intensity drizzle rain
+ case 313: // Shower rain and drizzle
+ case 314: // Heavy shower rain and drizzle
+ case 321: // Shower drizzle
+ return PrecipitationType.Drizzle;
+ // Group 5xx: Rain
+ case 500: // Light rain
+ case 501: // Moderate rain
+ case 502: // Heavy intensity rain
+ case 503: // Very heavy rain
+ case 504: // Extreme rain
+ return PrecipitationType.Rain;
+ case 511: // Freezing rain
+ return PrecipitationType.FreezingRain;
+ case 520: // Light intensity shower rain
+ case 521: // Shower rain
+ case 522: // Heavy intensity shower rain
+ case 531: // Ragged shower rain
+ return PrecipitationType.Rain;
+ // Group 6xx: Snow
+ case 600: // Light snow
+ case 601: // Snow
+ case 620: // Light shower snow
+ case 602: // Heavy snow
+ return PrecipitationType.Snow;
+ case 611: // Sleet
+ case 612: // Shower sleet
+ return PrecipitationType.Sleet;
+ case 621: // Shower snow
+ case 622: // Heavy shower snow
+ return PrecipitationType.Snow;
+ case 615: // Light rain and snow
+ case 616: // Rain and snow
+ return PrecipitationType.Sleet;
+ // Group 7xx: Atmosphere
+ case 701: // Mist
+ case 711: // Smoke
+ case 721: // Haze
+ case 731: // Sandcase/dust whirls
+ case 741: // Fog
+ case 751: // Sand
+ case 761: // Dust
+ case 762: // Volcanic ash
+ return PrecipitationType.Ash;
+ case 771: // Squalls
+ case 781: // Tornado
+ case 900: // Tornado
+ // Group 800: Clear
+ case 800: // Clear sky
+ return PrecipitationType.None;
+ // Group 80x: Clouds
+ case 801: // Few clouds
+ case 802: // Scattered clouds
+ case 803: // Broken clouds
+ case 804: // Overcast clouds
+ // Group 90x: Extreme
+ case 901: // Tropical storm
+ case 903: // Cold
+ case 904: // Hot
+ case 905: // Windy
+ case 906: // Hail
+ return PrecipitationType.Hail;
+ // 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:
+ return PrecipitationType.Length;
+ }
+ }
+
+ ;
+
+ /**
+ * OpenWeather's condition codes are a bit annoying,
+ * they've mixed obscuration with other weather events.
+ *
+ * <p>
+ * Even if they're absolutely not mutually exclusive.
+ * <p>
+ * So, this function only returns ObscurationType
+ */
+ public static final ObscurationType mapOpenWeatherConditionToPineTimeObscuration(int openWeatherMapCondition) {
+ switch (openWeatherMapCondition) {
+ // Group 2xx: Thunderstorm
+ case 200: // Thunderstorm with light rain
+ case 201: // Thunderstorm with rain
+ case 202: // Thunderstorm with heavy rain
+ case 210: // Light thunderstorm
+ case 211: // Thunderstorm
+ case 230: // Thunderstorm with light drizzle
+ case 231: // Thunderstorm with drizzle
+ case 232: // Thunderstorm with heavy drizzle
+ case 212: // Heavy thunderstorm
+ case 221: // Ragged thunderstorm
+ return ObscurationType.Precipitation;
+ // Group 3xx: Drizzle
+ case 300: // Light intensity drizzle
+ case 301: // Drizzle
+ case 302: // Heavy intensity drizzle
+ case 310: // Light intensity drizzle rain
+ case 311: // Drizzle rain
+ case 312: // Heavy intensity drizzle rain
+ case 313: // Shower rain and drizzle
+ case 314: // Heavy shower rain and drizzle
+ case 321: // Shower drizzle
+ return ObscurationType.Precipitation;
+ // Group 5xx: Rain
+ case 500: // Light rain
+ case 501: // Moderate rain
+ case 502: // Heavy intensity rain
+ case 503: // Very heavy rain
+ case 504: // Extreme rain
+ case 511: // Freezing rain
+ case 520: // Light intensity shower rain
+ case 521: // Shower rain
+ case 522: // Heavy intensity shower rain
+ case 531: // Ragged shower rain
+ return ObscurationType.Precipitation;
+ // Group 6xx: Snow
+ case 600: // Light snow
+ case 601: // Snow
+ case 620: // Light shower snow
+ case 602: // Heavy snow
+ case 611: // Sleet
+ case 612: // Shower sleet
+ case 621: // Shower snow
+ case 622: // Heavy shower snow
+ case 615: // Light rain and snow
+ case 616: // Rain and snow
+ return ObscurationType.Precipitation;
+ // Group 7xx: Atmosphere
+ case 701: // Mist
+ return ObscurationType.Mist;
+ case 711: // Smoke
+ return ObscurationType.Smoke;
+ case 721: // Haze
+ return ObscurationType.Haze;
+ case 731: // Sandcase/dust whirls
+ return ObscurationType.Sand;
+ case 741: // Fog
+ return ObscurationType.Fog;
+ case 751: // Sand
+ return ObscurationType.Sand;
+ case 761: // Dust
+ return ObscurationType.Dust;
+ case 762: // Volcanic ash
+ return ObscurationType.Ash;
+ case 771: // Squalls
+ return ObscurationType.Length;
+ case 781: // Tornado
+ case 900: // Tornado
+ return ObscurationType.Precipitation;
+ // Group 800: Clear
+ case 800: // Clear sky
+ return ObscurationType.Length;
+ // Group 80x: Clouds
+ case 801: // Few clouds
+ case 802: // Scattered clouds
+ case 803: // Broken clouds
+ case 804: // Overcast clouds
+ return ObscurationType.Length;
+ // Group 90x: Extreme
+ case 901: // Tropical storm
+ return ObscurationType.Precipitation;
+ case 903: // Cold
+ case 904: // Hot
+ return ObscurationType.Length;
+ case 905: // Windy
+ case 906: // Hail
+ return ObscurationType.Precipitation;
+ // 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
+ return ObscurationType.Length;
+ case 960: // Storm
+ case 961: // Violent storm
+ return ObscurationType.Precipitation;
+ case 902: // Hurricane
+ return ObscurationType.Precipitation;
+ case 962: // Hurricane
+ return ObscurationType.Precipitation;
+ default:
+ return ObscurationType.Length;
+ }
+ }
+
+ ;
+
+ /**
+ * OpenWeather's condition codes are a bit annoying,
+ * they've mixed precipitation with other weather events.
+ * <p>
+ * Even if they're absolutely not mutually exclusive.
+ *
+ * <p>
+ * So, this function only returns SpecialType
+ */
+ public static final SpecialType mapOpenWeatherConditionToPineTimeSpecial(int openWeatherMapCondition) {
+ switch (openWeatherMapCondition) {
+ // Group 2xx: Thunderstorm
+ case 200: // Thunderstorm with light rain
+ case 201: // Thunderstorm with rain
+ case 202: // Thunderstorm with heavy rain
+ case 210: // Light thunderstorm
+ case 211: // Thunderstorm
+ case 230: // Thunderstorm with light drizzle
+ case 231: // Thunderstorm with drizzle
+ case 232: // Thunderstorm with heavy drizzle
+ case 212: // Heavy thunderstorm
+ case 221: // Ragged thunderstorm
+ // Group 3xx: Drizzle
+ case 300: // Light intensity drizzle
+ case 301: // Drizzle
+ case 302: // Heavy intensity drizzle
+ case 310: // Light intensity drizzle rain
+ case 311: // Drizzle rain
+ case 312: // Heavy intensity drizzle rain
+ case 313: // Shower rain and drizzle
+ case 314: // Heavy shower rain and drizzle
+ case 321: // Shower drizzle
+ // Group 5xx: Rain
+ case 500: // Light rain
+ case 501: // Moderate rain
+ case 502: // Heavy intensity rain
+ case 503: // Very heavy rain
+ case 504: // Extreme rain
+ case 511: // Freezing rain
+ case 520: // Light intensity shower rain
+ case 521: // Shower rain
+ case 522: // Heavy intensity shower rain
+ case 531: // Ragged shower rain
+ // Group 6xx: Snow
+ case 600: // Light snow
+ case 601: // Snow
+ case 620: // Light shower snow
+ case 602: // Heavy snow
+ case 611: // Sleet
+ case 612: // Shower sleet
+ case 621: // Shower snow
+ case 622: // Heavy shower snow
+ case 615: // Light rain and snow
+ case 616: // Rain and snow
+ // Group 7xx: Atmosphere
+ case 701: // Mist
+ case 711: // Smoke
+ case 721: // Haze
+ case 731: // Sandcase/dust whirls
+ case 741: // Fog
+ case 751: // Sand
+ case 761: // Dust
+ case 762: // Volcanic ash
+ return SpecialType.Length;
+ case 771: // Squalls
+ return SpecialType.Squall;
+ case 781: // Tornado
+ case 900: // Tornado
+ // Group 800: Clear
+ case 800: // Clear sky
+ // Group 80x: Clouds
+ case 801: // Few clouds
+ case 802: // Scattered clouds
+ case 803: // Broken clouds
+ case 804: // Overcast clouds
+ // Group 90x: Extreme
+ case 901: // Tropical storm
+ case 903: // Cold
+ return SpecialType.Length;
+ case 904: // Hot
+ return SpecialType.Fire;
+ case 905: // Windy
+ case 906: // Hail
+ // 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:
+ return SpecialType.Length;
+ }
+ }
+
+ ;
+
+ /**
+ * OpenWeather's condition codes are a bit annoying,
+ * they've mixed precipitation with other weather events.
+ * <p>
+ * Even if they're absolutely not mutually exclusive.
+ *
+ * <p>
+ * So, this function only returns cloudyness 0-100%
+ */
+ public static final int mapOpenWeatherConditionToCloudCover(int openWeatherMapCondition) {
+ switch (openWeatherMapCondition) {
+ // Group 2xx: Thunderstorm
+ case 200: // Thunderstorm with light rain
+ case 201: // Thunderstorm with rain
+ case 202: // Thunderstorm with heavy rain
+ case 210: // Light thunderstorm
+ case 211: // Thunderstorm
+ case 230: // Thunderstorm with light drizzle
+ case 231: // Thunderstorm with drizzle
+ case 232: // Thunderstorm with heavy drizzle
+ case 212: // Heavy thunderstorm
+ case 221: // Ragged thunderstorm
+ return 100;
+ // Group 3xx: Drizzle
+ case 300: // Light intensity drizzle
+ case 301: // Drizzle
+ case 302: // Heavy intensity drizzle
+ case 310: // Light intensity drizzle rain
+ case 311: // Drizzle rain
+ case 312: // Heavy intensity drizzle rain
+ case 313: // Shower rain and drizzle
+ case 314: // Heavy shower rain and drizzle
+ case 321: // Shower drizzle
+ return 75;
+ // Group 5xx: Rain
+ case 500: // Light rain
+ case 501: // Moderate rain
+ case 502: // Heavy intensity rain
+ case 503: // Very heavy rain
+ case 504: // Extreme rain
+ case 511: // Freezing rain
+ case 520: // Light intensity shower rain
+ case 521: // Shower rain
+ case 522: // Heavy intensity shower rain
+ case 531: // Ragged shower rain
+ return 75;
+ // Group 6xx: Snow
+ case 600: // Light snow
+ case 601: // Snow
+ case 620: // Light shower snow
+ case 602: // Heavy snow
+ case 611: // Sleet
+ case 612: // Shower sleet
+ case 621: // Shower snow
+ case 622: // Heavy shower snow
+ case 615: // Light rain and snow
+ case 616: // Rain and snow
+ return 75;
+ // Group 7xx: Atmosphere
+ case 701: // Mist
+ case 711: // Smoke
+ case 721: // Haze
+ case 731: // Sandcase/dust whirls
+ case 741: // Fog
+ case 751: // Sand
+ case 761: // Dust
+ return -1;
+ case 762: // Volcanic ash
+ return 100;
+ case 771: // Squalls
+ case 781: // Tornado
+ case 900: // Tornado
+ // Group 800: Clouds & Clear
+ case 800: // Clear sky
+ return 0;
+ case 801: // Few clouds
+ return 25;
+ case 802: // Scattered clouds
+ return 50;
+ case 803: // Broken clouds
+ return 75;
+ case 804: // Overcast clouds
+ return 100;
+ // Group 90x: Extreme
+ case 901: // Tropical storm
+ case 903: // Cold
+ case 904: // Hot
+ case 905: // Windy
+ case 906: // Hail
+ return 75;
+ // 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:
+ return -1;
+ }
+ }
+
+ ;
+
+ /**
+ * List of weather condition codes used to determine display
+ * https://openweathermap.org/weather-conditions
+ */
+ public static final ConditionType mapOpenWeatherConditionToPineTimeCondition(int openWeatherMapCondition) {
+ switch (openWeatherMapCondition) {
+ // Group 2xx: Thunderstorm
+ case 200: // Thunderstorm with light rain
+ case 201: // Thunderstorm with rain
+ case 202: // Thunderstorm with heavy rain
+ case 210: // Light thunderstorm
+ case 211: // Thunderstorm
+ case 212: // Heavy thunderstorm
+ case 221: // Ragged thunderstorm
+ case 230: // Thunderstorm with light drizzle
+ case 231: // Thunderstorm with drizzle
+ case 232: // Thunderstorm with heavy drizzle
+ return ConditionType.Thunderstorm;
+ // Group 3xx: Drizzle
+ case 300: // Light intensity drizzle
+ case 301: // Drizzle
+ case 302: // Heavy intensity drizzle
+ case 310: // Light intensity drizzle rain
+ case 311: // Drizzle rain
+ case 312: // Heavy intensity drizzle rain
+ case 313: // Shower rain and drizzle
+ case 314: // Heavy shower rain and drizzle
+ case 321: // Shower drizzle
+ return ConditionType.CloudsAndRain;
+ // Group 5xx: Rain
+ case 500: // Light rain
+ case 501: // Moderate rain
+ case 502: // Heavy intensity rain
+ case 503: // Very heavy rain
+ case 504: // Extreme rain
+ return ConditionType.Rain;
+ case 511: // Freezing rain
+ return ConditionType.Snow;
+ case 520: // Light intensity shower rain
+ case 521: // Shower rain
+ case 522: // Heavy intensity shower rain
+ case 531: // Ragged shower rain
+ return ConditionType.CloudsAndRain;
+ // Group 6xx: Snow
+ case 600: // Light snow
+ case 601: // Snow
+ case 602: // Heavy snow
+ case 611: // Sleet
+ case 612: // Light shower sleet
+ case 613: // Shower sleet
+ case 615: // Light rain and snow
+ case 616: // Rain and snow
+ case 620: // Light shower snow
+ case 621: // Shower snow
+ case 622: // Heavy shower snow
+ return ConditionType.Snow;
+ // Group 7xx: Atmosphere
+ case 701: // Mist
+ case 711: // Smoke
+ case 721: // Haze
+ case 731: // Sandcase/dust whirls
+ case 741: // Fog
+ case 751: // Sand
+ case 761: // Dust
+ case 762: // Volcanic ash
+ case 771: // Squalls
+ case 781: // Tornado
+ return ConditionType.Mist;
+ // Group 800: Clear
+ case 800: // Clear sky
+ return ConditionType.ClearSky;
+ // Group 80x: Clouds
+ case 801: // Few clouds
+ return ConditionType.FewClouds;
+ case 802: // Scattered clouds
+ return ConditionType.Clouds;
+ case 803: // Broken clouds
+ case 804: // Overcast clouds
+ return ConditionType.HeavyClouds;
+ default:
+ return ConditionType.Length;
+ }
+ }
+
+ ;
+
+ /**
+ * Visibility obscuration types
+ */
+ public enum ObscurationType {
+ /**
+ * No obscuration
+ */
+ None(0),
+ /**
+ * Water particles suspended in the air; low visibility; does not fall
+ */
+ Fog(1),
+ /**
+ * Tiny, dry particles in the air; invisible to the eye; opalescent
+ */
+ Haze(2),
+ /**
+ * Small fire-created particles suspended in the air
+ */
+ Smoke(3),
+ /**
+ * Fine rock powder, from for example volcanoes
+ */
+ Ash(4),
+ /**
+ * Fine particles of earth suspended in the air by the wind
+ */
+ Dust(5),
+ /**
+ * Fine particles of sand suspended in the air by the wind
+ */
+ Sand(6),
+ /**
+ * Water particles suspended in the air; low-ish visibility; temperature is near dewpoint
+ */
+ Mist(7),
+ /**
+ * This is special in the sense that the thing falling down is doing the obscuration
+ */
+ Precipitation(8),
+ Length(9);
+
+ public final int value;
+
+ ObscurationType(int value) {
+ this.value = value;
+ }
+ }
+
+ /**
+ * Types of precipitation
+ */
+ public enum PrecipitationType {
+ /**
+ * No precipitation
+ * <p>
+ * Theoretically we could just _not_ send the event, but then
+ * how do we differentiate between no precipitation and
+ * no information about precipitation
+ */
+ None(0),
+ /**
+ * Drops larger than a drizzle; also widely separated drizzle
+ */
+ Rain(1),
+ /**
+ * Fairly uniform rain consisting of fine drops
+ */
+ Drizzle(2),
+ /**
+ * Rain that freezes upon contact with objects and ground
+ */
+ FreezingRain(3),
+ /**
+ * Rain + hail; ice pellets; small translucent frozen raindrops
+ */
+ Sleet(4),
+ /**
+ * Larger ice pellets; falling separately or in irregular clumps
+ */
+ Hail(5),
+ /**
+ * Hail with smaller grains of ice; mini-snowballs
+ */
+ SmallHail(6),
+ /**
+ * Snow...
+ */
+ Snow(7),
+ /**
+ * Frozen drizzle; very small snow crystals
+ */
+ SnowGrains(8),
+ /**
+ * Needles; columns or plates of ice. Sometimes described as "diamond dust". In very cold regions
+ */
+ IceCrystals(9),
+ /**
+ * It's raining down ash, e.g. from a volcano
+ */
+ Ash(10),
+ Length(11);
+
+ public final int value;
+
+ PrecipitationType(int value) {
+ this.value = value;
+ }
+ }
+
+ /**
+ * These are special events that can "enhance" the "experience" of existing weather events
+ */
+ public enum SpecialType {
+ /**
+ * Strong wind with a sudden onset that lasts at least a minute
+ */
+ Squall(0),
+ /**
+ * Series of waves in a water body caused by the displacement of a large volume of water
+ */
+ Tsunami(1),
+ /**
+ * Violent; rotating column of air
+ */
+ Tornado(2),
+ /**
+ * Unplanned; unwanted; uncontrolled fire in an area
+ */
+ Fire(3),
+ /**
+ * Thunder and/or lightning
+ */
+ Thunder(4),
+ Length(5);
+ public final int value;
+
+ SpecialType(int value) {
+ this.value = value;
+ }
+ }
+
+ /**
+ * List of weather condition codes used to determine display
+ */
+ public enum ConditionType {
+ /**
+ * Clear sky
+ */
+ ClearSky(0),
+ /**
+ * Few clouds
+ */
+ FewClouds(1),
+ /**
+ * Scattered clouds
+ */
+ Clouds(2),
+ /**
+ * Broken/heavy clouds
+ */
+ HeavyClouds(3),
+ /**
+ * Shower rain
+ */
+ CloudsAndRain(4),
+ /**
+ * Rain
+ */
+ Rain(5),
+ /**
+ * Thunderstorm
+ */
+ Thunderstorm(6),
+ /**
+ * Snow
+ */
+ Snow(7),
+ /**
+ * Mist
+ */
+ Mist(8),
+ Length(9);
+ public final byte value;
+
+ ConditionType(int value) { this.value = (byte) value; }
+ }
+
+ /**
+ * These are used for weather timeline manipulation
+ * that isn't just adding to the stack of weather events
+ */
+ public enum ControlCodes {
+ /**
+ * How much is stored already
+ */
+ GetLength(0),
+ /**
+ * This wipes the entire timeline
+ */
+ DelTimeline(1),
+ /**
+ * There's a currently valid timeline event with the given type
+ */
+ HasValidEvent(3),
+ Length(4);
+
+ public final int value;
+
+ ControlCodes(int value) {
+ this.value = value;
+ }
+ }
+
+ /**
+ * Events have types
+ * then they're easier to parse after sending them over the air
+ */
+ public enum EventType {
+ /**
+ * @see WeatherData.Obscuration
+ */
+ Obscuration(0),
+ /**
+ * @see WeatherData.Precipitation
+ */
+ Precipitation(1),
+ /**
+ * @see WeatherData.Wind
+ */
+ Wind(2),
+ /**
+ * @see WeatherData.Temperature
+ */
+ Temperature(3),
+ /**
+ * @see WeatherData.AirQuality
+ */
+ AirQuality(4),
+ /**
+ * @see WeatherData.Special
+ */
+ Special(5),
+ /**
+ * @see WeatherData.Pressure
+ */
+ Pressure(6),
+ /**
+ * @see WeatherData.Location
+ */
+ Location(7),
+ /**
+ * @see WeatherData.Clouds
+ */
+ Clouds(8),
+ /**
+ * @see WeatherData.Humidity
+ */
+ Humidity(9),
+ Length(10);
+
+ public final int value;
+
+ EventType(int value) {
+ this.value = value;
+ }
+ }
+
+ ;
+
+ /**
+ * Valid event query
+ */
+ static public class ValidEventQuery {
+ ControlCodes code = ControlCodes.HasValidEvent;
+ EventType eventType;
+ }
+
+ ;
+
+ /**
+ * The header used for further parsing
+ */
+ static public class TimelineHeader {
+ /**
+ * UNIX timestamp
+ */
+ long timestamp;
+ /**
+ * Time in seconds until the event expires
+ * <p>
+ * 32 bits ought to be enough for everyone
+ * <p>
+ * If there's a newer event of the same type then it overrides this one, even if it hasn't expired
+ */
+ int expires;
+ /**
+ * What type of weather-related event
+ */
+ EventType eventType;
+ }
+
+ /**
+ * Specifies how cloudiness is stored
+ */
+ static public class Clouds extends TimelineHeader {
+ /**
+ * Cloud coverage in percentage, 0-100%
+ */
+ byte amount;
+ }
+
+ /**
+ * Specifies how obscuration is stored
+ */
+ static public class Obscuration extends TimelineHeader {
+ /**
+ * Type
+ */
+ ObscurationType type;
+ /**
+ * Visibility distance in meters (0-65535)
+ */
+ int amount;
+ }
+
+ /**
+ * Specifies how precipitation is stored
+ */
+ static public class Precipitation extends TimelineHeader {
+ /**
+ * Type
+ */
+ PrecipitationType type;
+ /**
+ * How much is it going to rain? In millimeters (0-255)
+ */
+ int amount;
+ }
+
+ /**
+ * How wind speed is stored
+ * <p>
+ * In order to represent bursts of wind instead of constant wind,
+ * you have minimum and maximum speeds.
+ * <p>
+ * As direction can fluctuate wildly and some watchfaces might wish to display it nicely,
+ * we're following the aerospace industry weather report option of specifying a range.
+ */
+ static public class Wind extends TimelineHeader {
+ /**
+ * Meters per second (0-255)
+ */
+ byte speedMin;
+ /**
+ * Meters per second (0-255)
+ */
+ byte speedMax;
+ /**
+ * Unitless direction between 0-255; approximately 1 unit per 0.71 degrees
+ */
+ byte directionMin;
+ /**
+ * Unitless direction between 0-255; approximately 1 unit per 0.71 degrees
+ */
+ byte directionMax;
+ }
+
+ /**
+ * How temperature is stored
+ * <p>
+ * As it's annoying to figure out the dewpoint on the watch,
+ * please send it from the companion
+ * <p>
+ * We don't do floats, microdegrees are not useful. Make sure to multiply.
+ */
+ static public class Temperature extends TimelineHeader {
+ /**
+ * Temperature 掳C but multiplied by 100 (e.g. -12.50掳C becomes -1250, 0-65535)
+ */
+ short temperature;
+ /**
+ * Dewpoint 掳C but multiplied by 100 (e.g. -12.50掳C becomes -1250, 0-65535)
+ */
+ short dewPoint;
+ }
+
+ /**
+ * How location info is stored
+ * <p>
+ * This can be mostly static with long expiration,
+ * as it usually is, but it could change during a trip for ex.
+ * so we allow changing it dynamically.
+ * <p>
+ * Location info can be for some kind of map watchface
+ * or daylight calculations, should those be required.
+ */
+ static public class Location extends TimelineHeader {
+ /**
+ * Location name
+ */
+ String location;
+ /**
+ * Altitude relative to sea level in meters (0-65535)
+ */
+ short altitude;
+ /**
+ * Latitude, EPSG:3857 (Google Maps, Openstreetmaps datum, 0-4294967295)
+ */
+ int latitude;
+ /**
+ * Longitude, EPSG:3857 (Google Maps, Openstreetmaps datum, 0-4294967295)
+ */
+ int longitude;
+ }
+
+ /**
+ * How humidity is stored
+ */
+ static public class Humidity extends TimelineHeader {
+ /**
+ * Relative humidity, 0-100%
+ */
+ byte humidity;
+ }
+
+ /**
+ * How air pressure is stored
+ */
+ static public class Pressure extends TimelineHeader {
+ /**
+ * Air pressure in hectopascals (hPa, 0-65535)
+ */
+ short pressure;
+ }
+
+ /**
+ * How special events are stored
+ */
+ static public class Special extends TimelineHeader {
+ /**
+ * Special event's type
+ */
+ SpecialType type;
+ }
+
+ /**
+ * How air quality is stored
+ * <p>
+ * These events are a bit more complex because the topic is not simple,
+ * the intention is to heavy-lift the annoying preprocessing from the watch
+ * this allows watchface or watchapp makers to generate accurate alerts and graphics
+ * <p>
+ * If this needs further enforced standardization, pull requests are welcome
+ */
+ static public class AirQuality extends TimelineHeader {
+ /**
+ * The name of the pollution
+ * <p>
+ * for the sake of better compatibility with watchapps
+ * that might want to use this data for say visuals
+ * don't localize the name.
+ * <p>
+ * Ideally watchapp itself localizes the name, if it's at all needed.
+ * <p>
+ * E.g.
+ * For generic ones use "PM0.1", "PM5", "PM10"
+ * For chemical compounds use the molecular formula e.g. "NO2", "CO2", "O3"
+ * For pollen use the genus, e.g. "Betula" for birch or "Alternaria" for that mold's spores
+ */
+ String polluter;
+ /**
+ * Amount of the pollution in SI units,
+ * otherwise it's going to be difficult to create UI, alerts
+ * and so on and for.
+ * <p>
+ * See more:
+ * https://ec.europa.eu/environment/air/quality/standards.htm
+ * http://www.ourair.org/wp-content/uploads/2012-aaqs2.pdf
+ * <p>
+ * Example units:
+ * count/m鲁 for pollen
+ * 碌gC/m鲁 for micrograms of organic carbon
+ * 碌g/m鲁 sulfates, PM0.1, PM1, PM2, PM10 and so on, dust
+ * mg/m鲁 CO2, CO
+ * ng/m鲁 for heavy metals
+ * <p>
+ * List is not comprehensive, should be improved.
+ * The current ones are what watchapps assume.
+ * <p>
+ * Note: ppb and ppm to concentration should be calculated on the companion, using
+ * the correct formula (taking into account temperature and air pressure)
+ * <p>
+ * Note2: The amount is off by times 100, for two decimal places of precision.
+ * E.g. 54.32碌g/m鲁 is 5432
+ */
+ int amount;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qc35/QC35Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qc35/QC35Coordinator.java
new file mode 100644
index 0000000..69bbb0c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qc35/QC35Coordinator.java
@@ -0,0 +1,91 @@
+/* Copyright (C) 2021-2024 Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.qc35;
+
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qc35.QC35BaseSupport;
+
+public class QC35Coordinator extends AbstractBLClassicDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Bose QC 35.*");
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_qc35
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return QC35BaseSupport.class;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Bose";
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_bose_qc35;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ return new BatteryConfig[]{
+ new BatteryConfig(
+ 0,
+ GBDevice.BATTERY_ICON_DEFAULT,
+ GBDevice.BATTERY_LABEL_DEFAULT,
+ 25,
+ 100
+ )
+ };
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_headphones;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_headphones_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/AppsManagementActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/AppsManagementActivity.java
new file mode 100644
index 0000000..103c1cd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/AppsManagementActivity.java
@@ -0,0 +1,162 @@
+/* Copyright (C) 2021-2024 Daniel Dakhno, Hasan Ammar
+
+ 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.devices.qhybrid;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.PopupMenu;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.widget.PopupMenuCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class AppsManagementActivity extends AbstractGBActivity {
+ ListView appsListView;
+ String[] appNames;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_qhybrid_apps_management);
+
+ initViews();
+ refreshInstalledApps();
+ }
+
+ private void toast(String data) {
+ GB.toast(data, Toast.LENGTH_LONG, GB.INFO);
+ }
+
+ private void refreshInstalledApps() {
+ try {
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ boolean deviceFound = false;
+ for(GBDevice device : devices){
+ if (
+ device.getType() == DeviceType.FOSSILQHYBRID &&
+ device.isConnected() &&
+ (device.getModel().startsWith("DN") || device.getModel().startsWith("IV")) &&
+ device.getState() == GBDevice.State.INITIALIZED
+ ) {
+ String installedAppsJson = device.getDeviceInfo("INSTALLED_APPS").getDetails();
+ if (installedAppsJson == null || installedAppsJson.isEmpty()) {
+ throw new RuntimeException("can't get installed apps");
+ }
+ JSONArray apps = new JSONArray(installedAppsJson);
+ appNames = new String[apps.length()];
+ for (int i = 0; i < apps.length(); i++) {
+ appNames[i] = apps.getString(i);
+ }
+ appsListView.setAdapter(new AppsListAdapter(this, appNames));
+ }
+ return;
+ }
+ } catch (JSONException e) {
+ toast(e.getMessage());
+ finish();
+ return;
+ }
+ throw new RuntimeException("Device not connected");
+ }
+
+ class AppsListAdapter extends ArrayAdapter<String> {
+ public AppsListAdapter(@NonNull Context context, @NonNull String[] objects) {
+ super(context, 0, objects);
+ }
+
+ @NonNull
+ @Override
+ public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
+ if (convertView == null) {
+ LayoutInflater inflater = (LayoutInflater.from(getContext()));
+ convertView = inflater.inflate(R.layout.fossil_hr_row_installed_app, null);
+ }
+ TextView nameView = convertView.findViewById(R.id.fossil_hr_row_app_name);
+ nameView.setText(getItem(position));
+ return nameView;
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(deviceUpdateReceiver);
+ finish();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ LocalBroadcastManager.getInstance(this).registerReceiver(deviceUpdateReceiver, new IntentFilter(GBDevice.ACTION_DEVICE_CHANGED));
+ }
+
+ BroadcastReceiver deviceUpdateReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ refreshInstalledApps();
+ }
+ };
+
+ private void initViews() {
+ appsListView = findViewById(R.id.qhybrid_apps_list);
+ appsListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, final int position, long id) {
+ PopupMenu menu = new PopupMenu(AppsManagementActivity.this, view);
+ menu.getMenu()
+ .add("uninstall")
+ .setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ Intent intent = new Intent(QHybridSupport.QHYBRID_COMMAND_UNINSTALL_APP);
+ intent.putExtra("EXTRA_APP_NAME", appNames[position]);
+ LocalBroadcastManager.getInstance(AppsManagementActivity.this).sendBroadcast(intent);
+ return true;
+ }
+ });
+ menu.show();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CalibrationActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CalibrationActivity.java
new file mode 100644
index 0000000..b0e4157
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CalibrationActivity.java
@@ -0,0 +1,158 @@
+/* Copyright (C) 2020-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.Spinner;
+import android.widget.SpinnerAdapter;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport;
+
+public class CalibrationActivity extends AbstractGBActivity {
+ enum HAND{
+ MINUTE,
+ HOUR,
+ SUB;
+
+ public String getDisplayName(){
+ return name().substring(0, 1).toUpperCase() + name().substring(1).toLowerCase();
+ }
+
+ public String getVariableName(){
+ return name();
+ }
+
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getDisplayName();
+ }
+ }
+
+ enum MOVE_BUTTON{
+ CLOCKWISE_ONE(R.id.qhybrid_calibration_clockwise_1, 1),
+ CLOCKWISE_TEN(R.id.qhybrid_calibration_clockwise_10, 10),
+ CLOCKWISE_HUNRED(R.id.qhybrid_calibration_clockwise_100, 100),
+
+ COUNTER_CLOCKWISE_ONE(R.id.qhybrid_calibration_counter_clockwise_1, -1),
+ COUNTER_CLOCKWISE_TEN(R.id.qhybrid_calibration_counter_clockwise_10, -10),
+ COUNTER_CLOCKWISE_HUNRED(R.id.qhybrid_calibration_counter_clockwise_100, -100),
+ ;
+
+ int layoutId;
+ int distance;
+
+ MOVE_BUTTON(int layoutId, int distance) {
+ this.layoutId = layoutId;
+ this.distance = distance;
+ }
+ }
+
+ HAND selectedHand = HAND.MINUTE;
+ LocalBroadcastManager localBroadcastManager;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_qhybrid_calibration);
+
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ boolean atLeastOneConnected = false;
+ for(GBDevice device : devices){
+ if(device.getType() == DeviceType.FOSSILQHYBRID){
+ atLeastOneConnected = true;
+ break;
+ }
+ }
+
+ if(!atLeastOneConnected){
+ Toast.makeText(this, R.string.watch_not_connected, Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+
+ localBroadcastManager = LocalBroadcastManager.getInstance(this);
+
+ localBroadcastManager.sendBroadcast(
+ new Intent(QHybridSupport.QHYBRID_COMMAND_CONTROL)
+ );
+
+ initViews();
+ }
+
+ private void initViews(){
+ Spinner handSpinner = findViewById(R.id.qhybrid_calibration_hand_spinner);
+ handSpinner.setAdapter(new ArrayAdapter<HAND>(this, android.R.layout.simple_spinner_dropdown_item, HAND.values()));
+ handSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ selectedHand = (HAND) parent.getSelectedItem();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+
+ }
+ });
+
+ for(final MOVE_BUTTON buttonDeclaration : MOVE_BUTTON.values()) {
+ final Button button = findViewById(buttonDeclaration.layoutId);
+
+ button.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(QHybridSupport.QHYBRID_COMMAND_MOVE);
+ intent.putExtra("EXTRA_DISTANCE_" + selectedHand.getVariableName(), (short) buttonDeclaration.distance);
+
+ localBroadcastManager.sendBroadcast(intent);
+ }
+ });
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ finish();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (localBroadcastManager != null) {
+ localBroadcastManager.sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_SAVE_CALIBRATION));
+ localBroadcastManager.sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_UNCONTROL));
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CommuteActionsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CommuteActionsActivity.java
new file mode 100644
index 0000000..7f0b38a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CommuteActionsActivity.java
@@ -0,0 +1,213 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver
+
+ 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.devices.qhybrid;
+
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.recyclerview.widget.ItemTouchHelper;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport;
+
+public class CommuteActionsActivity extends AbstractGBActivity implements CommuteActionsListAdapter.ItemClickListener, DialogInterface.OnClickListener, View.OnClickListener {
+ protected final List<String> actionsList = new ArrayList<>();
+ private static final Logger LOG = LoggerFactory.getLogger(CommuteActionsActivity.class);
+ private SharedPreferences sharedPreferences;
+ private ItemTouchHelper actionTouchHelper;
+ private CommuteActionsListAdapter actionsListAdapter;
+ static public final String CONFIG_KEY_Q_ACTIONS = "Q_ACTIONS";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_commute_actions);
+ sharedPreferences = GBApplication.getPrefs().getPreferences();
+
+ findViewById(R.id.actionAddFab).setOnClickListener(this);
+
+ // set up the RecyclerView
+ RecyclerView recyclerView = findViewById(R.id.actionsListView);
+ recyclerView.setLayoutManager(new LinearLayoutManager(this));
+ actionsListAdapter = new CommuteActionsListAdapter(this, actionsList);
+ actionsListAdapter.setClickListener(this);
+ recyclerView.setAdapter(actionsListAdapter);
+ refreshActions();
+
+ // set up touch helper for reordering items
+ ItemTouchHelper.Callback actionTouchHelperCallback = new ActionTouchHelperCallback(actionsListAdapter);
+ actionTouchHelper = new ItemTouchHelper(actionTouchHelperCallback);
+ actionTouchHelper.attachToRecyclerView(recyclerView);
+ }
+
+ private void refreshActions() {
+ JSONArray actionArray = null;
+ try {
+ actionArray = new JSONArray(sharedPreferences.getString(CONFIG_KEY_Q_ACTIONS, "[]"));
+ actionsList.clear();
+ for (int i = 0; i < actionArray.length(); i++)
+ actionsList.add(actionArray.getString(i));
+
+ actionsListAdapter.notifyDataSetChanged();
+ } catch (JSONException e) {
+ LOG.error("Error retrieving commute actions", e);
+ }
+ }
+
+ private void putActionItems(List<String> actions) {
+ JSONArray array = new JSONArray();
+ for (String action : actions) array.put(action);
+
+ sharedPreferences.edit().putString(CONFIG_KEY_Q_ACTIONS, array.toString()).apply();
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (v.getId() == R.id.actionAddFab) {
+ final EditText input = new EditText(this);
+ input.setId(0);
+ LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.MATCH_PARENT);
+ input.setLayoutParams(lp);
+
+ new MaterialAlertDialogBuilder(this)
+ .setView(input)
+ .setNegativeButton(R.string.fossil_hr_new_action_cancel, null)
+ .setPositiveButton(R.string.ok, this)
+ .setTitle(R.string.fossil_hr_new_action)
+ .show();
+ }
+ }
+
+ @Override
+ public void onItemClick(View view, final int position) {
+ final EditText input = new EditText(this);
+ input.setId(0);
+ input.setText(actionsListAdapter.getItem(position));
+ LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.MATCH_PARENT);
+ input.setLayoutParams(lp);
+
+ new MaterialAlertDialogBuilder(this)
+ .setView(input)
+ .setNegativeButton(R.string.fossil_hr_edit_action_delete, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ actionsList.remove(position);
+ putActionItems(actionsList);
+ refreshActions();
+
+ LocalBroadcastManager.getInstance(CommuteActionsActivity.this).sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_OVERWRITE_BUTTONS));
+ }
+ })
+ .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ actionsList.set(position, input.getText().toString());
+ putActionItems(actionsList);
+ refreshActions();
+
+ LocalBroadcastManager.getInstance(CommuteActionsActivity.this).sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_OVERWRITE_BUTTONS));
+ }
+ })
+ .setTitle(R.string.fossil_hr_edit_action)
+ .show();
+ }
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ EditText actionEditText = ((AlertDialog) dialog).findViewById(0);
+
+ String action = actionEditText.getText().toString();
+ try {
+ JSONArray actionArray = new JSONArray(sharedPreferences.getString(CONFIG_KEY_Q_ACTIONS, "[]"));
+ actionArray.put(action);
+ sharedPreferences.edit().putString(CONFIG_KEY_Q_ACTIONS, actionArray.toString()).apply();
+ refreshActions();
+
+ LocalBroadcastManager.getInstance(CommuteActionsActivity.this).sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_OVERWRITE_BUTTONS));
+ } catch (JSONException e) {
+ LOG.error("Error adding new commute action", e);
+ }
+ }
+
+ public void startDragging(RecyclerView.ViewHolder viewHolder) {
+ actionTouchHelper.startDrag(viewHolder);
+ }
+
+ public class ActionTouchHelperCallback extends ItemTouchHelper.Callback {
+
+ private final CommuteActionsListAdapter actionsListAdapter;
+
+ public ActionTouchHelperCallback(CommuteActionsListAdapter actionsListAdapter) {
+ this.actionsListAdapter = actionsListAdapter;
+ }
+
+ @Override
+ public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
+ //we only support up and down movement and only for moving, not for swiping apps away
+ return makeMovementFlags(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0);
+ }
+
+ @Override
+ public boolean isLongPressDragEnabled() {
+ return false;
+ }
+
+ @Override
+ public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) {
+ actionsListAdapter.onItemMove(source.getAdapterPosition(), target.getAdapterPosition());
+ putActionItems(actionsList);
+ return true;
+ }
+
+ @Override
+ public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
+ //nothing to do
+ }
+
+ @Override
+ public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
+ super.clearView(recyclerView, viewHolder);
+ putActionItems(actionsList);
+ LocalBroadcastManager.getInstance(CommuteActionsActivity.this).sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_OVERWRITE_BUTTONS));
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CommuteActionsListAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CommuteActionsListAdapter.java
new file mode 100644
index 0000000..ab9f069
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/CommuteActionsListAdapter.java
@@ -0,0 +1,111 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver
+
+ 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.devices.qhybrid;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.Collections;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class CommuteActionsListAdapter extends RecyclerView.Adapter<CommuteActionsListAdapter.ViewHolder> {
+ private CommuteActionsActivity parentActivity;
+ private List<String> mData;
+ private LayoutInflater mInflater;
+ private ItemClickListener mClickListener;
+
+ // data is passed into the constructor
+ public CommuteActionsListAdapter(Context context, List<String> data) {
+ this.mInflater = LayoutInflater.from(context);
+ this.mData = data;
+ this.parentActivity = (CommuteActionsActivity) context;
+ }
+
+ // inflates the row layout from xml when needed
+ @Override
+ public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ View view = mInflater.inflate(R.layout.fossil_hr_row_commute_action, parent, false);
+ return new ViewHolder(view);
+ }
+
+ // binds the data to the TextView in each row
+ @Override
+ public void onBindViewHolder(final ViewHolder holder, int position) {
+ String line = mData.get(position);
+ holder.mTextView.setText(line);
+ holder.mDragHandle.setOnTouchListener(new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(View view, MotionEvent motionEvent) {
+ parentActivity.startDragging(holder);
+ return true;
+ }
+ });
+ }
+
+ // total number of rows
+ @Override
+ public int getItemCount() {
+ return mData.size();
+ }
+
+ public void onItemMove(int from, int to) {
+ Collections.swap(mData, from, to);
+ notifyItemMoved(from, to);
+ }
+
+ // stores and recycles views as they are scrolled off screen
+ public class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
+ TextView mTextView;
+ ImageView mDragHandle;
+
+ ViewHolder(View itemView) {
+ super(itemView);
+ mTextView = itemView.findViewById(R.id.fossil_hr_row_commute_action);
+ mDragHandle = (ImageView) itemView.findViewById(R.id.drag_handle);
+ itemView.setOnClickListener(this);
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (mClickListener != null) mClickListener.onItemClick(view, getAdapterPosition());
+ }
+ }
+
+ // convenience method for getting data at click position
+ String getItem(int id) {
+ return mData.get(id);
+ }
+
+ // allows clicks events to be caught
+ void setClickListener(ItemClickListener itemClickListener) {
+ this.mClickListener = itemClickListener;
+ }
+
+ // parent activity will implement this method to respond to click events
+ public interface ItemClickListener {
+ void onItemClick(View view, int position);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FileManagementActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FileManagementActivity.java
new file mode 100644
index 0000000..bb09113
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FileManagementActivity.java
@@ -0,0 +1,194 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Arjan Schrijver, Daniel Dakhno
+
+ 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.devices.qhybrid;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.CompoundButton;
+import android.widget.Spinner;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.materialswitch.MaterialSwitch;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.file.FileHandle;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class FileManagementActivity extends AbstractGBActivity implements View.OnClickListener {
+ private final int REQUEST_CODE_PICK_UPLOAD_FILE = 0;
+
+ private Spinner fileTypesSpinner;
+ private MaterialSwitch encryptedFile;
+ private boolean generateFileHeader = false;
+
+ private boolean warningDisplayed = false;
+
+ BroadcastReceiver fileResultReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent.getAction().equals(QHybridSupport.QHYBRID_ACTION_DOWNLOADED_FILE)) {
+ boolean success = intent.getBooleanExtra("EXTRA_SUCCESS", false);
+ if (!success) {
+ Toast.makeText(FileManagementActivity.this, "error downloading file, check logcat", Toast.LENGTH_LONG).show();
+ return;
+ }
+ String path = intent.getStringExtra("EXTRA_PATH");
+ Toast.makeText(FileManagementActivity.this, "downloaded file " + path, Toast.LENGTH_LONG).show();
+ }else if(intent.getAction().equals(QHybridSupport.QHYBRID_ACTION_UPLOADED_FILE)) {
+ boolean success = intent.getBooleanExtra("EXTRA_SUCCESS", false);
+ if (!success) {
+ Toast.makeText(FileManagementActivity.this, "error uploading file, check logcat", Toast.LENGTH_LONG).show();
+ return;
+ }
+ Toast.makeText(FileManagementActivity.this, "uploaded file", Toast.LENGTH_LONG).show();
+ }
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_qhybrid_file_management);
+
+ initViews();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(QHybridSupport.QHYBRID_ACTION_DOWNLOADED_FILE);
+ filter.addAction(QHybridSupport.QHYBRID_ACTION_UPLOADED_FILE);
+ LocalBroadcastManager.getInstance(this).registerReceiver(fileResultReceiver, filter);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(fileResultReceiver);
+ }
+
+ private void initViews() {
+ FileHandle[] handles = FileHandle.values();
+ fileTypesSpinner = findViewById(R.id.qhybrid_file_types);
+ fileTypesSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, handles));
+
+ encryptedFile = findViewById(R.id.qhybrid_switch_encrypted_file);
+
+ findViewById(R.id.qhybrid_button_download_file).setOnClickListener(this);
+ findViewById(R.id.qhybrid_button_upload_file).setOnClickListener(this);
+
+ ((MaterialSwitch) findViewById(R.id.sqhybrid_switch_generate_file_header)).setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ generateFileHeader = isChecked;
+ fileTypesSpinner.setClickable(isChecked);
+ fileTypesSpinner.setAlpha(isChecked ? 1f : 0.2f);
+ }
+ });
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode != REQUEST_CODE_PICK_UPLOAD_FILE) return;
+ if (resultCode != RESULT_OK) return;
+ String fileName;
+ try {
+ fileName = AndroidUtils.getFilePath(this, data.getData());
+ if (fileName == null) {
+ return;
+ }
+ } catch (IllegalArgumentException e) {
+ GB.toast("please choose a local file", Toast.LENGTH_LONG, GB.WARN);
+ return;
+ }
+
+ try {
+ if (!warningDisplayed) {
+ FileInputStream fis = new FileInputStream(fileName);
+ short fileHandle = (short) (fis.read() | (fis.read() << 8));
+ fis.close();
+
+ boolean handleFound = FileHandle.fromHandle(fileHandle) != null;
+ if (handleFound == generateFileHeader) {
+ warningDisplayed = true;
+ String text = "File seems to contain file handle. Are you sure you want to generate a potentially already existing header?";
+ if (!handleFound)
+ text = "File does not start with a known handle. Are you sure the header is already generated?";
+ text += " Repeat to continue anyway.";
+ new MaterialAlertDialogBuilder(this)
+ .setTitle("warning")
+ .setMessage(text)
+ .setPositiveButton("ok", null)
+ .show();
+ return;
+ }
+ }
+
+ Intent callIntent = new Intent(QHybridSupport.QHYBRID_COMMAND_UPLOAD_FILE);
+ callIntent.putExtra("EXTRA_HANDLE", (FileHandle) fileTypesSpinner.getSelectedItem());
+ callIntent.putExtra("EXTRA_ENCRYPTED", encryptedFile.isChecked());
+ callIntent.putExtra("EXTRA_GENERATE_FILE_HEADER", generateFileHeader);
+ callIntent.putExtra("EXTRA_PATH", AndroidUtils.getFilePath(this, data.getData()));
+ // callIntent.setData(data.getData());
+
+ LocalBroadcastManager.getInstance(this).sendBroadcast(callIntent);
+ } catch (IOException e) {
+ GB.toast("cannot open file", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ @Override
+ public void onClick(View v) {
+ boolean isEncrypted = encryptedFile.isChecked();
+
+ if (v.getId() == R.id.qhybrid_button_download_file) {
+ Intent fileIntent = new Intent();
+ fileIntent.putExtra("EXTRA_ENCRYPTED", isEncrypted);
+ fileIntent.setAction(QHybridSupport.QHYBRID_COMMAND_DOWNLOAD_FILE);
+ fileIntent.putExtra("EXTRA_MAJORHANDLE", ((FileHandle) fileTypesSpinner.getSelectedItem()).getMajorHandle());
+ fileIntent.putExtra("EXTRA_MINORHANDLE", ((FileHandle) fileTypesSpinner.getSelectedItem()).getMinorHandle());
+ fileIntent.putExtra("EXTRA_NAME", ((FileHandle) fileTypesSpinner.getSelectedItem()).name());
+ LocalBroadcastManager.getInstance(this).sendBroadcast(fileIntent);
+ } else if (v.getId() == R.id.qhybrid_button_upload_file) {
+ Intent chooserIntent = new Intent()
+ .addCategory(Intent.CATEGORY_OPENABLE)
+ .setType("*/*")
+ .setAction(Intent.ACTION_GET_CONTENT);
+
+ Intent intent = Intent.createChooser(chooserIntent, "Select a file");
+
+ startActivityForResult(intent, REQUEST_CODE_PICK_UPLOAD_FILE);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilAppWriter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilAppWriter.java
new file mode 100644
index 0000000..c0c2b40
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilAppWriter.java
@@ -0,0 +1,170 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver
+
+ 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.devices.qhybrid;
+
+import android.content.Context;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+
+import nodomain.freeyourgadget.gadgetbridge.util.CRC32C;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+/**
+ * Writes watch apps to a file in the Fossil Hybrid HR .wapp format.
+ */
+public class FossilAppWriter {
+ private final Logger LOG = LoggerFactory.getLogger(FossilAppWriter.class);
+ private Context mContext;
+ private String version;
+ private LinkedHashMap<String, InputStream> code;
+ private LinkedHashMap<String, InputStream> icons;
+ private LinkedHashMap<String, InputStream> layout;
+ private LinkedHashMap<String, String> displayName;
+ private LinkedHashMap<String, String> config;
+
+ public FossilAppWriter(Context context, String version, LinkedHashMap<String, InputStream> code, LinkedHashMap<String, InputStream> icons, LinkedHashMap<String, InputStream> layout, LinkedHashMap<String, String> displayName, LinkedHashMap<String, String> config) {
+ this.mContext = context;
+ if (this.mContext == null) throw new AssertionError("context cannot be null");
+ this.version = version;
+ if (!this.version.matches("^[0-9]\\.[0-9]+$")) throw new AssertionError("Version must be in x.x format");
+ this.code = code;
+ if (this.code.size() == 0) throw new AssertionError("At least one code file InputStream must be supplied");
+ this.icons = icons;
+ if (this.icons == null) throw new AssertionError("icons cannot be null");
+ this.layout = layout;
+ if (this.layout == null) throw new AssertionError("layout cannot be null");
+ this.displayName = displayName;
+ if (this.displayName == null) throw new AssertionError("displayName cannot be null");
+ this.config = config;
+ if (this.config == null) throw new AssertionError("config cannot be null");
+ }
+
+ public byte[] getWapp() throws IOException {
+ byte[] codeData = loadFiles(code, false);
+ byte[] iconsData = loadFiles(icons, false);
+ byte[] layoutData = loadFiles(layout, true);
+ byte[] displayNameData = loadStringFiles(displayName);
+ byte[] configData = loadStringFiles(config);
+
+ int offsetCode = 88;
+ int offsetIcons = offsetCode + codeData.length;
+ int offsetLayout = offsetIcons + iconsData.length;
+ int offsetDisplayName = offsetLayout + layoutData.length;
+ int offsetConfig = offsetDisplayName + displayNameData.length;
+ int offsetFileEnd = offsetConfig + configData.length;
+
+ ByteArrayOutputStream filePart = new ByteArrayOutputStream();
+ filePart.write(0x1); // 1 = watchface, 2 = app
+ String[] versionParts = this.version.split("\\.");
+ for (String versionPart : versionParts) {
+ filePart.write(Integer.valueOf(versionPart).byteValue());
+ }
+ filePart.write(0x0);
+ filePart.write(intToLEBytes(0));
+ filePart.write(intToLEBytes(0));
+ filePart.write(intToLEBytes(offsetCode));
+ filePart.write(intToLEBytes(offsetIcons));
+ filePart.write(intToLEBytes(offsetLayout));
+ filePart.write(intToLEBytes(offsetDisplayName));
+ filePart.write(intToLEBytes(offsetDisplayName));
+ filePart.write(intToLEBytes(offsetConfig));
+ filePart.write(intToLEBytes(offsetFileEnd));
+ filePart.write(intToLEBytes(0));
+ filePart.write(intToLEBytes(0));
+ filePart.write(intToLEBytes(0));
+ filePart.write(intToLEBytes(0));
+ filePart.write(intToLEBytes(0));
+ filePart.write(intToLEBytes(0));
+ filePart.write(intToLEBytes(0));
+ filePart.write(intToLEBytes(0));
+ filePart.write(intToLEBytes(0));
+ filePart.write(codeData);
+ filePart.write(iconsData);
+ filePart.write(layoutData);
+ filePart.write(displayNameData);
+ filePart.write(configData);
+ byte[] filePartBytes = filePart.toByteArray();
+
+ ByteArrayOutputStream wapp = new ByteArrayOutputStream();
+ wapp.write(new byte[]{(byte)0xFE, (byte)0x15}); // file handle
+ wapp.write(new byte[]{(byte)0x03, (byte)0x00}); // file version
+ wapp.write(intToLEBytes(0)); // file offset
+ wapp.write(intToLEBytes(filePartBytes.length));
+ wapp.write(filePartBytes);
+
+ CRC32C crc = new CRC32C();
+ crc.update(filePartBytes,0,filePartBytes.length);
+ wapp.write(intToLEBytes((int)crc.getValue()));
+
+ return wapp.toByteArray();
+ }
+
+ public byte[] loadFiles(LinkedHashMap<String, InputStream> filesMap, boolean appendNull) throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ for (String filename : filesMap.keySet()) {
+ InputStream in = filesMap.get(filename);
+ output.write((byte)filename.length() + 1);
+ output.write(StringUtils.terminateNull(filename).getBytes(StandardCharsets.UTF_8));
+ int fileLength = in.available();
+ if(appendNull){
+ fileLength++;
+ }
+ output.write(shortToLEBytes((short)fileLength));
+ byte[] fileBytes = new byte[in.available()];
+ in.read(fileBytes);
+ output.write(fileBytes);
+ if(appendNull){
+ output.write(0x00);
+ }
+ }
+ return output.toByteArray();
+ }
+
+ public byte[] loadStringFiles(LinkedHashMap<String, String> stringsMap) throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ for (String filename : stringsMap.keySet()) {
+ output.write((byte)filename.length() + 1);
+ output.write(StringUtils.terminateNull(filename).getBytes(StandardCharsets.UTF_8));
+ output.write(shortToLEBytes((short)(stringsMap.get(filename).getBytes(StandardCharsets.UTF_8).length + 1)));
+ output.write(StringUtils.terminateNull(stringsMap.get(filename)).getBytes(StandardCharsets.UTF_8));
+ }
+ return output.toByteArray();
+ }
+
+ private static byte[] intToLEBytes(int number) {
+ byte[] b = new byte[4];
+ b[0] = (byte) (number & 0xFF);
+ b[1] = (byte) ((number >> 8) & 0xFF);
+ b[2] = (byte) ((number >> 16) & 0xFF);
+ b[3] = (byte) ((number >> 24) & 0xFF);
+ return b;
+ }
+
+ private static byte[] shortToLEBytes(short number) {
+ byte[] b = new byte[2];
+ b[0] = (byte) (number & 0xFF);
+ b[1] = (byte) ((number >> 8) & 0xFF);
+ return b;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilFileReader.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilFileReader.java
new file mode 100644
index 0000000..937986f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilFileReader.java
@@ -0,0 +1,328 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, Arjan Schrijver
+
+ 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.devices.qhybrid;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.json.JSONTokener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.ImageConverter;
+import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+
+/**
+ * Reads and parses files meant to be uploaded to Fossil Hybrid Q & HR watches.
+ * These can be firmware files, watch apps and watchfaces (HR only).
+ */
+public class FossilFileReader {
+ private static final Logger LOG = LoggerFactory.getLogger(FossilFileReader.class);
+ private Uri uri;
+ private final UriHelper uriHelper;
+ private boolean isValid = false;
+ private boolean isFirmware = false;
+ private boolean isApp = false;
+ private boolean isWatchface = false;
+ private String foundVersion = "(Unknown version)";
+ private String foundName = "(unknown)";
+ private GBDeviceApp app;
+ private JSONObject mAppKeys;
+
+ private int jerryStart;
+ private int appIconStart;
+ private int layout_start;
+ private int display_name_start;
+ private int display_name_start_2;
+ private int config_start;
+ private int file_end;
+
+ ArrayList<String> filenamesIcons;
+ ArrayList<String> filenamesLayout;
+ ArrayList<String> filenamesDisplayName;
+ ArrayList<String> filenamesConfig;
+
+
+ public FossilFileReader(Uri uri, Context context) throws IOException {
+ this.uri = uri;
+ uriHelper = UriHelper.get(uri, context);
+
+ try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ // Read just the first 32 bytes for file type detection
+ byte[] bytes = new byte[32];
+ int read = in.read(bytes);
+ in.close();
+ if (read < 32) {
+ isValid = false;
+ return;
+ }
+ ByteBuffer buf = ByteBuffer.wrap(bytes);
+ buf.order(ByteOrder.LITTLE_ENDIAN);
+
+ short handle = buf.getShort();
+ byte version = buf.get();
+ if (handle == 5630 && version == 3) {
+ // This is a watch app or watch face
+ isValid = true;
+ isApp = true;
+ parseApp();
+ return;
+ }
+
+ // Back to byte 0 for firmware detection
+ buf.rewind();
+ int header0 = buf.getInt();
+ buf.getInt(); // size
+ int header2 = buf.getInt();
+ int header3 = buf.getInt();
+ if (header0 != 1 ||
+ (header2 != 0x00012000 && header2 != 0x00020000) ||
+ (header3 != 0x00012000 && header3 != 0x00020000)) {
+ return;
+ }
+
+ buf.getInt(); // unknown
+ isValid = true;
+ isFirmware = true;
+ parseFirmware();
+ } catch (Exception e) {
+ LOG.warn("Error during Fossil file parsing", e);
+ }
+ }
+
+ private void parseFirmware() throws IOException {
+ InputStream in = new BufferedInputStream(uriHelper.openInputStream());
+ byte[] bytes = new byte[in.available()];
+ in.read(bytes);
+ in.close();
+ ByteBuffer buf = ByteBuffer.wrap(bytes);
+ buf.order(ByteOrder.LITTLE_ENDIAN);
+ buf.position(20);
+ int version1 = buf.get() % 0xff;
+ int version2 = buf.get() & 0xff;
+ foundVersion = version1 + "." + version2;
+ foundName = "Fossil/Skagen Hybrid HR firmware";
+ }
+
+ private void parseApp() throws IOException, JSONException {
+ mAppKeys = new JSONObject();
+ mAppKeys.put("creator", "(unknown)");
+ InputStream in = new BufferedInputStream(uriHelper.openInputStream());
+ byte[] bytes = new byte[in.available()];
+ in.read(bytes);
+ in.close();
+ ByteBuffer buf = ByteBuffer.wrap(bytes);
+ buf.order(ByteOrder.LITTLE_ENDIAN);
+ buf.position(8); // skip file handle and version
+ int fileSize = buf.getInt();
+ buf.get(); // 1 = watchface, 2 = app
+ foundVersion = (int)buf.get() + "." + (int)buf.get();
+ buf.get(); // unknown
+ mAppKeys.put("version", foundVersion);
+ buf.position(buf.position() + 8); // skip null bytes
+ jerryStart = buf.getInt();
+ appIconStart = buf.getInt();
+ layout_start = buf.getInt();
+ display_name_start = buf.getInt();
+ display_name_start_2 = buf.getInt();
+ config_start = buf.getInt();
+ file_end = buf.getInt();
+ buf.position(jerryStart);
+
+ ArrayList<String> filenamesCode = parseAppFilenames(buf, appIconStart,false);
+ if (filenamesCode.size() > 0) {
+ foundName = filenamesCode.get(0);
+ mAppKeys.put("name", foundName);
+ mAppKeys.put("uuid", UUID.nameUUIDFromBytes(foundName.getBytes(StandardCharsets.UTF_8)));
+ }
+ filenamesIcons = parseAppFilenames(buf, layout_start,false);
+ filenamesLayout = parseAppFilenames(buf, display_name_start,true);
+ filenamesDisplayName = parseAppFilenames(buf, config_start,true);
+ filenamesConfig = parseAppFilenames(buf, file_end,true);
+
+ if (filenamesDisplayName.contains("theme_class")) {
+ isApp = false;
+ isWatchface = true;
+ mAppKeys.put("type", "WATCHFACE");
+ } else {
+ mAppKeys.put("type", "APP_GENERIC");
+ }
+ app = new GBDeviceApp(mAppKeys, false, null);
+ }
+
+ private ArrayList<String> parseAppFilenames(ByteBuffer buf, int untilPosition, boolean cutTrailingNull) {
+ ArrayList<String> list = new ArrayList<>();
+ while (buf.position() < untilPosition) {
+ int filenameLength = (int)buf.get();
+ byte[] filenameBytes = new byte[filenameLength - 1];
+ buf.get(filenameBytes);
+ buf.get();
+ list.add(new String(filenameBytes, Charset.forName("UTF8")));
+ int filesize = buf.getShort();
+ if (cutTrailingNull) {
+ filesize -= 1;
+ }
+ buf.position(buf.position() + filesize); // skip file data for now
+ if (cutTrailingNull) {
+ buf.get();
+ }
+ }
+ return list;
+ }
+
+ public JSONObject getConfigJSON(String filename) throws IOException, JSONException {
+ byte[] fileBytes = getFileContentsByName(filename, config_start, file_end, true);
+ String fileString = new String(fileBytes, StandardCharsets.UTF_8);
+ JSONTokener jsonTokener = new JSONTokener(fileString);
+ return new JSONObject(jsonTokener);
+ }
+
+ public Bitmap getPreview() {
+ try {
+ if ((filenamesIcons != null) && (filenamesIcons.contains("!preview.rle"))) {
+ return BitmapUtil.getCircularBitmap(ImageConverter.decodeFromRLEImage(getImageFileContents("!preview.rle")));
+ }
+ if ((filenamesIcons != null) && (filenamesIcons.contains("!preview"))) {
+ return BitmapUtil.getCircularBitmap(ImageConverter.decodeFromRLEImage(getImageFileContents("!preview")));
+ }
+ } catch (IOException e) {
+ LOG.warn("Couldn't read preview image from wapp file: ", e);
+ return null;
+ }
+ LOG.warn("No preview image found in wapp file");
+ return null;
+ }
+
+ public Bitmap getBackground() {
+ try {
+ if (filenamesIcons != null) {
+ String filename = null;
+ if (filenamesIcons.contains("background.raw")) {
+ filename = "background.raw";
+ } else if (filenamesIcons.contains("background")) {
+ filename = "background";
+ } else {
+ JSONObject config = getConfigJSON("customWatchFace");
+ JSONArray layout = config.getJSONArray("layout");
+ JSONObject firstLayoutItem = layout.getJSONObject(0);
+ if (firstLayoutItem.getString("type").equals("image")) {
+ filename = firstLayoutItem.getString("name");
+ }
+ }
+ if (filename != null) {
+ byte[] rawImage = getImageFileContents(filename);
+ Bitmap decodedImage = ImageConverter.decodeFromRAWImage(rawImage, 240, 240);
+ return BitmapUtil.getCircularBitmap(decodedImage);
+ }
+ }
+ } catch (Exception e) {
+ LOG.warn("Couldn't read background image from wapp file: ", e);
+ return null;
+ }
+ LOG.warn("No background image found in wapp file");
+ return null;
+ }
+
+ private byte[] getImageFileContents(String filename) throws IOException {
+ return getFileContentsByName(filename, appIconStart, layout_start, false);
+ }
+
+ private byte[] getFileContentsByName(String filename, int startPos, int endPos, boolean cutTrailingNull) throws IOException {
+ InputStream in = new BufferedInputStream(uriHelper.openInputStream());
+ byte[] bytes = new byte[in.available()];
+ in.read(bytes);
+ in.close();
+ ByteBuffer buf = ByteBuffer.wrap(bytes);
+ buf.order(ByteOrder.LITTLE_ENDIAN);
+ buf.position(startPos);
+ while (buf.position() < endPos) {
+ int filenameLength = (int)buf.get();
+ byte[] filenameBytes = new byte[filenameLength - 1];
+ buf.get(filenameBytes);
+ buf.get();
+ String foundFilename = new String(filenameBytes, StandardCharsets.UTF_8);
+ int filesize = buf.getShort();
+ if (cutTrailingNull) {
+ filesize -= 1;
+ }
+ if (foundFilename.equals(filename)) {
+ byte[] fileBytes = new byte[filesize];
+ buf.get(fileBytes);
+ return fileBytes;
+ } else {
+ buf.position(buf.position() + filesize);
+ }
+ if (cutTrailingNull) {
+ buf.get();
+ }
+ }
+ throw new FileNotFoundException();
+ }
+
+ public boolean isValid() {
+ return isValid;
+ }
+
+ public boolean isFirmware() {
+ return isFirmware;
+ }
+
+ public boolean isApp() {
+ return isApp;
+ }
+
+ public boolean isWatchface() {
+ return isWatchface;
+ }
+
+ public Uri getUri() {
+ return uri;
+ }
+
+ public String getVersion() {
+ return foundVersion;
+ }
+
+ public String getName() {
+ return foundName;
+ }
+
+ public GBDeviceApp getGBDeviceApp() {
+ return app;
+ }
+
+ public JSONObject getAppKeysJSON() {
+ return mAppKeys;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilHRInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilHRInstallHandler.java
new file mode 100644
index 0000000..50589f2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilHRInstallHandler.java
@@ -0,0 +1,189 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Arjan Schrijver, Daniel Dakhno,
+ Petr Van臎k
+
+ 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.devices.qhybrid;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.net.Uri;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.Writer;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AbstractAppManagerFragment;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class FossilHRInstallHandler implements InstallHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(FossilHRInstallHandler.class);
+
+ private final Uri mUri;
+ private final Context mContext;
+ private FossilFileReader fossilFile;
+
+ FossilHRInstallHandler(Uri uri, Context context) {
+ mUri = uri;
+ mContext = context;
+ try {
+ fossilFile = new FossilFileReader(uri, mContext);
+ } catch (IOException ignored) {
+ }
+ }
+
+ @Override
+ public void validateInstallation(InstallActivity installActivity, GBDevice device) {
+ if (device.isBusy()) {
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ if (device.getType() != DeviceType.FOSSILQHYBRID || !device.isConnected() || !fossilFile.isValid()) {
+ installActivity.setInfoText("Element cannot be installed 1, type: " + device.getType() + " device: "+ device + " is connected" + device.isConnected() + " is valid file:" + fossilFile.isValid());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ GenericItem installItem = new GenericItem();
+ installItem.setName(fossilFile.getName());
+ installItem.setDetails(fossilFile.getVersion());
+ Bitmap preview = fossilFile.getPreview();
+ if (preview != null) {
+ preview = Bitmap.createScaledBitmap(preview, preview.getWidth() * 3, preview.getHeight() * 3, false);
+ }
+ installItem.setPreview(preview);
+ if (fossilFile.isFirmware()) {
+ installItem.setIcon(R.drawable.ic_firmware);
+ installActivity.setInfoText(mContext.getString(R.string.firmware_install_warning, "(unknown)"));
+ } else if (fossilFile.isApp()) {
+ installItem.setIcon(R.drawable.ic_watchapp);
+ installActivity.setInfoText(mContext.getString(R.string.app_install_info, installItem.getName(), fossilFile.getVersion(), "(unknown)"));
+ } else if (fossilFile.isWatchface()) {
+ installItem.setIcon(R.drawable.ic_watchface);
+ installActivity.setInfoText(mContext.getString(R.string.watchface_install_info, installItem.getName(), fossilFile.getVersion(), "(unknown)"));
+ } else {
+ installActivity.setInfoText("Element cannot be installed 2");
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ installActivity.setInstallEnabled(true);
+ installActivity.setInstallItem(installItem);
+ }
+
+ @Override
+ public void onStartInstall(GBDevice device) {
+ DeviceCoordinator mCoordinator = device.getDeviceCoordinator();
+ LocalBroadcastManager manager = LocalBroadcastManager.getInstance(mContext);
+ manager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_BAR).putExtra(GB.PROGRESS_BAR_INDETERMINATE, true));
+ if (fossilFile.isFirmware()) {
+ return;
+ }
+ saveAppInCache(fossilFile, fossilFile.getBackground(), fossilFile.getPreview(), mCoordinator, mContext);
+ // refresh list
+ manager.sendBroadcast(new Intent(AbstractAppManagerFragment.ACTION_REFRESH_APPLIST));
+ }
+
+ public static boolean saveAppInCache(FossilFileReader fossilFile, Bitmap backgroundImg, Bitmap previewImg, DeviceCoordinator mCoordinator, Context mContext) {
+ GBDeviceApp app;
+ File destDir;
+ // write app file
+ try {
+ app = fossilFile.getGBDeviceApp();
+ destDir = mCoordinator.getAppCacheDir();
+ destDir.mkdirs();
+ FileUtils.copyURItoFile(mContext, fossilFile.getUri(), new File(destDir, app.getUUID().toString() + mCoordinator.getAppFileExtension()));
+ } catch (IOException e) {
+ LOG.error("Saving app in cache failed: " + e.getMessage(), e);
+ return false;
+ }
+ // write app metadata
+ File outputFile = new File(destDir, app.getUUID().toString() + ".json");
+ Writer writer;
+ try {
+ writer = new BufferedWriter(new FileWriter(outputFile));
+ } catch (IOException e) {
+ LOG.error("Failed to open output file: " + e.getMessage(), e);
+ return false;
+ }
+ try {
+ LOG.info(app.getJSON().toString());
+ JSONObject appJSON = app.getJSON();
+ JSONObject appKeysJSON = fossilFile.getAppKeysJSON();
+ if (appKeysJSON != null) {
+ appJSON.put("appKeys", appKeysJSON);
+ }
+ writer.write(appJSON.toString());
+
+ writer.close();
+ } catch (IOException e) {
+ LOG.error("Failed to write to output file: " + e.getMessage(), e);
+ return false;
+ } catch (JSONException e) {
+ LOG.error("Failed to load or write appKeys JSON: " + e.getMessage(), e);
+ return false;
+ }
+ // write watchface background image
+ if (backgroundImg != null) {
+ outputFile = new File(destDir, app.getUUID().toString() + "_bg.png");
+ try {
+ FileOutputStream fos = new FileOutputStream(outputFile);
+ backgroundImg.compress(Bitmap.CompressFormat.PNG, 9, fos);
+ fos.close();
+ } catch (IOException e) {
+ LOG.error("Failed to write to output file: " + e.getMessage(), e);
+ return false;
+ }
+ }
+ // write watchface preview image
+ if (previewImg != null) {
+ outputFile = new File(destDir, app.getUUID().toString() + "_preview.png");
+ try {
+ FileOutputStream fos = new FileOutputStream(outputFile);
+ previewImg.compress(Bitmap.CompressFormat.PNG, 9, fos);
+ fos.close();
+ } catch (IOException e) {
+ LOG.error("Failed to write to output file: " + e.getMessage(), e);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public boolean isValid() {
+ return fossilFile.isValid();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilInstallHandler.java
new file mode 100644
index 0000000..e1dcceb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/FossilInstallHandler.java
@@ -0,0 +1,111 @@
+/* Copyright (C) 2020-2024 Daniel Dakhno, Petr Van臎k
+
+ 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.devices.qhybrid;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+
+public class FossilInstallHandler implements InstallHandler {
+ private final Context mContext;
+ private boolean mIsValid;
+ private String mVersion = "(Unknown version)";
+
+ FossilInstallHandler(Uri uri, Context context) {
+ mContext = context;
+ UriHelper uriHelper;
+ try {
+ uriHelper = UriHelper.get(uri, mContext);
+ } catch (IOException e) {
+ mIsValid = false;
+ return;
+ }
+ try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ byte[] bytes = new byte[32];
+ int read = in.read(bytes);
+ if (read < 32) {
+ mIsValid = false;
+ return;
+ }
+
+ ByteBuffer buf = ByteBuffer.wrap(bytes);
+ buf.order(ByteOrder.LITTLE_ENDIAN);
+ int header0 = buf.getInt();
+ buf.getInt(); // size
+ int header2 = buf.getInt();
+ int header3 = buf.getInt();
+ if (header0 != 1 || header2 != 0x00012000 || header3 != 0x00012000) {
+ mIsValid = false;
+ return;
+ }
+
+ buf.getInt(); // unknown
+ int version1 = buf.get() % 0xff;
+ int version2 = buf.get() & 0xff;
+ mVersion = "DN0.0." + version1 + "." + version2;
+ } catch (Exception e) {
+ mIsValid = false;
+ return;
+ }
+ mIsValid = true;
+ }
+
+ @Override
+ public void validateInstallation(InstallActivity installActivity, GBDevice device) {
+ if (device.isBusy()) {
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ if (device.getType() != DeviceType.FOSSILQHYBRID || !device.isConnected()) {
+ installActivity.setInfoText("Element cannot be installed 3");
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+ GenericItem installItem = new GenericItem();
+ installItem.setIcon(R.drawable.ic_firmware);
+ installItem.setName("Fossil Hybrid Firmware");
+ installItem.setDetails(mVersion);
+
+ installActivity.setInfoText(mContext.getString(R.string.firmware_install_warning, "(unknown)"));
+ installActivity.setInstallEnabled(true);
+ installActivity.setInstallItem(installItem);
+ }
+
+
+ @Override
+ public void onStartInstall(GBDevice device) {
+ }
+
+ @Override
+ public boolean isValid() {
+ return mIsValid;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HRConfigActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HRConfigActivity.java
new file mode 100644
index 0000000..415d0eb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HRConfigActivity.java
@@ -0,0 +1,426 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Arjan Schrijver, Daniel Dakhno,
+ Hasan Ammar
+
+ 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.devices.qhybrid;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport.QHYBRID_COMMAND_UPDATE_WIDGETS;
+
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.SparseArray;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.CustomBackgroundWidgetElement;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.CustomTextWidgetElement;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.CustomWidget;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.CustomWidgetElement;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.Widget;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Version;
+
+public class HRConfigActivity extends AbstractGBActivity {
+ private SharedPreferences sharedPreferences;
+ private WidgetListAdapter widgetListAdapter;
+ private ArrayList<CustomWidget> customWidgets = new ArrayList<>();
+
+ SparseArray<String> widgetButtonsMapping = new SparseArray<>(4);
+
+ private static final int REQUEST_CODE_WIDGET_EDIT = 0;
+ private static final int REQUEST_CODE_IMAGE_PICK = 1;
+ private static final int REQUEST_CODE_IMAGE_EDIT = 2;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_qhybrid_hr_settings);
+
+ sharedPreferences = GBApplication.getPrefs().getPreferences();
+
+ initMappings();
+ loadWidgetConfigs();
+
+ final ListView widgetListView = findViewById(R.id.qhybrid_widget_list);
+ widgetListAdapter = new WidgetListAdapter(customWidgets);
+ widgetListView.setAdapter(widgetListAdapter);
+ widgetListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ Widget widget = widgetListAdapter.getItem(position);
+
+ Intent startIntent = new Intent(HRConfigActivity.this, WidgetSettingsActivity.class);
+ startIntent.putExtra("EXTRA_WIDGET", widget);
+ startIntent.putExtra("EXTRA_WIDGET_IDNEX", position);
+ startIntent.putExtra("EXTRA_WIDGET_INITIAL_NAME", ((CustomWidget) widget).getName());
+
+ startActivityForResult(startIntent, REQUEST_CODE_WIDGET_EDIT);
+ }
+ });
+ loadCustomWidgetList();
+
+ findViewById(R.id.qhybrid_widget_add).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent startIntent = new Intent(HRConfigActivity.this, WidgetSettingsActivity.class);
+
+ startActivityForResult(startIntent, REQUEST_CODE_WIDGET_EDIT);
+ }
+ });
+
+ findViewById(R.id.qhybrid_set_background).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ new MaterialAlertDialogBuilder(HRConfigActivity.this)
+ .setTitle("whoop whoop")
+ .setMessage("background has to be pushed every time a custom widget changes, causing traffic and battery drain. Consider that when using custom widgets.")
+ .setPositiveButton("ok", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ Intent pickIntent = new Intent(Intent.ACTION_PICK);
+ pickIntent.setType("image/*");
+
+ startActivityForResult(pickIntent, REQUEST_CODE_IMAGE_PICK);
+ }
+ })
+ .setNegativeButton("nah", null)
+ .show();
+ }
+ });
+
+ findViewById(R.id.qhybrid_unset_background).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(QHybridSupport.QHYBRID_COMMAND_SET_BACKGROUND_IMAGE);
+ intent.putIntegerArrayListExtra("EXTRA_PIXELS", null);
+ LocalBroadcastManager.getInstance(HRConfigActivity.this).sendBroadcast(intent);
+ }
+ });
+
+ for (int i = 0; i < widgetButtonsMapping.size(); i++) {
+ final int widgetButtonId = widgetButtonsMapping.keyAt(i);
+ findViewById(widgetButtonId).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Widget.WidgetType[] types = Widget.WidgetType.values();
+ final ArrayList<String> names = new ArrayList<>(types.length);
+
+ for (Widget.WidgetType type : types) {
+ names.add(getResources().getString(type.getStringResource()));
+ }
+
+ for(CustomWidget customWidget : customWidgets){
+ names.add(customWidget.getName());
+ }
+
+ final String[] nameStrings = names.toArray(new String[0]);
+ new MaterialAlertDialogBuilder(HRConfigActivity.this)
+ .setItems(
+ nameStrings,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ saveWidgetSetting(widgetButtonId, which, nameStrings);
+ }
+ }
+ )
+ .show();
+
+ }
+ });
+ }
+
+ // Disable some functions on watches with too new firmware (from official app 4.6.0 and higher)
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ for (GBDevice device : devices) {
+ if (device.getType() == DeviceType.FOSSILQHYBRID) {
+ String fwVersion_str = device.getFirmwareVersion2();
+ Version fwVersion = new Version(fwVersion_str);
+ if (fwVersion.compareTo(new Version("2.20")) >= 0) {
+ findViewById(R.id.qhybrid_widget_add).setEnabled(false);
+ for (int i = 0; i < widgetButtonsMapping.size(); i++) {
+ final int widgetButtonId = widgetButtonsMapping.keyAt(i);
+ findViewById(widgetButtonId).setEnabled(false);
+ }
+ findViewById(R.id.qhybrid_set_background).setEnabled(false);
+ findViewById(R.id.qhybrid_unset_background).setEnabled(false);
+ GB.toast(getString(R.string.fossil_hr_warning_firmware_too_new), Toast.LENGTH_LONG, GB.INFO);
+ }
+ }
+ }
+
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if(data == null) return;
+ if(requestCode == REQUEST_CODE_WIDGET_EDIT) {
+ if (resultCode == WidgetSettingsActivity.RESULT_CODE_WIDGET_CREATED) {
+ CustomWidget widget = (CustomWidget) data.getExtras().get("EXTRA_WIDGET");
+ this.customWidgets.add(widget);
+ refreshWidgetList();
+ saveCustomWidgetList();
+
+ LocalBroadcastManager.getInstance(HRConfigActivity.this).sendBroadcast(new Intent(QHYBRID_COMMAND_UPDATE_WIDGETS));
+ } else if (resultCode == WidgetSettingsActivity.RESULT_CODE_WIDGET_UPDATED) {
+ CustomWidget widget = (CustomWidget) data.getExtras().get("EXTRA_WIDGET");
+ int updateIndex = data.getIntExtra("EXTRA_WIDGET_IDNEX", -1);
+
+ String initialName = data.getStringExtra("EXTRA_WIDGET_INITIAL_NAME");
+ String newName = widget.getName();
+
+ String widgetJSON = sharedPreferences.getString("FOSSIL_HR_WIDGETS", "{}");
+ widgetJSON = widgetJSON.replace("custom_" + initialName, "custom_" + newName);
+ sharedPreferences.edit().putString("FOSSIL_HR_WIDGETS", widgetJSON).apply();
+
+ this.customWidgets.set(updateIndex, widget);
+
+ loadWidgetConfigs();
+ refreshWidgetList();
+ saveCustomWidgetList();
+
+ LocalBroadcastManager.getInstance(HRConfigActivity.this).sendBroadcast(new Intent(QHYBRID_COMMAND_UPDATE_WIDGETS));
+ } else if (resultCode == WidgetSettingsActivity.RESULT_CODE_WIDGET_DELETED) {
+ int updateIndex = data.getIntExtra("EXTRA_WIDGET_IDNEX", -1);
+
+ this.customWidgets.remove(updateIndex);
+
+ refreshWidgetList();
+ saveCustomWidgetList();
+
+ LocalBroadcastManager.getInstance(HRConfigActivity.this).sendBroadcast(new Intent(QHYBRID_COMMAND_UPDATE_WIDGETS));
+ }
+ }else if(requestCode == REQUEST_CODE_IMAGE_PICK){
+ if (resultCode == RESULT_OK)
+ {
+ Uri imageUri = data.getData();
+ Intent activityIntent = new Intent();
+ activityIntent.setClass(this, ImageEditActivity.class);
+ activityIntent.setData(imageUri);
+
+ startActivityForResult(activityIntent, REQUEST_CODE_IMAGE_EDIT);
+ }
+ }else if(requestCode == REQUEST_CODE_IMAGE_EDIT){
+ if(resultCode == ImageEditActivity.RESULT_CODE_EDIT_SUCCESS){
+ data.setAction(QHybridSupport.QHYBRID_COMMAND_SET_BACKGROUND_IMAGE);
+ LocalBroadcastManager.getInstance(this).sendBroadcast(data);
+ }
+ }
+ }
+
+ private void saveCustomWidgetList() {
+ try {
+ JSONArray widgetArray = new JSONArray();
+ for(CustomWidget widget : customWidgets){
+ JSONArray elementArray = new JSONArray();
+
+ for(CustomWidgetElement element : widget.getElements()){
+ JSONObject elementObject = new JSONObject();
+ elementObject
+ .put("type", element.getWidgetElementType().getJsonIdentifier())
+ .put("id", element.getId())
+ .put("value", element.getValue())
+ .put("x", element.getX())
+ .put("y", element.getY());
+ elementArray.put(elementObject);
+ }
+
+ JSONObject widgetObject = new JSONObject();
+ widgetObject
+ .put("name", widget.getName())
+ .put("elements", elementArray);
+
+ widgetArray.put(widgetObject);
+ }
+ sharedPreferences.edit().putString("QHYBRID_CUSTOM_WIDGETS", widgetArray.toString()).apply();
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void loadCustomWidgetList() {
+ String customWidgetJson = sharedPreferences.getString("QHYBRID_CUSTOM_WIDGETS", "[]");
+
+ try {
+ JSONArray customWidgets = new JSONArray(customWidgetJson);
+ this.customWidgets.clear();
+
+ for (int i = 0; i < customWidgets.length(); i++) {
+ JSONObject customWidgetObject = customWidgets.getJSONObject(i);
+ CustomWidget widget = new CustomWidget(
+ customWidgetObject.getString("name"), 0, 0, "default" // FIXME: handle force white background
+ );
+ JSONArray elements = customWidgetObject.getJSONArray("elements");
+
+ for (int i2 = 0; i2 < elements.length(); i2++) {
+ JSONObject element = elements.getJSONObject(i2);
+ if (element.getString("type").equals("text")) {
+ widget.addElement(new CustomTextWidgetElement(
+ element.getString("id"),
+ element.getString("value"),
+ element.getInt("x"),
+ element.getInt("y")
+ ));
+ } else if (element.getString("type").equals("background")) {
+ widget.addElement(new CustomBackgroundWidgetElement(
+ element.getString("id"),
+ element.getString("value")
+ ));
+ }
+ }
+
+ this.customWidgets.add(widget);
+ }
+
+ refreshWidgetList();
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void refreshWidgetList() {
+ widgetListAdapter.notifyDataSetChanged();
+ }
+
+ private void saveWidgetSetting(int buttonId, int option, String[] names) {
+ String jsonKey = widgetButtonsMapping.get(buttonId);
+ Widget.WidgetType[] types = Widget.WidgetType.values();
+ String identifier = null;
+ if(option < types.length){
+ Widget.WidgetType type = types[option];
+ identifier = type.getIdentifier();
+ }else{
+ identifier = "custom_" + names[option];
+ }
+
+ try {
+ JSONObject keyConfig = new JSONObject(sharedPreferences.getString("FOSSIL_HR_WIDGETS", "{}"));
+ if (identifier != null) {
+ keyConfig.put(jsonKey, identifier);
+ } else {
+ keyConfig.remove(jsonKey);
+ }
+ sharedPreferences.edit().putString("FOSSIL_HR_WIDGETS", keyConfig.toString()).apply();
+ LocalBroadcastManager.getInstance(this).sendBroadcast(new Intent(QHYBRID_COMMAND_UPDATE_WIDGETS));
+
+ loadWidgetConfigs();
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+
+ }
+
+
+ private void loadWidgetConfigs() {
+ try {
+ for (int i = 0; i < widgetButtonsMapping.size(); i++) {
+ ((TextView) findViewById(widgetButtonsMapping.keyAt(i))).setText(widgetButtonsMapping.valueAt(i) + " widget");
+ }
+
+ JSONObject keyConfig = new JSONObject(sharedPreferences.getString("FOSSIL_HR_WIDGETS", "{}"));
+ Iterator<String> keyIterator = keyConfig.keys();
+
+ loop:
+ while (keyIterator.hasNext()) {
+ String position = keyIterator.next();
+
+ for (int widgetButtonIndex = 0; widgetButtonIndex < widgetButtonsMapping.size(); widgetButtonIndex++) {
+ if (position.equals(widgetButtonsMapping.valueAt(widgetButtonIndex))) {
+ int buttonId = widgetButtonsMapping.keyAt(widgetButtonIndex);
+ String function = keyConfig.getString(position);
+
+ Widget.WidgetType[] types = Widget.WidgetType.values();
+ if(function.startsWith("custom_")){
+ ((TextView) findViewById(buttonId)).setText(
+ position + " widget: " + function.substring(7)
+ );
+ continue loop;
+ }
+ for (int widgetIdIndex = 0; widgetIdIndex < types.length; widgetIdIndex++) {
+ String widgetIdMappingValue = types[widgetIdIndex].getIdentifier();
+ if (widgetIdMappingValue != null && widgetIdMappingValue.equals(function)) {
+ ((TextView) findViewById(buttonId)).setText(
+ position + " widget: "
+ + getResources().getText(
+ types[widgetIdIndex].getStringResource()
+ )
+ );
+ break;
+ }
+ }
+ }
+ }
+ }
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void initMappings() {
+ widgetButtonsMapping.put(R.id.qhybrid_button_widget_top, "top");
+ widgetButtonsMapping.put(R.id.qhybrid_button_widget_right, "right");
+ widgetButtonsMapping.put(R.id.qhybrid_button_widget_bottom, "bottom");
+ widgetButtonsMapping.put(R.id.qhybrid_button_widget_left, "left");
+ }
+
+ class WidgetListAdapter extends ArrayAdapter<CustomWidget> {
+ public WidgetListAdapter(@NonNull List<CustomWidget> objects) {
+ super(HRConfigActivity.this, 0, objects);
+ }
+
+ @NonNull
+ @Override
+ public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
+ if (convertView == null) convertView = new TextView(getContext());
+ TextView view = (TextView) convertView;
+
+ view.setText(getItem(position).getName());
+ // view.setTextColor(Color.WHITE);
+ view.setTextSize(25);
+
+ return view;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRActivitySampleProvider.java
new file mode 100644
index 0000000..a5eed76
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRActivitySampleProvider.java
@@ -0,0 +1,80 @@
+/* Copyright (C) 2020-2024 Daniel Dakhno
+
+ 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.devices.qhybrid;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.parser.ActivityEntry;
+
+public class HybridHRActivitySampleProvider extends AbstractSampleProvider<HybridHRActivitySample> {
+ public HybridHRActivitySampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<HybridHRActivitySample, ?> getSampleDao() {
+ return getSession().getHybridHRActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return null;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HybridHRActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HybridHRActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ if (rawType == -1) return ActivityKind.UNKNOWN;
+ return ActivityEntry.WEARING_STATE.fromValue((byte) rawType).getActivityKind();
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return 0;
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return Math.min(rawIntensity / 128f, 1f);
+ }
+
+ @Override
+ public HybridHRActivitySample createActivitySample() {
+ return new HybridHRActivitySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRSpo2SampleProvider.java
new file mode 100644
index 0000000..3bad94b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRSpo2SampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Benjamin Swartley
+
+ 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.devices.qhybrid;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class HybridHRSpo2SampleProvider extends AbstractTimeSampleProvider<HybridHRSpo2Sample> {
+ public HybridHRSpo2SampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<HybridHRSpo2Sample, ?> getSampleDao() {
+ return getSession().getHybridHRSpo2SampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return HybridHRSpo2SampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return HybridHRSpo2SampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public HybridHRSpo2Sample createSample() {
+ return new HybridHRSpo2Sample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceDesignerActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceDesignerActivity.java
new file mode 100644
index 0000000..2484ead
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceDesignerActivity.java
@@ -0,0 +1,676 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver, Daniel Dakhno, HelloCodeberg,
+ Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.provider.MediaStore;
+import android.util.DisplayMetrics;
+import android.view.DragEvent;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class HybridHRWatchfaceDesignerActivity extends AbstractGBActivity implements View.OnClickListener, View.OnLongClickListener, View.OnDragListener {
+ private final Logger LOG = LoggerFactory.getLogger(HybridHRWatchfaceDesignerActivity.class);
+ private GBDevice mGBDevice;
+ private DeviceCoordinator mCoordinator;
+ private int displayImageSize = 0;
+ private float scaleFactor = 0;
+ private ImageView backgroundImageView;
+ private Bitmap selectedBackgroundImage, processedBackgroundImage;
+ private String watchfaceName = "NewWatchface";
+ final private ArrayList<HybridHRWatchfaceWidget> widgets = new ArrayList<>();
+ private HybridHRWatchfaceSettings watchfaceSettings = new HybridHRWatchfaceSettings();
+ private int defaultWidgetColor = HybridHRWatchfaceWidget.COLOR_WHITE;
+ private boolean readyToCloseActivity = false;
+
+ private final int CHILD_ACTIVITY_IMAGE_CHOOSER = 0;
+ private final int CHILD_ACTIVITY_SETTINGS = 1;
+ private final int CHILD_ACTIVITY_WIDGET = 2;
+
+ BroadcastReceiver fileUploadReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent.getAction().equals(QHybridSupport.QHYBRID_ACTION_UPLOADED_FILE)) {
+ boolean success = intent.getBooleanExtra("EXTRA_SUCCESS", false);
+ findViewById(R.id.watchface_upload_progress_bar).setVisibility(View.GONE);
+ if (success) {
+ if (readyToCloseActivity) {
+ finish();
+ }
+ } else {
+ readyToCloseActivity = false;
+ new MaterialAlertDialogBuilder(HybridHRWatchfaceDesignerActivity.this)
+ .setMessage(R.string.watchface_upload_failed)
+ .setPositiveButton(R.string.ok, null)
+ .show();
+ }
+ }
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_hybridhr_watchface_designer);
+
+ Intent intent = getIntent();
+ Bundle bundle = intent.getExtras();
+ if (bundle != null) {
+ mGBDevice = bundle.getParcelable(GBDevice.EXTRA_DEVICE);
+ } else {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+
+ IntentFilter intentFilter = new IntentFilter();
+ intentFilter.addAction(QHybridSupport.QHYBRID_ACTION_UPLOADED_FILE);
+ LocalBroadcastManager.getInstance(this).registerReceiver(fileUploadReceiver, intentFilter);
+
+ mCoordinator = mGBDevice.getDeviceCoordinator();
+ calculateDisplayImageSize();
+ backgroundImageView = findViewById(R.id.hybridhr_background_image);
+
+ if (bundle.containsKey(GBDevice.EXTRA_UUID)) {
+ String appUUID = bundle.getString(GBDevice.EXTRA_UUID);
+ loadConfigurationFromApp(appUUID);
+ }
+
+ renderWatchfacePreview();
+
+ backgroundImageView.setOnDragListener(this);
+ findViewById(R.id.watchface_widget_delete_droparea).setOnDragListener(this);
+ findViewById(R.id.watchface_invert_colors).setOnClickListener(this);
+ findViewById(R.id.button_edit_name).setOnClickListener(this);
+ findViewById(R.id.button_set_background).setOnClickListener(this);
+ findViewById(R.id.button_add_widget).setOnClickListener(this);
+ findViewById(R.id.button_watchface_settings).setOnClickListener(this);
+ findViewById(R.id.watchface_rotate_left).setOnClickListener(this);
+ findViewById(R.id.watchface_rotate_right).setOnClickListener(this);
+ findViewById(R.id.watchface_remove_image).setOnClickListener(this);
+ findViewById(R.id.button_watchface_open_menu_companion).setOnClickListener(this);
+ findViewById(R.id.button_watchface_reset_menu_structure).setOnClickListener(this);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ reloadMenuStructureIndicator();
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
+ super.onActivityResult(requestCode, resultCode, resultData);
+ if (requestCode == CHILD_ACTIVITY_IMAGE_CHOOSER && resultCode == Activity.RESULT_OK) {
+ Uri imageUri = resultData.getData();
+ if (imageUri == null) {
+ LOG.warn("No image selected");
+ return;
+ }
+ try {
+ selectedBackgroundImage = createImageFromURI(imageUri);
+ } catch (IOException e) {
+ LOG.warn("Error converting selected image to Bitmap", e);
+ return;
+ }
+ renderWatchfacePreview();
+ } else if (requestCode == CHILD_ACTIVITY_SETTINGS && resultCode == Activity.RESULT_OK && resultData != null) {
+ watchfaceSettings = (HybridHRWatchfaceSettings) resultData.getSerializableExtra("watchfaceSettings");
+ } else if (requestCode == CHILD_ACTIVITY_WIDGET && resultCode == Activity.RESULT_OK && resultData != null) {
+ int widgetIndex = (int) resultData.getSerializableExtra("widgetIndex");
+ HybridHRWatchfaceWidget editedWidget = (HybridHRWatchfaceWidget) resultData.getSerializableExtra("widgetSettings");
+ if (widgetIndex >= 0) {
+ widgets.set(widgetIndex, editedWidget);
+ } else {
+ widgets.add(editedWidget);
+ }
+ renderWatchfacePreview();
+ }
+ }
+
+ // Add action bar buttons
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.activity_hybridhr_watchface_designer_actionbar, menu);
+ return super.onCreateOptionsMenu(menu);
+ }
+
+ // Handle action bar button presses
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int id = item.getItemId();
+ if (id == R.id.button_save_watchface) {
+ sendToWatch(false);
+ } else if (id == R.id.button_preview_watchface) {
+ sendToWatch(true);
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public void onClick(View v) {
+ int buttonId = v.getId();
+ if (buttonId == R.id.button_edit_name) {
+ final EditText input = new EditText(this);
+ input.setText(watchfaceName);
+ input.setId(0);
+ LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.MATCH_PARENT);
+ input.setLayoutParams(lp);
+ new MaterialAlertDialogBuilder(this)
+ .setView(input)
+ .setNegativeButton(R.string.fossil_hr_new_action_cancel, null)
+ .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ setWatchfaceName(input.getText().toString());
+ }
+ })
+ .setTitle(R.string.watchface_dialog_title_set_name)
+ .show();
+ } else if (buttonId == R.id.watchface_invert_colors) {
+ if (selectedBackgroundImage != null) {
+ selectedBackgroundImage = BitmapUtil.invertBitmapColors(selectedBackgroundImage);
+ for (int i=0; i<widgets.size(); i++) {
+ HybridHRWatchfaceWidget widget = widgets.get(i);
+ widget.setColor(widget.getColor() ^ 1);
+ widgets.set(i, widget);
+ }
+ renderWatchfacePreview();
+ if (defaultWidgetColor == HybridHRWatchfaceWidget.COLOR_WHITE) {
+ defaultWidgetColor = HybridHRWatchfaceWidget.COLOR_BLACK;
+ } else {
+ defaultWidgetColor = HybridHRWatchfaceWidget.COLOR_WHITE;
+ }
+ }
+ } else if (buttonId == R.id.button_set_background) {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("image/*");
+ startActivityForResult(intent, CHILD_ACTIVITY_IMAGE_CHOOSER);
+ } else if (buttonId == R.id.button_add_widget) {
+ showWidgetEditPopup(-1);
+ } else if (buttonId == R.id.button_watchface_settings) {
+ showWatchfaceSettingsPopup();
+ } else if (buttonId == R.id.watchface_rotate_left) {
+ if (selectedBackgroundImage != null) {
+ selectedBackgroundImage = BitmapUtil.rotateImage(selectedBackgroundImage, -90);
+ renderWatchfacePreview();
+ }
+ } else if (buttonId== R.id.watchface_rotate_right) {
+ if (selectedBackgroundImage != null) {
+ selectedBackgroundImage = BitmapUtil.rotateImage(selectedBackgroundImage, 90);
+ renderWatchfacePreview();
+ }
+ } else if (buttonId == R.id.watchface_remove_image) {
+ deleteWatchfaceBackground();
+ renderWatchfacePreview();
+ } else if(buttonId == R.id.button_watchface_open_menu_companion){
+ try {
+ AndroidUtils.openApp("d.d.hrmenucompanion");
+ } catch (Exception e) {
+ GB.toast(getString(R.string.error_menu_companion_not_installed), Toast.LENGTH_SHORT, GB.INFO);
+
+ AndroidUtils.openWebsite("https://github.com/dakhnod/Fossil-HR-Menu-Companion/releases/latest");
+ }
+ } else if(buttonId == R.id.button_watchface_reset_menu_structure) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(mGBDevice.getAddress());
+ prefs.edit().remove("MENU_STRUCTURE_JSON").apply();
+ GB.toast(getString(R.string.info_menu_structure_removed), Toast.LENGTH_SHORT, GB.INFO);
+ reloadMenuStructureIndicator();
+ }
+ }
+
+ private void reloadMenuStructureIndicator(){
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(mGBDevice.getAddress());
+ String menuStructureJson = prefs.getString("MENU_STRUCTURE_JSON", "");
+
+ boolean active = !menuStructureJson.isEmpty();
+
+ ((Button) findViewById(R.id.button_watchface_reset_menu_structure))
+ .setEnabled(active);
+ findViewById(R.id.fossil_menu_structure_hint_container).setVisibility(active ? View.VISIBLE : View.GONE);
+
+ if(active){
+ ((TextView)findViewById(R.id.text_watchface_menu_structure))
+ .setText(getString(R.string.info_menu_structure_contents, menuStructureJson));
+ }
+ }
+
+ @Override
+ public boolean onLongClick(View view) {
+ view.startDrag(null, new View.DragShadowBuilder(view), view, 0);
+ view.setVisibility(View.INVISIBLE);
+ return true;
+ }
+
+ @Override
+ public boolean onDrag(View targetView, DragEvent event) {
+ View draggedWidget = (View) event.getLocalState();
+ switch (event.getAction()) {
+ case DragEvent.ACTION_DRAG_STARTED:
+ findViewById(R.id.watchface_widget_delete_layout).setVisibility(View.VISIBLE);
+ break;
+ case DragEvent.ACTION_DRAG_ENTERED:
+ if (targetView.getId() == R.id.watchface_widget_delete_droparea) {
+ findViewById(R.id.watchface_widget_delete_droparea).setBackgroundColor(Color.RED);
+ }
+ break;
+ case DragEvent.ACTION_DRAG_EXITED:
+ if (targetView.getId() == R.id.watchface_widget_delete_droparea) {
+ findViewById(R.id.watchface_widget_delete_droparea).setBackgroundColor(Color.TRANSPARENT);
+ }
+ break;
+ case DragEvent.ACTION_DROP:
+ if (targetView.getId() == R.id.watchface_widget_delete_droparea) {
+ widgets.remove(draggedWidget.getId());
+ renderWatchfacePreview();
+ } else if (targetView.getId() == R.id.hybridhr_background_image) {
+ int posX = (int) (event.getX() / scaleFactor);
+ int posY = (int) (event.getY() / scaleFactor);
+ widgets.get(draggedWidget.getId()).setPosX(posX);
+ widgets.get(draggedWidget.getId()).setPosY(posY);
+ renderWatchfacePreview();
+ }
+ break;
+ case DragEvent.ACTION_DRAG_ENDED:
+ if (!event.getResult()) {
+ draggedWidget.setVisibility(View.VISIBLE);
+ }
+ findViewById(R.id.watchface_widget_delete_droparea).setBackgroundColor(Color.TRANSPARENT);
+ findViewById(R.id.watchface_widget_delete_layout).setVisibility(View.GONE);
+ break;
+ }
+ return true;
+ }
+
+ private void loadConfigurationFromApp(String appUUID) {
+ File appCacheDir;
+ try {
+ appCacheDir = mCoordinator.getAppCacheDir();
+ } catch (IOException e) {
+ LOG.warn("Could not get external dir while trying to access app cache.", e);
+ return;
+ }
+ File backgroundFile = new File(appCacheDir, appUUID + "_bg.png");
+ if (!backgroundFile.exists()) {
+ backgroundFile = new File(appCacheDir, appUUID + ".png");
+ }
+ try {
+ Bitmap cachedBackground = BitmapFactory.decodeStream(new FileInputStream(backgroundFile));
+ selectedBackgroundImage = BitmapUtil.convertToGrayscale(BitmapUtil.getCircularBitmap(cachedBackground));
+ } catch (IOException e) {
+ LOG.warn("Error loading cached background image", e);
+ }
+ File cachedFile = new File(appCacheDir, appUUID + mCoordinator.getAppFileExtension());
+ FossilFileReader fileReader;
+ try {
+ fileReader = new FossilFileReader(Uri.fromFile(cachedFile), this);
+ } catch (IOException e) {
+ LOG.warn("Could not open cached app file", e);
+ return;
+ }
+ setWatchfaceName(fileReader.getName());
+ JSONObject configJSON;
+ try {
+ configJSON = fileReader.getConfigJSON("customWatchFace");
+ } catch (IOException e) {
+ LOG.warn("Could not read config from cached app file", e);
+ return;
+ } catch (JSONException e) {
+ LOG.warn("JSON parsing error", e);
+ return;
+ }
+ if (configJSON == null) {
+ return;
+ }
+ for (Iterator<String> it = configJSON.keys(); it.hasNext(); ) {
+ String key = it.next();
+ if (key.equals("layout")) {
+ try {
+ JSONArray layout = configJSON.getJSONArray(key);
+ for (int i = 0; i < layout.length(); i++) {
+ JSONObject layoutItem = layout.getJSONObject(i);
+ if (layoutItem.getString("type").equals("comp")) {
+ widgets.add(HybridHRWatchfaceFactory.parseWidgetJSON(layoutItem));
+ }
+ }
+ } catch (JSONException e) {
+ LOG.warn("JSON parsing error", e);
+ }
+ } else if (key.equals("config")) {
+ try {
+ JSONObject watchfaceConfig = configJSON.getJSONObject(key);
+ if (watchfaceConfig.has("timeout_display_full")) {
+ watchfaceSettings.setDisplayTimeoutFull(watchfaceConfig.getInt("timeout_display_full") / 60 / 1000);
+ }
+ if (watchfaceConfig.has("timeout_display_partial")) {
+ watchfaceSettings.setDisplayTimeoutPartial(watchfaceConfig.getInt("timeout_display_partial") / 60 / 1000);
+ }
+ if (watchfaceConfig.has("wrist_flick_hands_relative")) {
+ watchfaceSettings.setWristFlickHandsMoveRelative(watchfaceConfig.getBoolean("wrist_flick_hands_relative"));
+ }
+ if (watchfaceConfig.has("wrist_flick_duration")) {
+ watchfaceSettings.setWristFlickDuration(watchfaceConfig.getInt("wrist_flick_duration"));
+ }
+ if (watchfaceConfig.has("wrist_flick_move_hour")) {
+ watchfaceSettings.setWristFlickMoveHour(watchfaceConfig.getInt("wrist_flick_move_hour"));
+ }
+ if (watchfaceConfig.has("wrist_flick_move_minute")) {
+ watchfaceSettings.setWristFlickMoveMinute(watchfaceConfig.getInt("wrist_flick_move_minute"));
+ }
+ if (watchfaceConfig.has("toggle_widgets_event")) {
+ watchfaceSettings.setToggleWidgetsEvent(watchfaceConfig.getString("toggle_widgets_event"));
+ }
+ if (watchfaceConfig.has("toggle_backlight_event")) {
+ watchfaceSettings.setToggleBacklightEvent(watchfaceConfig.getString("toggle_backlight_event"));
+ }
+ if (watchfaceConfig.has("move_hands_event")) {
+ watchfaceSettings.setMoveHandsEvent(watchfaceConfig.getString("move_hands_event"));
+ }
+ if (watchfaceConfig.has("powersave_display")) {
+ watchfaceSettings.setPowersaveDisplay(watchfaceConfig.getBoolean("powersave_display"));
+ }
+ if (watchfaceConfig.has("powersave_hands")) {
+ watchfaceSettings.setPowersaveHands(watchfaceConfig.getBoolean("powersave_hands"));
+ }
+ if (watchfaceConfig.has("light_up_on_notification")) {
+ watchfaceSettings.setLightUpOnNotification(watchfaceConfig.getBoolean("light_up_on_notification"));
+ }
+ } catch (JSONException e) {
+ LOG.warn("JSON parsing error", e);
+ }
+ }
+ }
+ }
+
+ private void setWatchfaceName(String name) {
+ watchfaceName = name;
+ TextView watchfaceNameView = findViewById(R.id.watchface_name);
+ watchfaceNameView.setText(watchfaceName);
+ }
+
+ private void renderWatchfacePreview() {
+ int widgetSize = QHybridConstants.HYBRID_HR_WATCHFACE_WIDGET_SIZE;
+ if (selectedBackgroundImage == null) {
+ try {
+ selectedBackgroundImage = BitmapUtil.getCircularBitmap(BitmapFactory.decodeStream(getAssets().open("fossil_hr/default_background.png")));
+ } catch (IOException e) {
+ LOG.warn("Loading default watchface background failed", e);
+ }
+ }
+ if (selectedBackgroundImage == null) {
+ deleteWatchfaceBackground();
+ } else {
+ processedBackgroundImage = Bitmap.createScaledBitmap(selectedBackgroundImage, displayImageSize, displayImageSize, true);
+ }
+ // Remove existing widget ImageViews
+ RelativeLayout previewLayout = this.findViewById(R.id.watchface_preview_layout);
+ boolean onlyPreviewIsRemaining = false;
+ while (!onlyPreviewIsRemaining) {
+ int childCount = previewLayout.getChildCount();
+ int i;
+ for(i=0; i<childCount; i++) {
+ View currentChild = previewLayout.getChildAt(i);
+ if (currentChild.getId() != R.id.hybridhr_background_image) {
+ previewLayout.removeView(currentChild);
+ break;
+ }
+ }
+ if (i == childCount) {
+ onlyPreviewIsRemaining = true;
+ }
+ }
+ // Paint a gray circle around the watchface
+ Canvas backgroundImageCanvas = new Canvas(processedBackgroundImage);
+ Paint circlePaint = new Paint();
+ circlePaint.setColor(Color.GRAY);
+ circlePaint.setAntiAlias(true);
+ circlePaint.setStrokeWidth(3);
+ circlePaint.setStyle(Paint.Style.STROKE);
+ backgroundImageCanvas.drawCircle(displayImageSize/2f, displayImageSize/2f, displayImageSize/2f - 2, circlePaint);
+ // Dynamically add an ImageView for each widget
+ Paint widgetPaint = new Paint();
+ widgetPaint.setColor(Color.RED);
+ widgetPaint.setStyle(Paint.Style.STROKE);
+ widgetPaint.setStrokeWidth(5);
+ Bitmap widgetNoPreviewBitmap = Bitmap.createBitmap((int)(widgetSize * scaleFactor), (int)(widgetSize * scaleFactor), Bitmap.Config.ARGB_8888);
+ Canvas widgetCanvas = new Canvas(widgetNoPreviewBitmap);
+ widgetCanvas.drawRect(0, 0, widgetSize * scaleFactor, widgetSize * scaleFactor, widgetPaint);
+ for (int i=0; i<widgets.size(); i++) {
+ HybridHRWatchfaceWidget widget = widgets.get(i);
+ RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT);
+ layoutParams.addRule(RelativeLayout.ALIGN_LEFT, backgroundImageView.getId());
+ layoutParams.addRule(RelativeLayout.ALIGN_TOP, backgroundImageView.getId());
+ layoutParams.setMargins((int) ((widget.getPosX() - widgetSize/2) * scaleFactor), (int) ((widget.getPosY() - widgetSize/2) * scaleFactor), 0, 0);
+ ImageView widgetView = new ImageView(this);
+ widgetView.setId(i);
+ try {
+ widgetView.setImageBitmap(Bitmap.createScaledBitmap(widget.getPreviewImage(this), (int)(widgetSize * scaleFactor), (int)(widgetSize * scaleFactor), true));
+ } catch (IOException e) {
+ widgetView.setImageBitmap(widgetNoPreviewBitmap);
+ }
+ widgetView.setLayoutParams(layoutParams);
+ widgetView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ showWidgetEditPopup(v.getId());
+ }
+ });
+ widgetView.setOnLongClickListener(this);
+ previewLayout.addView(widgetView);
+ }
+ backgroundImageView.setImageBitmap(processedBackgroundImage);
+ }
+
+ private void showWidgetEditPopup(int index) {
+ HybridHRWatchfaceWidget widget;
+ if (index >= 0) {
+ widget = widgets.get(index);
+ } else {
+ int posX = 120;
+ int posY = 120;
+ int currentIndex = widgets.size();
+ if (currentIndex < 4) {
+ HybridHRWidgetPosition newPosition = HybridHRWatchfaceWidget.defaultPositions[currentIndex];
+ posX = newPosition.posX;
+ posY = newPosition.posY;
+ GB.toast(getString(R.string.watchface_dialog_pre_setting_position, getString(newPosition.hintStringResource)), Toast.LENGTH_SHORT, GB.INFO);
+ }
+ int color = defaultWidgetColor;
+ if (widgets.size() > 0) {
+ color = widgets.get(0).getColor();
+ }
+ widget = new HybridHRWatchfaceWidget("widgetDate", posX, posY, 76, 76, color, null);
+ }
+ Intent intent = new Intent(this, HybridHRWatchfaceWidgetActivity.class);
+ intent.putExtra("widgetIndex", index);
+ intent.putExtra("widgetSettings", widget);
+ startActivityForResult(intent, CHILD_ACTIVITY_WIDGET);
+ }
+
+ private void showWatchfaceSettingsPopup() {
+ Intent intent = new Intent(this, HybridHRWatchfaceSettingsActivity.class);
+ intent.putExtra("watchfaceSettings", watchfaceSettings);
+ startActivityForResult(intent, CHILD_ACTIVITY_SETTINGS);
+ }
+
+ private void calculateDisplayImageSize() {
+ DisplayMetrics displayMetrics = new DisplayMetrics();
+ getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
+ displayImageSize = (int) Math.round(displayMetrics.widthPixels * 0.75);
+ scaleFactor = displayImageSize / 240f;
+ }
+
+ private Bitmap createImageFromURI(Uri imageUri) throws IOException, RuntimeException {
+ if (imageUri == null) {
+ throw new RuntimeException("No image selected");
+ }
+
+// UriHelper uriHelper = UriHelper.get(imageUri, this);
+// InputStream in = new BufferedInputStream(uriHelper.openInputStream());
+// Bitmap bitmap = BitmapFactory.decodeStream(in);
+
+ ContentResolver resolver = getContentResolver();
+ Cursor c = resolver.query(imageUri, new String[]{MediaStore.Images.ImageColumns.ORIENTATION}, null, null, null);
+ c.moveToFirst();
+ int orientation = c.getInt(c.getColumnIndex(MediaStore.Images.ImageColumns.ORIENTATION));
+ c.close();
+ Bitmap bitmap = MediaStore.Images.Media.getBitmap(this.getContentResolver(), imageUri);
+ if (orientation != 0) { // FIXME: doesn't seem to work
+ Matrix matrix = new Matrix();
+ matrix.postRotate(90);
+ bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
+ }
+
+ return BitmapUtil.convertToGrayscale(BitmapUtil.getCircularBitmap(bitmap));
+ }
+
+ private void deleteWatchfaceBackground() {
+ selectedBackgroundImage = Bitmap.createBitmap(displayImageSize, displayImageSize, Bitmap.Config.ARGB_8888);
+ selectedBackgroundImage.eraseColor(Color.BLACK);
+ selectedBackgroundImage = BitmapUtil.getCircularBitmap(selectedBackgroundImage);
+ }
+
+ private void sendToWatch(boolean preview) {
+ final Context mContext = this;
+ final HybridHRWatchfaceFactory wfFactory;
+ if (preview) {
+ wfFactory = new HybridHRWatchfaceFactory("previewWatchface");
+ } else {
+ wfFactory = new HybridHRWatchfaceFactory(watchfaceName);
+ }
+ wfFactory.setSettings(watchfaceSettings);
+ wfFactory.setBackground(selectedBackgroundImage);
+ wfFactory.addWidgets(widgets);
+
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(mGBDevice.getAddress());
+ String menuStructureJson = prefs.getString("MENU_STRUCTURE_JSON", "");
+ if(!menuStructureJson.isEmpty()){
+ try {
+ JSONObject menuStructure = new JSONObject(menuStructureJson);
+ wfFactory.setMenuStructure(menuStructure);
+ } catch (JSONException e) {
+ LOG.error("Error loading menu structure", e);
+ }
+ }
+
+ try {
+ File tempFile = File.createTempFile("tmpWatchfaceFile", null);
+ tempFile.deleteOnExit();
+ FileOutputStream fos = new FileOutputStream(tempFile);
+ BufferedOutputStream bos = new BufferedOutputStream(fos);
+ bos.write(wfFactory.getWapp(this));
+ bos.close();
+ fos.close();
+ final Uri tempAppFileUri = Uri.fromFile(tempFile);
+ if (preview) {
+ findViewById(R.id.watchface_upload_progress_bar).setVisibility(View.VISIBLE);
+ GBApplication.deviceService(mGBDevice).onInstallApp(tempAppFileUri);
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ GBApplication.deviceService(mGBDevice).onAppDelete(UUID.nameUUIDFromBytes("previewWatchface".getBytes(StandardCharsets.UTF_8)));
+ }
+ }, 15000);
+ } else {
+ readyToCloseActivity = true;
+ final FossilFileReader fossilFile = new FossilFileReader(tempAppFileUri, this);
+ GBDeviceApp app = fossilFile.getGBDeviceApp();
+ File cacheDir = mCoordinator.getAppCacheDir();
+ File destFile = new File(cacheDir, app.getUUID().toString() + mCoordinator.getAppFileExtension());
+ if (destFile.exists()) {
+ new MaterialAlertDialogBuilder(this)
+ .setMessage(R.string.watchface_cache_confirm_overwrite)
+ .setNegativeButton(R.string.no, null)
+ .setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ findViewById(R.id.watchface_upload_progress_bar).setVisibility(View.VISIBLE);
+ GBApplication.deviceService(mGBDevice).onInstallApp(tempAppFileUri);
+ FossilHRInstallHandler.saveAppInCache(fossilFile, selectedBackgroundImage, wfFactory.getPreviewImage(mContext), mCoordinator, HybridHRWatchfaceDesignerActivity.this);
+ }
+ })
+ .show();
+ } else {
+ findViewById(R.id.watchface_upload_progress_bar).setVisibility(View.VISIBLE);
+ GBApplication.deviceService(mGBDevice).onInstallApp(tempAppFileUri);
+ FossilHRInstallHandler.saveAppInCache(fossilFile, selectedBackgroundImage, wfFactory.getPreviewImage(mContext), mCoordinator, HybridHRWatchfaceDesignerActivity.this);
+ }
+ }
+ } catch (IOException e) {
+ LOG.warn("Error while creating and uploading watchface", e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceFactory.java
new file mode 100644
index 0000000..1fa1c9a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceFactory.java
@@ -0,0 +1,408 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver, Daniel Dakhno
+
+ 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.devices.qhybrid;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.LinkedHashMap;
+import java.util.TimeZone;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.ImageConverter;
+import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil;
+
+public class HybridHRWatchfaceFactory {
+ private static final Logger LOG = LoggerFactory.getLogger(HybridHRWatchfaceFactory.class);
+ private String watchfaceName;
+ private HybridHRWatchfaceSettings settings;
+ private Bitmap background;
+ private Bitmap previewImage;
+ private static final int PREVIEW_WIDTH = 192;
+ private static final int PREVIEW_HEIGHT = 192;
+ private ArrayList<JSONObject> widgets = new ArrayList<>();
+ private JSONObject menuStructure = new JSONObject();
+
+ public HybridHRWatchfaceFactory(String name) {
+ watchfaceName = name.replaceAll("[^-A-Za-z0-9]", "");
+ if (watchfaceName.equals("")) throw new AssertionError("name cannot be empty");
+ if (watchfaceName.endsWith("App")) watchfaceName += "Watchface";
+ }
+
+ public void setSettings(HybridHRWatchfaceSettings settings) {
+ this.settings = settings;
+ }
+
+ public void setBackground(Bitmap background) {
+ if ((background.getWidth() == 240) && (background.getHeight() == 240)) {
+ this.background = background;
+ } else {
+ this.background = Bitmap.createScaledBitmap(background, 240, 240, true);
+ }
+ }
+
+ public void addWidget(HybridHRWatchfaceWidget widgetDesc) {
+ JSONObject widget = new JSONObject();
+ try {
+ switch (widgetDesc.getWidgetType()) {
+ case "widgetCustom":
+ if (widgetDesc.getExtraConfigInt("update_timeout", -1) >= 0) {
+ JSONObject data = new JSONObject();
+ data.put("update_timeout", widgetDesc.getExtraConfigInt("update_timeout", -1));
+ data.put("timeout_hide_text", widgetDesc.getExtraConfigBoolean("timeout_hide_text", true));
+ data.put("timeout_show_circle", widgetDesc.getExtraConfigBoolean("timeout_show_circle", true));
+ if (widgetDesc.getBackground() != "") {
+ data.put("background", widgetDesc.getBackground() + widgetDesc.getColor() + ".rle");
+ }
+ widget.put("data", data);
+ }
+ // fall through
+ case "widget2ndTZ":
+ if (widgetDesc.getExtraConfigString("tzName", null) != null) {
+ JSONObject data = new JSONObject();
+ TimeZone tz = TimeZone.getTimeZone(widgetDesc.getExtraConfigString("tzName", null));
+ String tzShortName = widgetDesc.getExtraConfigString("tzName", null).replaceAll(".*/", "");
+ int tzOffsetMins = tz.getOffset(Calendar.getInstance().getTimeInMillis()) / 1000 / 60;
+ data.put("tzName", widgetDesc.getExtraConfigString("tzName", null));
+ data.put("loc", tzShortName);
+ data.put("utc", tzOffsetMins);
+ data.put("timeout_secs", widgetDesc.getExtraConfigInt("timeout_secs", 0));
+ widget.put("data", data);
+ }
+ // fall through
+ case "widgetDate":
+ case "widgetWeather":
+ case "widgetSteps":
+ case "widgetHR":
+ case "widgetBattery":
+ case "widgetCalories":
+ case "widgetActiveMins":
+ case "widgetChanceOfRain":
+ case "widgetUV":
+ case "widgetSpO2":
+ widget.put("type", "comp");
+ widget.put("name", widgetDesc.getWidgetType());
+ widget.put("goal_ring", false);
+ widget.put("color", widgetDesc.getColor() == HybridHRWatchfaceWidget.COLOR_WHITE ? "white" : "black");
+ if (!widgetDesc.getBackground().equals("")) {
+ widget.put("bg", widgetDesc.getBackground() + widgetDesc.getColor() + ".rle");
+ }
+ break;
+ default:
+ LOG.warn("Invalid widget name: " + widgetDesc.getWidgetType());
+ return;
+ }
+ JSONObject size = new JSONObject();
+ size.put("w", widgetDesc.getWidth());
+ size.put("h", widgetDesc.getHeight());
+ widget.put("size", size);
+ JSONObject pos = new JSONObject();
+ pos.put("x", widgetDesc.getPosX());
+ pos.put("y", widgetDesc.getPosY());
+ widget.put("pos", pos);
+ widgets.add(widget);
+ } catch (JSONException e) {
+ LOG.warn("JSON error", e);
+ }
+ }
+
+ public void setMenuStructure(JSONObject menuStructure){
+ this.menuStructure = menuStructure;
+ }
+
+ public void addWidgets(ArrayList<HybridHRWatchfaceWidget> widgets) {
+ for (HybridHRWatchfaceWidget widget : widgets) {
+ addWidget(widget);
+ }
+ }
+
+ private int includeWidget(String name) {
+ int count = 0;
+ for (JSONObject widget : this.widgets) {
+ try {
+ if (widget.get("name").equals(name)) {
+ count++;
+ }
+ } catch (JSONException e) {
+ }
+ }
+ return count;
+ }
+
+ private Boolean includeBackground(String name, int color) {
+ for (JSONObject widget : this.widgets) {
+ try {
+ if (widget.get("bg").toString().startsWith(name + color)) {
+ return true;
+ }
+ } catch (JSONException e) {
+ }
+ }
+ return false;
+ }
+
+ private InputStream getWidgetBackgroundStream(Context context, String name, Boolean invert) throws IOException {
+ Bitmap bgImage = BitmapFactory.decodeStream(context.getAssets().open("fossil_hr/" + name + ".png"));
+ if (invert) {
+ bgImage = BitmapUtil.invertBitmapColors(bgImage);
+ }
+ return new ByteArrayInputStream(ImageConverter.encodeToRLEImage(ImageConverter.get2BitsRLEImageBytes(bgImage), QHybridConstants.HYBRID_HR_WATCHFACE_WIDGET_SIZE, QHybridConstants.HYBRID_HR_WATCHFACE_WIDGET_SIZE));
+ }
+
+ public byte[] getWapp(Context context) throws IOException {
+ byte[] backgroundBytes = ImageConverter.encodeToRawImage(ImageConverter.get2BitsRAWImageBytes(background));
+ InputStream backgroundStream = new ByteArrayInputStream(backgroundBytes);
+ byte[] previewBytes = ImageConverter.encodeToRLEImage(ImageConverter.get2BitsRLEImageBytes(Bitmap.createScaledBitmap(getPreviewImage(context), PREVIEW_WIDTH, PREVIEW_HEIGHT, true)), PREVIEW_HEIGHT, PREVIEW_WIDTH);
+ InputStream previewStream = new ByteArrayInputStream(previewBytes);
+ LinkedHashMap<String, InputStream> code = new LinkedHashMap<>();
+ try {
+ code.put(watchfaceName, context.getAssets().open("fossil_hr/openSourceWatchface.bin"));
+ if (includeWidget("widgetDate") > 0) code.put("widgetDate", context.getAssets().open("fossil_hr/widgetDate.bin"));
+ if (includeWidget("widgetWeather") > 0) code.put("widgetWeather", context.getAssets().open("fossil_hr/widgetWeather.bin"));
+ if (includeWidget("widgetSteps") > 0) code.put("widgetSteps", context.getAssets().open("fossil_hr/widgetSteps.bin"));
+ if (includeWidget("widgetHR") > 0) code.put("widgetHR", context.getAssets().open("fossil_hr/widgetHR.bin"));
+ if (includeWidget("widgetBattery") > 0) code.put("widgetBattery", context.getAssets().open("fossil_hr/widgetBattery.bin"));
+ if (includeWidget("widgetCalories") > 0) code.put("widgetCalories", context.getAssets().open("fossil_hr/widgetCalories.bin"));
+ if (includeWidget("widgetActiveMins") > 0) code.put("widgetActiveMins", context.getAssets().open("fossil_hr/widgetActiveMins.bin"));
+ if (includeWidget("widgetChanceOfRain") > 0) code.put("widgetChanceOfRain", context.getAssets().open("fossil_hr/widgetChanceOfRain.bin"));
+ if (includeWidget("widgetUV") > 0) code.put("widgetUV", context.getAssets().open("fossil_hr/widgetUV.bin"));
+ if (includeWidget("widgetSpO2") > 0) code.put("widgetSpO2", context.getAssets().open("fossil_hr/widgetSpO2.bin"));
+ for (int i=0; i<includeWidget("widget2ndTZ"); i++) {
+ code.put("widget2ndTZ" + i, context.getAssets().open("fossil_hr/widget2ndTZ.bin"));
+ }
+ for (int i=0; i<includeWidget("widgetCustom"); i++) {
+ code.put("widgetCustom" + i, context.getAssets().open("fossil_hr/widgetCustom.bin"));
+ }
+ } catch (IOException e) {
+ LOG.warn("Unable to read asset file", e);
+ }
+ LinkedHashMap<String, InputStream> icons = new LinkedHashMap<>();
+ try {
+ icons.put("background.raw", backgroundStream);
+ icons.put("!preview.rle", previewStream);
+ icons.put("icTrophy", context.getAssets().open("fossil_hr/icTrophy.rle"));
+ if (includeWidget("widgetWeather") > 0) icons.put("icWthClearDay", context.getAssets().open("fossil_hr/icWthClearDay.rle"));
+ if (includeWidget("widgetWeather") > 0) icons.put("icWthClearNite", context.getAssets().open("fossil_hr/icWthClearNite.rle"));
+ if (includeWidget("widgetWeather") > 0) icons.put("icWthCloudy", context.getAssets().open("fossil_hr/icWthCloudy.rle"));
+ if (includeWidget("widgetWeather") > 0) icons.put("icWthPartCloudyDay", context.getAssets().open("fossil_hr/icWthPartCloudyDay.rle"));
+ if (includeWidget("widgetWeather") > 0) icons.put("icWthPartCloudyNite", context.getAssets().open("fossil_hr/icWthPartCloudyNite.rle"));
+ if (includeWidget("widgetWeather") > 0) icons.put("icWthRainy", context.getAssets().open("fossil_hr/icWthRainy.rle"));
+ if (includeWidget("widgetWeather") > 0) icons.put("icWthSnowy", context.getAssets().open("fossil_hr/icWthSnowy.rle"));
+ if (includeWidget("widgetWeather") > 0) icons.put("icWthStormy", context.getAssets().open("fossil_hr/icWthStormy.rle"));
+ if (includeWidget("widgetWeather") > 0) icons.put("icWthWindy", context.getAssets().open("fossil_hr/icWthWindy.rle"));
+ if (includeWidget("widgetSteps") > 0) icons.put("icSteps", context.getAssets().open("fossil_hr/icSteps.rle"));
+ if (includeWidget("widgetHR") > 0) icons.put("icHeart", context.getAssets().open("fossil_hr/icHeart.rle"));
+ if (includeWidget("widgetBattery") > 0) icons.put("icBattCharging", context.getAssets().open("fossil_hr/icBattCharging.rle"));
+ if (includeWidget("widgetBattery") > 0) icons.put("icBattEmpty", context.getAssets().open("fossil_hr/icBattEmpty.rle"));
+ if (includeWidget("widgetBattery") > 0) icons.put("icBattery", context.getAssets().open("fossil_hr/icBattery.rle"));
+ if (includeWidget("widgetCalories") > 0) icons.put("icCalories", context.getAssets().open("fossil_hr/icCalories.rle"));
+ if (includeWidget("widgetActiveMins") > 0) icons.put("icActiveMins", context.getAssets().open("fossil_hr/icActiveMins.rle"));
+ if (includeWidget("widgetChanceOfRain") > 0) icons.put("icRainChance", context.getAssets().open("fossil_hr/icRainChance.rle"));
+ if (includeWidget("widgetSpO2") > 0) icons.put("icSpO2", context.getAssets().open("fossil_hr/icSpO2.rle"));
+ if (includeWidget("widgetCustom") > 0) icons.put("widget_bg_error.rle", context.getAssets().open("fossil_hr/widget_bg_error.rle"));
+ // Note: we have to check and invert every used widget background here,
+ // because the watch doesn't invert the background image when the widget color is inverted
+ if (includeBackground("widget_bg_thin_circle", HybridHRWatchfaceWidget.COLOR_WHITE)) {
+ icons.put("widget_bg_thin_circle" + HybridHRWatchfaceWidget.COLOR_WHITE + ".rle", getWidgetBackgroundStream(context, "widget_bg_thin_circle", false));
+ }
+ if (includeBackground("widget_bg_thin_circle", HybridHRWatchfaceWidget.COLOR_BLACK)) {
+ icons.put("widget_bg_thin_circle" + HybridHRWatchfaceWidget.COLOR_BLACK + ".rle", getWidgetBackgroundStream(context, "widget_bg_thin_circle", true));
+ }
+ if (includeBackground("widget_bg_double_circle", HybridHRWatchfaceWidget.COLOR_WHITE)) {
+ icons.put("widget_bg_double_circle" + HybridHRWatchfaceWidget.COLOR_WHITE + ".rle", getWidgetBackgroundStream(context, "widget_bg_double_circle", false));
+ }
+ if (includeBackground("widget_bg_double_circle", HybridHRWatchfaceWidget.COLOR_BLACK)) {
+ icons.put("widget_bg_double_circle" + HybridHRWatchfaceWidget.COLOR_BLACK + ".rle", getWidgetBackgroundStream(context, "widget_bg_double_circle", true));
+ }
+ if (includeBackground("widget_bg_dashed_circle", HybridHRWatchfaceWidget.COLOR_WHITE)) {
+ icons.put("widget_bg_dashed_circle" + HybridHRWatchfaceWidget.COLOR_WHITE + ".rle", getWidgetBackgroundStream(context, "widget_bg_dashed_circle", false));
+ }
+ if (includeBackground("widget_bg_dashed_circle", HybridHRWatchfaceWidget.COLOR_BLACK)) {
+ icons.put("widget_bg_dashed_circle" + HybridHRWatchfaceWidget.COLOR_BLACK + ".rle", getWidgetBackgroundStream(context, "widget_bg_dashed_circle", true));
+ }
+ } catch (IOException e) {
+ LOG.warn("Unable to read asset file", e);
+ }
+ LinkedHashMap<String, InputStream> layout = new LinkedHashMap<>();
+ layout.put("complication_layout", context.getAssets().open("fossil_hr/complication_layout.json"));
+ layout.put("image_layout", context.getAssets().open("fossil_hr/image_layout.json"));
+ layout.put("menu_layout", context.getAssets().open("fossil_hr/menu_layout.json"));
+
+ if (includeWidget("widgetBattery") > 0) {
+ layout.put("battery_layout", context.getAssets().open("fossil_hr/battery_layout.json"));
+ }
+
+ LinkedHashMap<String, String> displayName = new LinkedHashMap<>();
+ displayName.put("display_name", watchfaceName);
+ displayName.put("theme_class", "complications");
+ LinkedHashMap<String, String> config = new LinkedHashMap<>();
+ try {
+ config.put("customWatchFace", getConfiguration());
+ } catch (JSONException e) {
+ LOG.warn("Could not generate configuration", e);
+ }
+ FossilAppWriter appWriter = new FossilAppWriter(context, QHybridConstants.HYBRIDHR_WATCHFACE_VERSION, code, icons, layout, displayName, config);
+ return appWriter.getWapp();
+ }
+
+ private String getConfiguration() throws JSONException {
+ JSONObject configuration = new JSONObject();
+
+ JSONArray layout = new JSONArray();
+ JSONObject background = new JSONObject();
+ background.put("type", "image");
+ background.put("name", "background.raw");
+ JSONObject size = new JSONObject();
+ size.put("w", 240);
+ size.put("h", 240);
+ background.put("size", size);
+ JSONObject pos = new JSONObject();
+ pos.put("x", 120);
+ pos.put("y", 120);
+ background.put("pos", pos);
+ layout.put(background);
+ int count_widget2ndTZ = 0;
+ int count_widgetCustom = 0;
+ for (JSONObject widget : widgets) {
+ if (widget.get("name").equals("widget2ndTZ")) {
+ widget.put("name", "widget2ndTZ" + count_widget2ndTZ);
+ layout.put(widget);
+ count_widget2ndTZ++;
+ } else if (widget.get("name").equals("widgetCustom")) {
+ widget.put("name", "widgetCustom" + count_widgetCustom);
+ layout.put(widget);
+ count_widgetCustom++;
+ } else {
+ layout.put(widget);
+ }
+ }
+ configuration.put("layout", layout);
+
+ JSONObject config = new JSONObject();
+ config.put("timeout_display_full", settings.getDisplayTimeoutFull() * 60 * 1000);
+ config.put("timeout_display_partial", settings.getDisplayTimeoutPartial() * 60 * 1000);
+ config.put("wrist_flick_hands_relative", settings.isWristFlickHandsMoveRelative());
+ config.put("wrist_flick_duration", settings.getWristFlickDuration());
+ config.put("wrist_flick_move_hour", settings.getWristFlickMoveHour());
+ config.put("wrist_flick_move_minute", settings.getWristFlickMoveMinute());
+ config.put("toggle_widgets_event", settings.getToggleWidgetsEvent());
+ config.put("toggle_backlight_event", settings.getToggleBacklightEvent());
+ config.put("move_hands_event", settings.getMoveHandsEvent());
+ config.put("powersave_display", settings.getPowersaveDisplay());
+ config.put("powersave_hands", settings.getPowersaveHands());
+ config.put("light_up_on_notification", settings.getLightUpOnNotification());
+ configuration.put("config", config);
+
+ configuration.put("menu_structure", menuStructure);
+
+ return configuration.toString();
+ }
+
+ public static HybridHRWatchfaceWidget parseWidgetJSON(JSONObject widgetJSON) throws JSONException {
+ HybridHRWatchfaceWidget parsedWidget = null;
+ String widgetName = widgetJSON.getString("name");
+ String widgetTimezone = null;
+ int widgetUpdateTimeout = -1;
+ boolean widgetTimeoutHideText = true;
+ boolean widgetTimeoutShowCircle = true;
+ switch (widgetName) {
+ case "dateSSE":
+ widgetName = "widgetDate";
+ break;
+ case "weatherSSE":
+ widgetName = "widgetWeather";
+ break;
+ case "stepsSSE":
+ widgetName = "widgetSteps";
+ break;
+ case "hrSSE":
+ widgetName = "widgetHR";
+ break;
+ case "batterySSE":
+ widgetName = "widgetBattery";
+ break;
+ case "caloriesSSE":
+ widgetName = "widgetCalories";
+ break;
+ case "activeMinutesSSE":
+ widgetName = "widgetActiveMins";
+ break;
+ case "chanceOfRainSSE":
+ widgetName = "widgetChanceOfRain";
+ break;
+ case "timeZone2SSE":
+ widgetName = "widget2ndTZ";
+ break;
+ case "spo2SSE":
+ widgetName = "widgetSpO2";
+ break;
+ }
+ if (widgetName.startsWith("widget2ndTZ")) {
+ widgetName = "widget2ndTZ";
+ } else if (widgetName.startsWith("widgetCustom")) {
+ widgetName = "widgetCustom";
+ }
+ int widgetColor = widgetJSON.getString("color").equals("white") ? HybridHRWatchfaceWidget.COLOR_WHITE : HybridHRWatchfaceWidget.COLOR_BLACK;
+ JSONObject widgetData = widgetJSON.optJSONObject("data");
+ parsedWidget = new HybridHRWatchfaceWidget(widgetName,
+ widgetJSON.getJSONObject("pos").getInt("x"),
+ widgetJSON.getJSONObject("pos").getInt("y"),
+ widgetJSON.getJSONObject("size").getInt("w"),
+ widgetJSON.getJSONObject("size").getInt("h"),
+ widgetColor,
+ widgetData);
+ String widgetBackground = widgetJSON.optString("bg", "");
+ if (widgetBackground != "") {
+ parsedWidget.setBackground(widgetBackground.replaceAll("[0-9]?\\.rle$", ""));
+ }
+ return parsedWidget;
+ }
+
+ public Bitmap getPreviewImage(Context context) {
+ if (previewImage == null) {
+ previewImage = BitmapUtil.getCircularBitmap(background);
+ Canvas previewCanvas = new Canvas(previewImage);
+ int widgetSize = QHybridConstants.HYBRID_HR_WATCHFACE_WIDGET_SIZE;
+ float scaleFactor = previewImage.getWidth() / 240;
+ for (int i=0; i<widgets.size(); i++) {
+ try {
+ HybridHRWatchfaceWidget parsedWidget = parseWidgetJSON(widgets.get(i));
+ Bitmap widgetPreview = Bitmap.createScaledBitmap(parsedWidget.getPreviewImage(context), (int)(widgetSize * scaleFactor), (int)(widgetSize * scaleFactor), true);
+ int offsetFromCenter = (int)((widgetSize/2) * scaleFactor);
+ previewCanvas.drawBitmap(widgetPreview, parsedWidget.getPosX() - offsetFromCenter, parsedWidget.getPosY() - offsetFromCenter, null);
+ } catch (JSONException e) {
+ LOG.warn("Couldn't parse widget JSON", e);
+ } catch (IOException e) {
+ LOG.warn("Couldn't parse widget JSON", e);
+ }
+ }
+ }
+ return previewImage;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceSettings.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceSettings.java
new file mode 100644
index 0000000..d29992c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceSettings.java
@@ -0,0 +1,145 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver
+
+ 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.devices.qhybrid;
+
+import java.io.Serializable;
+
+public class HybridHRWatchfaceSettings implements Serializable {
+ private int displayTimeoutFull = 60;
+ private int displayTimeoutPartial = 15;
+ private boolean wristFlickHandsMoveRelative = true;
+ private int wristFlickDuration = 2200;
+ private int wristFlickMoveHour = 360;
+ private int wristFlickMoveMinute = -360;
+ private String toggleWidgetsEvent = "";
+ private String toggleBacklightEvent = "";
+ private String moveHandsEvent = "flick_away";
+ private boolean powersaveDisplay = false;
+ private boolean powersaveHands = false;
+ private boolean lightUpOnNotification = false;
+
+ public HybridHRWatchfaceSettings() {
+ }
+
+ public int getDisplayTimeoutFull() {
+ return displayTimeoutFull;
+ }
+
+ public void setDisplayTimeoutFull(int displayTimeoutFull) {
+ this.displayTimeoutFull = displayTimeoutFull;
+ }
+
+ public int getDisplayTimeoutPartial() {
+ return displayTimeoutPartial;
+ }
+
+ public void setDisplayTimeoutPartial(int displayTimeoutPartial) {
+ this.displayTimeoutPartial = displayTimeoutPartial;
+ }
+
+ public boolean isWristFlickHandsMoveRelative() {
+ return wristFlickHandsMoveRelative;
+ }
+
+ public void setWristFlickHandsMoveRelative(boolean wristFlickHandsMoveRelative) {
+ this.wristFlickHandsMoveRelative = wristFlickHandsMoveRelative;
+ }
+
+ public int getWristFlickDuration() {
+ return wristFlickDuration;
+ }
+
+ public void setWristFlickDuration(int wristFlickDuration) {
+ this.wristFlickDuration = wristFlickDuration;
+ }
+
+ public boolean getLightUpOnNotification() {
+ return lightUpOnNotification;
+ }
+
+ public void setLightUpOnNotification(boolean lightUpOnNotification) {
+ this.lightUpOnNotification = lightUpOnNotification;
+ }
+
+ public int getWristFlickMoveHour() {
+ return wristFlickMoveHour;
+ }
+
+ public void setWristFlickMoveHour(int wristFlickMoveHour) {
+ if (wristFlickMoveHour < -360) {
+ this.wristFlickMoveHour = -360;
+ } else if (wristFlickMoveHour > 360) {
+ this.wristFlickMoveHour = 360;
+ } else {
+ this.wristFlickMoveHour = wristFlickMoveHour;
+ }
+ }
+
+ public int getWristFlickMoveMinute() {
+ return wristFlickMoveMinute;
+ }
+
+ public void setWristFlickMoveMinute(int wristFlickMoveMinute) {
+ if (wristFlickMoveMinute < -360) {
+ this.wristFlickMoveMinute = -360;
+ } else if (wristFlickMoveMinute > 360) {
+ this.wristFlickMoveMinute = 360;
+ } else {
+ this.wristFlickMoveMinute = wristFlickMoveMinute;
+ }
+ }
+
+ public String getToggleWidgetsEvent() {
+ return toggleWidgetsEvent;
+ }
+
+ public void setToggleWidgetsEvent(String event) {
+ this.toggleWidgetsEvent = event;
+ }
+
+ public String getToggleBacklightEvent() {
+ return toggleBacklightEvent;
+ }
+
+ public void setToggleBacklightEvent(String event) {
+ this.toggleBacklightEvent = event;
+ }
+
+ public String getMoveHandsEvent() {
+ return moveHandsEvent;
+ }
+
+ public void setMoveHandsEvent(String event) {
+ this.moveHandsEvent = event;
+ }
+
+ public boolean getPowersaveDisplay() {
+ return powersaveDisplay;
+ }
+
+ public void setPowersaveDisplay(boolean powersaveDisplay) {
+ this.powersaveDisplay = powersaveDisplay;
+ }
+
+ public boolean getPowersaveHands() {
+ return powersaveHands;
+ }
+
+ public void setPowersaveHands(boolean powersaveHands) {
+ this.powersaveHands = powersaveHands;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceSettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceSettingsActivity.java
new file mode 100644
index 0000000..65b096f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceSettingsActivity.java
@@ -0,0 +1,207 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.InputType;
+import android.view.MenuItem;
+
+import androidx.annotation.NonNull;
+import androidx.preference.EditTextPreference;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.SwitchPreferenceCompat;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2;
+
+public class HybridHRWatchfaceSettingsActivity extends AbstractSettingsActivityV2 {
+ static HybridHRWatchfaceSettings settings;
+
+ @Override
+ protected String fragmentTag() {
+ return HybridHRWatchfaceSettingsFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return new HybridHRWatchfaceSettingsFragment();
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Intent intent = getIntent();
+ Bundle bundle = intent.getExtras();
+ if (bundle != null) {
+ settings = (HybridHRWatchfaceSettings) bundle.getSerializable("watchfaceSettings");
+ } else {
+ throw new IllegalArgumentException("Must provide a settings object when invoking this activity");
+ }
+
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public void onBackPressed() {
+ // Hardware back button
+ Intent output = new Intent();
+ output.putExtra("watchfaceSettings", settings);
+ setResult(RESULT_OK, output);
+ finish();
+ super.onBackPressed();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ // Action bar back button
+ Intent output = new Intent();
+ output.putExtra("watchfaceSettings", settings);
+ setResult(RESULT_OK, output);
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ public static class HybridHRWatchfaceSettingsFragment extends AbstractPreferenceFragment implements Preference.OnPreferenceChangeListener {
+ static final String FRAGMENT_TAG = "HYBRID_HR_WATCHFACE_SETTINGS_FRAGMENT";
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ setPreferencesFromResource(R.xml.fossil_hr_watchface_settings, rootKey);
+
+ EditTextPreference refresh_full = findPreference("pref_hybridhr_watchface_refresh_full");
+ refresh_full.setOnPreferenceChangeListener(this);
+ refresh_full.setText(Integer.toString(settings.getDisplayTimeoutFull()));
+ refresh_full.setSummary(Integer.toString(settings.getDisplayTimeoutFull()));
+ setInputTypeFor("pref_hybridhr_watchface_refresh_full", InputType.TYPE_CLASS_NUMBER);
+
+ EditTextPreference refresh_partial = findPreference("pref_hybridhr_watchface_refresh_partial");
+ refresh_partial.setOnPreferenceChangeListener(this);
+ refresh_partial.setText(Integer.toString(settings.getDisplayTimeoutPartial()));
+ refresh_partial.setSummary(Integer.toString(settings.getDisplayTimeoutPartial()));
+ setInputTypeFor("pref_hybridhr_watchface_refresh_partial", InputType.TYPE_CLASS_NUMBER);
+
+ SwitchPreferenceCompat wrist_flick_relative = findPreference("pref_hybridhr_watchface_wrist_flick_relative");
+ wrist_flick_relative.setOnPreferenceChangeListener(this);
+ wrist_flick_relative.setChecked(settings.isWristFlickHandsMoveRelative());
+
+ EditTextPreference wrist_flick_hour_hand = findPreference("pref_hybridhr_watchface_wrist_flick_hour_hand");
+ wrist_flick_hour_hand.setOnPreferenceChangeListener(this);
+ wrist_flick_hour_hand.setText(Integer.toString(settings.getWristFlickMoveHour()));
+ wrist_flick_hour_hand.setSummary(Integer.toString(settings.getWristFlickMoveHour()));
+ setInputTypeFor("pref_hybridhr_watchface_wrist_flick_hour_hand", InputType.TYPE_CLASS_NUMBER);
+
+ EditTextPreference wrist_flick_minute_hand = findPreference("pref_hybridhr_watchface_wrist_flick_minute_hand");
+ wrist_flick_minute_hand.setOnPreferenceChangeListener(this);
+ wrist_flick_minute_hand.setText(Integer.toString(settings.getWristFlickMoveMinute()));
+ wrist_flick_minute_hand.setSummary(Integer.toString(settings.getWristFlickMoveMinute()));
+ setInputTypeFor("pref_hybridhr_watchface_wrist_flick_minute_hand", InputType.TYPE_CLASS_NUMBER);
+
+ EditTextPreference wrist_flick_duration = findPreference("pref_hybridhr_watchface_wrist_flick_duration");
+ wrist_flick_duration.setOnPreferenceChangeListener(this);
+ wrist_flick_duration.setText(Integer.toString(settings.getWristFlickDuration()));
+ wrist_flick_duration.setSummary(Integer.toString(settings.getWristFlickDuration()));
+ setInputTypeFor("pref_hybridhr_watchface_wrist_flick_duration", InputType.TYPE_CLASS_NUMBER);
+
+ ListPreference toggle_widgets = findPreference("pref_hybridhr_watchface_toggle_widgets");
+ toggle_widgets.setOnPreferenceChangeListener(this);
+ toggle_widgets.setValue(settings.getToggleWidgetsEvent());
+ toggle_widgets.setSummary(toggle_widgets.getEntry());
+
+ ListPreference toggle_backlight = findPreference("pref_hybridhr_watchface_toggle_backlight");
+ toggle_backlight.setOnPreferenceChangeListener(this);
+ toggle_backlight.setValue(settings.getToggleBacklightEvent());
+ toggle_backlight.setSummary(toggle_backlight.getEntry());
+
+ ListPreference move_hands = findPreference("pref_hybridhr_watchface_move_hands");
+ move_hands.setOnPreferenceChangeListener(this);
+ move_hands.setValue(settings.getMoveHandsEvent());
+ move_hands.setSummary(move_hands.getEntry());
+
+ SwitchPreferenceCompat power_saving_display = findPreference("pref_hybridhr_watchface_power_saving_display");
+ power_saving_display.setOnPreferenceChangeListener(this);
+ power_saving_display.setChecked(settings.getPowersaveDisplay());
+
+ SwitchPreferenceCompat power_saving_hands = findPreference("pref_hybridhr_watchface_power_saving_hands");
+ power_saving_hands.setOnPreferenceChangeListener(this);
+ power_saving_hands.setChecked(settings.getPowersaveHands());
+
+ SwitchPreferenceCompat light_up_on_notification = findPreference("pref_hybridhr_watchface_light_up_on_notification");
+ light_up_on_notification.setOnPreferenceChangeListener(this);
+ light_up_on_notification.setChecked(settings.getLightUpOnNotification());
+ }
+
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ switch (preference.getKey()) {
+ case "pref_hybridhr_watchface_refresh_full":
+ settings.setDisplayTimeoutFull(Integer.parseInt(newValue.toString()));
+ preference.setSummary(newValue.toString());
+ break;
+ case "pref_hybridhr_watchface_refresh_partial":
+ settings.setDisplayTimeoutPartial(Integer.parseInt(newValue.toString()));
+ preference.setSummary(newValue.toString());
+ break;
+ case "pref_hybridhr_watchface_wrist_flick_relative":
+ settings.setWristFlickHandsMoveRelative((boolean) newValue);
+ break;
+ case "pref_hybridhr_watchface_wrist_flick_hour_hand":
+ settings.setWristFlickMoveHour(Integer.parseInt(newValue.toString()));
+ preference.setSummary(newValue.toString());
+ break;
+ case "pref_hybridhr_watchface_wrist_flick_minute_hand":
+ settings.setWristFlickMoveMinute(Integer.parseInt(newValue.toString()));
+ preference.setSummary(newValue.toString());
+ break;
+ case "pref_hybridhr_watchface_wrist_flick_duration":
+ settings.setWristFlickDuration(Integer.parseInt(newValue.toString()));
+ preference.setSummary(newValue.toString());
+ break;
+ case "pref_hybridhr_watchface_toggle_widgets":
+ settings.setToggleWidgetsEvent(newValue.toString());
+ ((ListPreference)preference).setValue(newValue.toString());
+ preference.setSummary(((ListPreference)preference).getEntry());
+ break;
+ case "pref_hybridhr_watchface_toggle_backlight":
+ settings.setToggleBacklightEvent(newValue.toString());
+ ((ListPreference)preference).setValue(newValue.toString());
+ preference.setSummary(((ListPreference)preference).getEntry());
+ break;
+ case "pref_hybridhr_watchface_move_hands":
+ settings.setMoveHandsEvent(newValue.toString());
+ ((ListPreference)preference).setValue(newValue.toString());
+ preference.setSummary(((ListPreference)preference).getEntry());
+ break;
+ case "pref_hybridhr_watchface_power_saving_display":
+ settings.setPowersaveDisplay((boolean) newValue);
+ break;
+ case "pref_hybridhr_watchface_power_saving_hands":
+ settings.setPowersaveHands((boolean) newValue);
+ break;
+ case "pref_hybridhr_watchface_light_up_on_notification":
+ settings.setLightUpOnNotification((boolean) newValue);
+ break;
+ }
+ return true;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceWidget.java
new file mode 100644
index 0000000..5b2eb73
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceWidget.java
@@ -0,0 +1,217 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver, Daniel Dakhno
+
+ 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.devices.qhybrid;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.LinkedHashMap;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil.invertBitmapColors;
+
+public class HybridHRWatchfaceWidget implements Serializable {
+ private String widgetType;
+ private int posX;
+ private int posY;
+ private int width;
+ private int height;
+ private int color;
+ private String background;
+ private String extraConfigJSON;
+
+ public static int COLOR_WHITE = 0;
+ public static int COLOR_BLACK = 1;
+
+ static HybridHRWidgetPosition[] defaultPositions = new HybridHRWidgetPosition[]{
+ new HybridHRWidgetPosition(120, 58, R.string.watchface_dialog_widget_preset_top),
+ new HybridHRWidgetPosition(182, 120, R.string.watchface_dialog_widget_preset_right),
+ new HybridHRWidgetPosition(120, 182, R.string.watchface_dialog_widget_preset_bottom),
+ new HybridHRWidgetPosition(58, 120, R.string.watchface_dialog_widget_preset_left),
+ };
+
+ public HybridHRWatchfaceWidget(String widgetType, int posX, int posY, int width, int height, int color, JSONObject extraConfig) {
+ this.widgetType = widgetType;
+ this.posX = posX;
+ this.posY = posY;
+ this.width = width;
+ this.height = height;
+ this.color = color;
+ this.background = "";
+ try {
+ this.extraConfigJSON = extraConfig.toString();
+ } catch (Exception e) {
+ this.extraConfigJSON = "{}";
+ }
+ }
+
+ public static LinkedHashMap<String, String> getAvailableWidgetTypes(Context context) {
+ LinkedHashMap<String, String> widgetTypes = new LinkedHashMap<>();
+ widgetTypes.put("widgetDate", context.getString(R.string.watchface_widget_type_date));
+ widgetTypes.put("widgetWeather", context.getString(R.string.watchface_widget_type_weather));
+ widgetTypes.put("widgetSteps", context.getString(R.string.watchface_widget_type_steps));
+ widgetTypes.put("widgetHR", context.getString(R.string.watchface_widget_type_heart_rate));
+ widgetTypes.put("widgetBattery", context.getString(R.string.watchface_widget_type_battery));
+ widgetTypes.put("widgetCalories", context.getString(R.string.watchface_widget_type_calories));
+ widgetTypes.put("widget2ndTZ", context.getString(R.string.watchface_widget_type_2nd_tz));
+ widgetTypes.put("widgetActiveMins", context.getString(R.string.watchface_widget_type_active_mins));
+ widgetTypes.put("widgetChanceOfRain", context.getString(R.string.watchface_widget_type_chance_rain));
+ widgetTypes.put("widgetUV", context.getString(R.string.watchface_widget_type_uv_index));
+ widgetTypes.put("widgetSpO2", context.getString(R.string.watchface_widget_type_sp02));
+ widgetTypes.put("widgetCustom", context.getString(R.string.watchface_widget_type_custom));
+ return widgetTypes;
+ }
+
+ public void setWidgetType(String widgetType) {
+ this.widgetType = widgetType;
+ }
+ public String getWidgetType() {
+ return widgetType;
+ }
+
+
+ public Bitmap getPreviewImage(Context context) throws IOException {
+ Bitmap preview = BitmapFactory.decodeStream(context.getAssets().open("fossil_hr/" + widgetType + "_preview.png"));
+ if (getBackground() != "") {
+ try {
+ Bitmap background = BitmapFactory.decodeStream(context.getAssets().open("fossil_hr/" + getBackground() + ".png"));
+ preview = BitmapUtil.overlay(background, preview);
+ } catch (Exception e) {
+ // continue silently without background
+ }
+ }
+ if (color == COLOR_WHITE) {
+ return preview;
+ } else {
+ return invertBitmapColors(preview);
+ }
+ }
+
+ public int getPosX() {
+ return posX;
+ }
+ public int getPosY() {
+ return posY;
+ }
+
+ public void setPosX(int posX) {
+ this.posX = posX;
+ }
+ public void setPosY(int posY) {
+ this.posY = posY;
+ }
+
+ public int getWidth() {
+ return width;
+ }
+ public int getHeight() {
+ return height;
+ }
+
+ public void setWidth(int width) {
+ this.width = width;
+ }
+ public void setHeight(int height) {
+ this.height = height;
+ }
+
+ public int getColor() {
+ return color;
+ }
+ public void setColor(int color) {
+ this.color = color;
+ }
+
+ public String getBackground() {
+ return background;
+ }
+ public void setBackground(String background) {
+ this.background = background;
+ }
+
+ public int getExtraConfigInt(String name, int fallback) {
+ try {
+ return new JSONObject(extraConfigJSON).optInt(name, fallback);
+ } catch (Exception e) {
+ return fallback;
+ }
+ }
+ public String getExtraConfigString(String name, String fallback) {
+ try {
+ return new JSONObject(extraConfigJSON).optString(name, fallback);
+ } catch (Exception e) {
+ return fallback;
+ }
+ }
+ public Boolean getExtraConfigBoolean(String name, Boolean fallback) {
+ try {
+ return new JSONObject(extraConfigJSON).optBoolean(name, fallback);
+ } catch (Exception e) {
+ return fallback;
+ }
+ }
+
+ public void setExtraConfigInt(String name, int value) {
+ JSONObject extraConfig;
+ try {
+ extraConfig = new JSONObject(extraConfigJSON);
+ } catch (Exception e) {
+ extraConfig = new JSONObject();
+ }
+ try {
+ extraConfig.put(name, value);
+ extraConfigJSON = extraConfig.toString();
+ } catch (Exception e) {
+ return;
+ }
+ }
+ public void setExtraConfigString(String name, String value) {
+ JSONObject extraConfig;
+ try {
+ extraConfig = new JSONObject(extraConfigJSON);
+ } catch (Exception e) {
+ extraConfig = new JSONObject();
+ }
+ try {
+ extraConfig.put(name, value);
+ extraConfigJSON = extraConfig.toString();
+ } catch (Exception e) {
+ return;
+ }
+ }
+ public void setExtraConfigBoolean(String name, Boolean value) {
+ JSONObject extraConfig;
+ try {
+ extraConfig = new JSONObject(extraConfigJSON);
+ } catch (Exception e) {
+ extraConfig = new JSONObject();
+ }
+ try {
+ extraConfig.put(name, value);
+ extraConfigJSON = extraConfig.toString();
+ } catch (Exception e) {
+ return;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceWidgetActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceWidgetActivity.java
new file mode 100644
index 0000000..6fcdf5f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWatchfaceWidgetActivity.java
@@ -0,0 +1,273 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.InputType;
+import android.view.MenuItem;
+
+import androidx.annotation.NonNull;
+import androidx.preference.EditTextPreference;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.SwitchPreferenceCompat;
+
+import java.util.LinkedHashMap;
+import java.util.TimeZone;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2;
+
+public class HybridHRWatchfaceWidgetActivity extends AbstractSettingsActivityV2 {
+ private static int widgetIndex;
+ private static HybridHRWatchfaceWidget widget;
+
+ private static LinkedHashMap<String, String> widgetTypes;
+ private static String[] widgetColors;
+
+ private static final String WIDGET_2NDTZ_DEFAULT_TZ = "Etc/UTC";
+ private static final int WIDGET_2NDTZ_DEFAULT_TIMEOUT = 15;
+ private static final int WIDGET_CUSTOM_DEFAULT_TIMEOUT = 60;
+ private static final Boolean WIDGET_CUSTOM_DEFAULT_HIDE_TEXT = true;
+ private static final Boolean WIDGET_CUSTOM_DEFAULT_SHOW_CIRCLE = true;
+
+ @Override
+ protected String fragmentTag() {
+ return HybridHRWatchfaceWidgetFragment.FRAGMENT_TAG;
+ }
+
+ @Override
+ protected PreferenceFragmentCompat newFragment() {
+ return new HybridHRWatchfaceWidgetFragment();
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Intent intent = getIntent();
+ Bundle bundle = intent.getExtras();
+ if (bundle != null) {
+ widgetIndex = (int) bundle.getSerializable("widgetIndex");
+ widget = (HybridHRWatchfaceWidget) bundle.getSerializable("widgetSettings");
+ } else {
+ throw new IllegalArgumentException("Must provide a widget object when invoking this activity");
+ }
+
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public void onBackPressed() {
+ // Hardware back button
+ Intent output = new Intent();
+ output.putExtra("widgetIndex", widgetIndex);
+ output.putExtra("widgetSettings", widget);
+ setResult(RESULT_OK, output);
+ finish();
+ super.onBackPressed();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ // Action bar back button
+ Intent output = new Intent();
+ output.putExtra("widgetIndex", widgetIndex);
+ output.putExtra("widgetSettings", widget);
+ setResult(RESULT_OK, output);
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ public static class HybridHRWatchfaceWidgetFragment extends AbstractPreferenceFragment implements Preference.OnPreferenceChangeListener {
+ static final String FRAGMENT_TAG = "HYBRID_HR_WATCHFACE_WIDGET_FRAGMENT";
+
+ @Override
+ public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
+ setPreferencesFromResource(R.xml.fossil_hr_widget_settings, rootKey);
+
+ widgetTypes = HybridHRWatchfaceWidget.getAvailableWidgetTypes(requireActivity().getBaseContext());
+ ListPreference widgetType = findPreference("pref_hybridhr_widget_type");
+ widgetType.setOnPreferenceChangeListener(this);
+ widgetType.setEntries(widgetTypes.values().toArray(new String[0]));
+ widgetType.setEntryValues(widgetTypes.keySet().toArray(new String[0]));
+ widgetType.setValue(widget.getWidgetType());
+ widgetType.setSummary(widgetTypes.get(widget.getWidgetType()));
+ updateEnabledCategories();
+
+ widgetColors = new String[]{getString(R.string.watchface_dialog_widget_color_white), getString(R.string.watchface_dialog_widget_color_black)};
+ ListPreference widgetColor = findPreference("pref_hybridhr_widget_color");
+ widgetColor.setOnPreferenceChangeListener(this);
+ widgetColor.setEntries(widgetColors);
+ widgetColor.setEntryValues(new String[]{"0", "1"});
+ widgetColor.setValueIndex(widget.getColor());
+ widgetColor.setSummary(widgetColors[widget.getColor()]);
+
+ ListPreference widgetBg = findPreference("pref_hybridhr_widget_background");
+ widgetBg.setOnPreferenceChangeListener(this);
+ widgetBg.setValue(widget.getBackground());
+ widgetBg.setSummary(widgetBg.getEntry());
+
+ EditTextPreference posX = findPreference("pref_hybridhr_widget_pos_x");
+ posX.setOnPreferenceChangeListener(this);
+ posX.setText(Integer.toString(widget.getPosX()));
+ posX.setSummary(Integer.toString(widget.getPosX()));
+ setInputTypeFor("pref_hybridhr_widget_pos_x", InputType.TYPE_CLASS_NUMBER);
+
+ EditTextPreference posY = findPreference("pref_hybridhr_widget_pos_y");
+ posY.setOnPreferenceChangeListener(this);
+ posY.setText(Integer.toString(widget.getPosY()));
+ posY.setSummary(Integer.toString(widget.getPosY()));
+ setInputTypeFor("pref_hybridhr_widget_pos_y", InputType.TYPE_CLASS_NUMBER);
+
+ LinkedHashMap<String, String> positionPresets = new LinkedHashMap<String, String>();
+ for (final HybridHRWidgetPosition position : widget.defaultPositions) {
+ positionPresets.put(String.valueOf(position.hintStringResource), getString(position.hintStringResource));
+ }
+ ListPreference widgetPositionPreset = findPreference("pref_hybridhr_widget_pos_preset");
+ widgetPositionPreset.setOnPreferenceChangeListener(this);
+ widgetPositionPreset.setEntries(positionPresets.values().toArray(new String[0]));
+ widgetPositionPreset.setEntryValues(positionPresets.keySet().toArray(new String[0]));
+
+ String[] timezonesList = TimeZone.getAvailableIDs();
+ ListPreference widgetTimezone = findPreference("pref_hybridhr_widget_timezone");
+ widgetTimezone.setOnPreferenceChangeListener(this);
+ widgetTimezone.setEntries(timezonesList);
+ widgetTimezone.setEntryValues(timezonesList);
+ widgetTimezone.setValue(widget.getExtraConfigString("tzName", WIDGET_2NDTZ_DEFAULT_TZ));
+ widgetTimezone.setSummary(widget.getExtraConfigString("tzName", WIDGET_2NDTZ_DEFAULT_TZ));
+
+ EditTextPreference timezoneDuration = findPreference("pref_hybridhr_widget_timezone_timeout");
+ timezoneDuration.setOnPreferenceChangeListener(this);
+ timezoneDuration.setText(Integer.toString(widget.getExtraConfigInt("timeout_secs", WIDGET_2NDTZ_DEFAULT_TIMEOUT)));
+ timezoneDuration.setSummary(Integer.toString(widget.getExtraConfigInt("timeout_secs", WIDGET_2NDTZ_DEFAULT_TIMEOUT)));
+ setInputTypeFor("pref_hybridhr_widget_timezone_timeout", InputType.TYPE_CLASS_NUMBER);
+
+ EditTextPreference width = findPreference("pref_hybridhr_widget_width");
+ width.setOnPreferenceChangeListener(this);
+ width.setText(Integer.toString(widget.getWidth()));
+ width.setSummary(Integer.toString(widget.getWidth()));
+ setInputTypeFor("pref_hybridhr_widget_width", InputType.TYPE_CLASS_NUMBER);
+
+ EditTextPreference customWidgetTimeout = findPreference("pref_hybridhr_widget_custom_timeout");
+ customWidgetTimeout.setOnPreferenceChangeListener(this);
+ customWidgetTimeout.setText(Integer.toString(widget.getExtraConfigInt("update_timeout", WIDGET_CUSTOM_DEFAULT_TIMEOUT)));
+ customWidgetTimeout.setSummary(Integer.toString(widget.getExtraConfigInt("update_timeout", WIDGET_CUSTOM_DEFAULT_TIMEOUT)));
+ setInputTypeFor("pref_hybridhr_widget_custom_timeout", InputType.TYPE_CLASS_NUMBER);
+
+ SwitchPreferenceCompat customWidgetHideText = findPreference("pref_hybridhr_widget_custom_hide_text");
+ customWidgetHideText.setOnPreferenceChangeListener(this);
+ customWidgetHideText.setChecked(widget.getExtraConfigBoolean("timeout_hide_text", WIDGET_CUSTOM_DEFAULT_HIDE_TEXT));
+
+ SwitchPreferenceCompat customWidgetShowCircle = findPreference("pref_hybridhr_widget_custom_show_circle");
+ customWidgetShowCircle.setOnPreferenceChangeListener(this);
+ customWidgetShowCircle.setChecked(widget.getExtraConfigBoolean("timeout_show_circle", WIDGET_CUSTOM_DEFAULT_SHOW_CIRCLE));
+ }
+
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ switch (preference.getKey()) {
+ case "pref_hybridhr_widget_type":
+ widget.setWidgetType(newValue.toString());
+ preference.setSummary(widgetTypes.get(widget.getWidgetType()));
+ updateEnabledCategories();
+ if (newValue.toString().equals("widget2ndTZ")) {
+ widget.setExtraConfigString("tzName", widget.getExtraConfigString("tzName", WIDGET_2NDTZ_DEFAULT_TZ));
+ widget.setExtraConfigInt("timeout_secs", widget.getExtraConfigInt("timeout_secs", WIDGET_2NDTZ_DEFAULT_TIMEOUT));
+ } else if (newValue.toString().equals("widgetCustom")) {
+ widget.setExtraConfigInt("update_timeout", widget.getExtraConfigInt("update_timeout", WIDGET_CUSTOM_DEFAULT_TIMEOUT));
+ widget.setExtraConfigBoolean("timeout_hide_text", widget.getExtraConfigBoolean("timeout_hide_text", WIDGET_CUSTOM_DEFAULT_HIDE_TEXT));
+ widget.setExtraConfigBoolean("timeout_show_circle", widget.getExtraConfigBoolean("timeout_show_circle", WIDGET_CUSTOM_DEFAULT_SHOW_CIRCLE));
+ }
+ break;
+ case "pref_hybridhr_widget_color":
+ widget.setColor(Integer.parseInt(newValue.toString()));
+ preference.setSummary(widgetColors[widget.getColor()]);
+ break;
+ case "pref_hybridhr_widget_background":
+ widget.setBackground(newValue.toString());
+ ((ListPreference)preference).setValue(newValue.toString());
+ preference.setSummary(((ListPreference)preference).getEntry());
+ break;
+ case "pref_hybridhr_widget_pos_x":
+ widget.setPosX(Integer.parseInt(newValue.toString()));
+ preference.setSummary(newValue.toString());
+ break;
+ case "pref_hybridhr_widget_pos_y":
+ widget.setPosY(Integer.parseInt(newValue.toString()));
+ preference.setSummary(newValue.toString());
+ break;
+ case "pref_hybridhr_widget_pos_preset":
+ LinkedHashMap<String, String> positionPresets = new LinkedHashMap<String, String>();
+ for (final HybridHRWidgetPosition position : widget.defaultPositions){
+ if (newValue.toString().equals(String.valueOf(position.hintStringResource))) {
+ widget.setPosX(position.posX);
+ widget.setPosY(position.posY);
+ EditTextPreference prefPosX = findPreference("pref_hybridhr_widget_pos_x");
+ EditTextPreference prefPosY = findPreference("pref_hybridhr_widget_pos_y");
+ prefPosX.setSummary(String.valueOf(position.posX));
+ prefPosY.setSummary(String.valueOf(position.posY));
+ }
+ }
+ break;
+ case "pref_hybridhr_widget_timezone":
+ widget.setExtraConfigString("tzName", newValue.toString());
+ preference.setSummary(newValue.toString());
+ break;
+ case "pref_hybridhr_widget_timezone_timeout":
+ widget.setExtraConfigInt("timeout_secs", Integer.parseInt(newValue.toString()));
+ preference.setSummary(newValue.toString());
+ break;
+ case "pref_hybridhr_widget_width":
+ widget.setWidth(Integer.parseInt(newValue.toString()));
+ preference.setSummary(newValue.toString());
+ break;
+ case "pref_hybridhr_widget_custom_timeout":
+ widget.setExtraConfigInt("update_timeout", Integer.parseInt(newValue.toString()));
+ preference.setSummary(newValue.toString());
+ break;
+ case "pref_hybridhr_widget_custom_hide_text":
+ widget.setExtraConfigBoolean("timeout_hide_text", (boolean) newValue);
+ break;
+ case "pref_hybridhr_widget_custom_show_circle":
+ widget.setExtraConfigBoolean("timeout_show_circle", (boolean) newValue);
+ break;
+ }
+ return true;
+ }
+
+ private void updateEnabledCategories() {
+ PreferenceCategory cat2ndTZ = findPreference("widget_pref_category_2nd_tz_widget");
+ if (widget.getWidgetType().equals("widget2ndTZ")) {
+ cat2ndTZ.setEnabled(true);
+ } else {
+ cat2ndTZ.setEnabled(false);
+ }
+ PreferenceCategory catCustom = findPreference("widget_pref_category_custom_widget");
+ if (widget.getWidgetType().equals("widgetCustom")) {
+ catCustom.setEnabled(true);
+ } else {
+ catCustom.setEnabled(false);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWidgetPosition.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWidgetPosition.java
new file mode 100644
index 0000000..45e6ca7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRWidgetPosition.java
@@ -0,0 +1,27 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver
+
+ 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.devices.qhybrid;
+
+class HybridHRWidgetPosition{
+ int posX, posY, hintStringResource;
+
+ public HybridHRWidgetPosition(int posX, int posY, int hintStringResource) {
+ this.posX = posX;
+ this.posY = posY;
+ this.hintStringResource = hintStringResource;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ImageEditActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ImageEditActivity.java
new file mode 100644
index 0000000..a484275
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/ImageEditActivity.java
@@ -0,0 +1,276 @@
+/* Copyright (C) 2020-2024 Daniel Dakhno
+
+ 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.devices.qhybrid;
+
+import android.content.ContentResolver;
+import android.content.Intent;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.MediaStore;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.Toast;
+
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.AssetImage;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.image.AssetImageFactory;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class ImageEditActivity extends AbstractGBActivity implements View.OnTouchListener {
+ static final public int RESULT_CODE_EDIT_SUCCESS = 0;
+
+ ImageView overlay;
+ Canvas overlayCanvas;
+ Paint overlayPaint;
+ Bitmap overlayBitmap, mainBitmap;
+
+ float x = 0, y = 0, diameter = 0, imageDimension = 0;
+ int imageWidth, imageHeight;
+
+ private enum MovementState {
+ MOVE_UPPER_LEFT,
+ MOVE_LOWER_RIGHT,
+ MOVE_FRAME
+ }
+ private MovementState movementState;
+ float movementStartX, movementStartY, movementStartFrameX, movementStartFrameY, movementStartDiameter, leftUpperDeltaX, leftUpperDeltaY;
+
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_qhybrid_image_edit);
+
+ final RelativeLayout mainLayout = findViewById(R.id.qhybrid_image_edit_container);
+ overlay = findViewById(R.id.qhybrid_image_edit_image_overlay);
+ overlay.setOnTouchListener(this);
+
+ findViewById(R.id.qhybrid_image_edit_okay).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ finalizeImage();
+ }
+ });
+
+ mainLayout.post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ fitImageToLayout(mainLayout);
+ } catch (IOException | RuntimeException e) {
+ GB.log("Error formatting image", GB.ERROR, e);
+ GB.toast("Error formatting image", Toast.LENGTH_LONG, GB.ERROR);
+ finish();
+ }
+ }
+ });
+ }
+
+ private void finalizeImage(){
+ Bitmap cropped = Bitmap.createBitmap(this.mainBitmap, (int) this.x, (int) this.y, (int) this.diameter, (int) this.diameter);
+ Bitmap scaled = Bitmap.createScaledBitmap(cropped, 400, 400, false);
+ cropped.recycle();
+
+ try {
+ AssetImage image = AssetImageFactory.createAssetImage(scaled, false, 0, 0, 0);
+
+ Intent resultIntent = new Intent();
+ resultIntent.putExtra("EXTRA_PIXELS_ENCODED", image.getFileData());
+ setResult(RESULT_CODE_EDIT_SUCCESS, resultIntent);
+ finish();
+ } catch (IOException e) {
+ e.printStackTrace();
+ } finally {
+
+ scaled.recycle();
+ }
+ }
+
+ private void fitImageToLayout(RelativeLayout mainLayout) throws IOException, RuntimeException {
+ float containerHeight = mainLayout.getHeight();
+ float containerWidth = mainLayout.getWidth();
+ float containerRelation = containerHeight / containerWidth;
+
+ Bitmap bitmap = this.createImageFromURI();
+ float imageHeight = bitmap.getHeight();
+ float imageWidth = bitmap.getWidth();
+ float imageRelation = imageHeight / imageWidth;
+
+ float scaleRatio;
+ if(imageRelation > containerRelation){
+ scaleRatio = containerHeight / imageHeight;
+ }else{
+ scaleRatio = containerWidth / imageWidth;
+ }
+
+ int scaledHeight = (int)(imageHeight * scaleRatio);
+ int scaledWidth = (int)(imageWidth * scaleRatio);
+
+ this.imageHeight = scaledHeight;
+ this.imageWidth = scaledWidth;
+
+ this.imageDimension = this.diameter = Math.min(scaledHeight, scaledWidth);
+
+ mainBitmap = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, false);
+
+ ImageView mainImageView = findViewById(R.id.qhybrid_image_edit_image);
+ mainImageView.setImageBitmap(mainBitmap);
+ createOverlay(scaledWidth, scaledHeight);
+ }
+
+ private void createOverlay(int width, int height){
+ this.overlayBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_4444);
+ this.overlayCanvas = new Canvas(this.overlayBitmap);
+
+ this.overlayPaint = new Paint();
+ this.overlayPaint.setColor(Color.BLACK);
+ this.overlayPaint.setStyle(Paint.Style.STROKE);
+ this.overlayPaint.setStrokeWidth(imageDimension / 100);
+
+ renderOverlay();
+ }
+
+ private Bitmap createImageFromURI() throws IOException, RuntimeException {
+ Uri imageURI = getIntent().getData();
+ if (imageURI == null) {
+ throw new RuntimeException("no image attached");
+ }
+
+ ContentResolver resolver = getContentResolver();
+ Cursor c = resolver.query(imageURI, new String[]{MediaStore.Images.ImageColumns.ORIENTATION}, null, null, null);
+ c.moveToFirst();
+ int orientation = c.getInt(c.getColumnIndex(MediaStore.Images.ImageColumns.ORIENTATION));
+ c.close();
+ Bitmap bitmap = MediaStore.Images.Media.getBitmap(this.getContentResolver(), imageURI);
+ if (orientation != 0) {
+ Matrix matrix = new Matrix();
+ matrix.postRotate(90);
+
+ bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
+ }
+
+ return bitmap;
+ }
+
+ private void renderOverlay() {
+ overlayCanvas.drawColor(0, PorterDuff.Mode.CLEAR);
+
+ overlayCanvas.drawCircle(x, y, imageDimension / 15, overlayPaint);
+ overlayCanvas.drawCircle(x + diameter, y + diameter, imageDimension / 15, overlayPaint);
+
+ overlayCanvas.drawCircle(x + diameter / 2, y + diameter / 2, diameter / 2, overlayPaint);
+
+ overlay.setImageBitmap(overlayBitmap);
+ }
+
+ private void forceImageBoundaries(){
+ this.x = Math.max(this.x, 0);
+ this.y = Math.max(this.y, 0);
+
+ this.x = Math.min(this.x, this.imageWidth - diameter);
+ this.y = Math.min(this.y, this.imageHeight - diameter);
+ }
+
+ @Override
+ public boolean onTouch(View view, MotionEvent motionEvent) {
+ if(motionEvent.getAction() == MotionEvent.ACTION_DOWN){
+ handleTouchDown(motionEvent);
+ }else if(motionEvent.getAction() == MotionEvent.ACTION_MOVE){
+ handleTouchMove(motionEvent);
+ }
+ return true;
+ }
+
+ private void handleTouchMove(MotionEvent motionEvent){
+ if(movementState == MovementState.MOVE_UPPER_LEFT){
+ float moveDeltaX = motionEvent.getX() - this.movementStartX;
+ float moveDeltaY = motionEvent.getY() - this.movementStartY;
+
+ float mid = (moveDeltaX + moveDeltaY) / 2;
+
+ this.diameter = this.movementStartDiameter - mid;
+ this.x = this.movementStartX + mid + this.leftUpperDeltaX;
+ this.y = this.movementStartY + mid + this.leftUpperDeltaY;
+ }else if(movementState == MovementState.MOVE_LOWER_RIGHT) {
+ float moveDeltaX = motionEvent.getX() - this.movementStartX;
+ float moveDeltaY = motionEvent.getY() - this.movementStartY;
+
+ float mid = (moveDeltaX + moveDeltaY) / 2;
+
+ this.diameter = this.movementStartDiameter + mid;
+ }else if(movementState == MovementState.MOVE_FRAME){
+ this.x = this.movementStartFrameX + (motionEvent.getX() - this.movementStartX);
+ this.y = this.movementStartFrameY + (motionEvent.getY() - this.movementStartY);
+ }
+
+ this.forceImageBoundaries();
+ renderOverlay();
+ }
+
+ private void handleTouchDown(MotionEvent motionEvent) {
+ this.movementStartX = motionEvent.getX();
+ this.movementStartY = motionEvent.getY();
+ this.movementStartFrameX = this.x;
+ this.movementStartFrameY = this.y;
+ this.movementStartDiameter = this.diameter;
+
+ final float threshold = imageDimension / 15;
+
+ float upperLeftDeltaX = this.x - motionEvent.getX();
+ float upperLeftDeltaY = this.y - motionEvent.getY();
+
+ float upperLeftDistance = (float) Math.sqrt(upperLeftDeltaX * upperLeftDeltaX + upperLeftDeltaY * upperLeftDeltaY);
+
+ if(upperLeftDistance < threshold){
+ // Toast.makeText(this, "upper left", 0).show();
+ this.leftUpperDeltaX = upperLeftDeltaX;
+ this.leftUpperDeltaY = upperLeftDeltaY;
+ this.movementState = MovementState.MOVE_UPPER_LEFT;
+ return;
+ }
+
+ float lowerLeftX = this.x + diameter;
+ float lowerLeftY = this.y + diameter;
+
+ float lowerRightDeltaX = lowerLeftX - motionEvent.getX();
+ float lowerRightDeltaY = lowerLeftY - motionEvent.getY();
+
+ float lowerRightDistance = (float) Math.sqrt(lowerRightDeltaX * lowerRightDeltaX + lowerRightDeltaY * lowerRightDeltaY);
+
+ if(lowerRightDistance < threshold){
+ // Toast.makeText(this, "lower right", 0).show();
+ this.movementState = MovementState.MOVE_LOWER_RIGHT;
+ return;
+ }
+
+ // Toast.makeText(this, "anywhere else", 0).show();
+ this.movementState = MovementState.MOVE_FRAME;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/NotificationConfiguration.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/NotificationConfiguration.java
new file mode 100644
index 0000000..8181902
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/NotificationConfiguration.java
@@ -0,0 +1,131 @@
+/* Copyright (C) 2019-2024 Daniel Dakhno
+
+ 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.devices.qhybrid;
+
+import android.util.Log;
+
+import java.io.Serializable;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.PlayNotificationRequest;
+
+public class NotificationConfiguration implements Serializable {
+ private short min, hour, subEye = -1;
+ private String packageName, appName;
+ private PlayNotificationRequest.VibrationType vibration;
+ private boolean respectSilentMode;
+ private long id = -1;
+
+ NotificationConfiguration(short min, short hour, String packageName, String appName, boolean respectSilentMode, PlayNotificationRequest.VibrationType vibration) {
+ this.min = min;
+ this.hour = hour;
+ this.packageName = packageName;
+ this.appName = appName;
+ this.respectSilentMode = respectSilentMode;
+ this.vibration = vibration;
+ }
+
+ public NotificationConfiguration(short min, short hour, short subEye, PlayNotificationRequest.VibrationType vibration) {
+ this.min = min;
+ this.hour = hour;
+ this.subEye = subEye;
+ this.vibration = vibration;
+ }
+
+ public NotificationConfiguration(short min, short hour, String packageName, String appName, boolean respectSilentMode, PlayNotificationRequest.VibrationType vibration, long id) {
+ this.min = min;
+ this.hour = hour;
+ this.packageName = packageName;
+ this.appName = appName;
+ this.respectSilentMode = respectSilentMode;
+ this.vibration = vibration;
+ this.id = id;
+ }
+ NotificationConfiguration(String packageName, String appName) {
+ this.min = -1;
+ this.hour = -1;
+ this.packageName = packageName;
+ this.appName = appName;
+ this.respectSilentMode = false;
+ this.vibration = PlayNotificationRequest.VibrationType.SINGLE_NORMAL;
+ this.id = -1;
+ }
+
+ public PlayNotificationRequest.VibrationType getVibration() {
+ return vibration;
+ }
+
+ public void setVibration(PlayNotificationRequest.VibrationType vibration) {
+ this.vibration = vibration;
+ }
+
+ public long getId() {
+ return id;
+ }
+
+ public void setId(long id) {
+ this.id = id;
+ }
+
+ public boolean getRespectSilentMode() {
+ Log.d("Config", "respect: " + respectSilentMode);
+ return respectSilentMode;
+ }
+
+ public void setRespectSilentMode(boolean respectSilentMode) {
+ this.respectSilentMode = respectSilentMode;
+ }
+
+ public void setMin(short min) {
+ this.min = min;
+ }
+
+ public void setHour(short hour) {
+ this.hour = hour;
+ }
+
+ public void setPackageName(String packageName) {
+ this.packageName = packageName;
+ }
+
+ public void setAppName(String appName) {
+ this.appName = appName;
+ }
+
+ public short getMin() {
+ return min;
+ }
+
+ public short getHour() {
+ return hour;
+ }
+
+ public short getSubEye() {
+ return subEye;
+ }
+
+ public void setSubEye(short subEye) {
+ this.subEye = subEye;
+ }
+
+ public String getPackageName() {
+ return packageName;
+ }
+
+ public String getAppName() {
+ return appName;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/NotificationHRConfiguration.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/NotificationHRConfiguration.java
new file mode 100644
index 0000000..3974cff
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/NotificationHRConfiguration.java
@@ -0,0 +1,60 @@
+/* Copyright (C) 2019-2024 Arjan Schrijver, Daniel Dakhno
+
+ 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.devices.qhybrid;
+
+import java.io.Serializable;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.zip.CRC32;
+
+public class NotificationHRConfiguration implements Serializable {
+ private String packageName;
+ private String iconName;
+ private byte[] packageCrc;
+
+ public NotificationHRConfiguration(String packageName, String iconName) {
+ this.packageName = packageName;
+ this.iconName = iconName;
+
+ CRC32 crc = new CRC32();
+ crc.update(packageName.getBytes());
+
+ this.packageCrc = ByteBuffer
+ .allocate(4)
+ .order(ByteOrder.LITTLE_ENDIAN)
+ .putInt((int) crc.getValue())
+ .array();
+ }
+
+ public NotificationHRConfiguration(String packageName, byte[] packageCrc, String iconName) {
+ this.packageCrc = packageCrc;
+ this.packageName = packageName;
+ this.iconName = iconName;
+ }
+
+ public String getPackageName() {
+ return packageName;
+ }
+
+ public String getIconName() {
+ return iconName;
+ }
+
+ public byte[] getPackageCrc() {
+ return packageCrc;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/PackageConfigHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/PackageConfigHelper.java
new file mode 100644
index 0000000..aee688f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/PackageConfigHelper.java
@@ -0,0 +1,156 @@
+/* Copyright (C) 2019-2024 Carsten Pfeiffer, Daniel Dakhno
+
+ 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.devices.qhybrid;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.util.Log;
+
+import java.util.ArrayList;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.PlayNotificationRequest;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class PackageConfigHelper {
+ public static final String DB_NAME = "qhybridNotifications.db";
+ public static final String DB_ID = "id";
+ public static final String DB_TABLE = "notifications";
+ public static final String DB_PACKAGE = "package";
+ public static final String DB_APPNAME = "appName";
+ public static final String DB_VIBRATION = "vibrationTtype";
+ public static final String DB_MINUTE = "minDegress";
+ public static final String DB_HOUR = "hourDegrees";
+ public static final String DB_RESPECT_SILENT = "respectSilent";
+
+
+ public PackageConfigHelper(Context context) {
+ try {
+ initDB();
+ } catch (Exception e) {
+ GB.log("error getting database", GB.ERROR, e);
+ }
+ }
+
+ public void saveNotificationConfiguration(NotificationConfiguration settings) throws Exception {
+ ContentValues values = new ContentValues(6);
+ values.put(DB_PACKAGE, settings.getPackageName());
+ values.put(DB_APPNAME, settings.getAppName());
+ values.put(DB_HOUR, settings.getHour());
+ values.put(DB_MINUTE, settings.getMin());
+ values.put(DB_VIBRATION, settings.getVibration().getValue());
+ values.put(DB_RESPECT_SILENT, settings.getRespectSilentMode());
+
+ try (DBHandler db = GBApplication.acquireDB()) {
+ SQLiteDatabase database = db.getDatabase();
+
+ if (settings.getId() == -1) {
+ settings.setId(database.insert(DB_TABLE, null, values));
+ } else {
+ database.update(DB_TABLE, values, DB_ID + "=?", new String[]{String.valueOf(settings.getId())});
+ }
+ }
+ //LocalBroadcastManager.getInstance(this).sendBroadcast(new Intent());
+ }
+
+ public ArrayList<NotificationConfiguration> getNotificationConfigurations() throws Exception {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ SQLiteDatabase database = db.getDatabase();
+
+ try (Cursor cursor = database.query(DB_TABLE, new String[]{"*"}, null, null, null, null, null)) {
+ int size = cursor.getCount();
+ ArrayList<NotificationConfiguration> list = new ArrayList<>(size);
+ if (size > 0) {
+ int appNamePos = cursor.getColumnIndex(DB_APPNAME);
+ int packageNamePos = cursor.getColumnIndex(DB_PACKAGE);
+ int hourPos = cursor.getColumnIndex(DB_HOUR);
+ int minPos = cursor.getColumnIndex(DB_MINUTE);
+ int silentPos = cursor.getColumnIndex(DB_RESPECT_SILENT);
+ int vibrationPos = cursor.getColumnIndex(DB_VIBRATION);
+ int idPos = cursor.getColumnIndex(DB_ID);
+ cursor.moveToFirst();
+ do {
+ list.add(new NotificationConfiguration(
+ (short) cursor.getInt(minPos),
+ (short) cursor.getInt(hourPos),
+ cursor.getString(packageNamePos),
+ cursor.getString(appNamePos),
+ cursor.getInt(silentPos) == 1,
+ PlayNotificationRequest.VibrationType.fromValue((byte) cursor.getInt(vibrationPos)),
+ cursor.getInt(idPos)
+ ));
+ Log.d("Settings", "setting #" + cursor.getPosition() + ": " + cursor.getInt(silentPos));
+ } while (cursor.moveToNext());
+ }
+ return list;
+ }
+ }
+ }
+
+ public NotificationConfiguration getNotificationConfiguration(String appName) throws Exception {
+ if(appName == null) return null;
+
+ try (DBHandler db = GBApplication.acquireDB()) {
+ SQLiteDatabase database = db.getDatabase();
+
+ try (Cursor c = database.query(DB_TABLE, new String[]{"*"}, DB_APPNAME + "=?", new String[]{appName}, null, null, null)) {
+ if (c.getCount() == 0) {
+ return null;
+ }
+ c.moveToFirst();
+ NotificationConfiguration settings = new NotificationConfiguration(
+ (short) c.getInt(c.getColumnIndex(DB_MINUTE)),
+ (short) c.getInt(c.getColumnIndex(DB_HOUR)),
+ c.getString(c.getColumnIndex(DB_PACKAGE)),
+ c.getString(c.getColumnIndex(DB_APPNAME)),
+ c.getInt(c.getColumnIndex(DB_RESPECT_SILENT)) == 1,
+ PlayNotificationRequest.VibrationType.fromValue((byte) c.getInt(c.getColumnIndex(DB_VIBRATION))),
+ c.getInt(c.getColumnIndex(DB_ID))
+ );
+ return settings;
+ }
+ }
+ }
+
+ private void initDB() throws Exception {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ SQLiteDatabase database = db.getDatabase();
+
+ database.execSQL("CREATE TABLE IF NOT EXISTS notifications(" +
+ DB_ID + " INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+ DB_PACKAGE + " TEXT, " +
+ DB_VIBRATION + " INTEGER, " +
+ DB_MINUTE + " INTEGER DEFAULT -1, " +
+ DB_APPNAME + " TEXT," +
+ DB_RESPECT_SILENT + " INTEGER," +
+ DB_HOUR + " INTEGER DEFAULT -1);");
+ }
+ }
+
+ public void deleteNotificationConfiguration(NotificationConfiguration packageSettings) throws Exception {
+ Log.d("DB", "deleting id " + packageSettings.getId());
+ if(packageSettings.getId() == -1) return;
+
+ try (DBHandler db = GBApplication.acquireDB()) {
+ SQLiteDatabase database = db.getDatabase();
+ database.delete(DB_TABLE, DB_ID + "=?", new String[]{String.valueOf(packageSettings.getId())});
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridAppChoserActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridAppChoserActivity.java
new file mode 100644
index 0000000..657616f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridAppChoserActivity.java
@@ -0,0 +1,197 @@
+/* Copyright (C) 2019-2024 Carsten Pfeiffer, Daniel Dakhno
+
+ 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.devices.qhybrid;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.IdentityHashMap;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+import static android.view.View.GONE;
+
+public class QHybridAppChoserActivity extends AbstractGBActivity {
+ boolean hasControl = false;
+
+ private PackageConfigHelper helper;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_qhybrid_app_choser);
+
+ helper = new PackageConfigHelper(getApplicationContext());
+
+ final ListView appList = findViewById(R.id.qhybrid_appChooserList);
+ final PackageManager manager = getPackageManager();
+ final List<PackageInfo> packages = manager.getInstalledPackages(0);
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ final IdentityHashMap<PackageInfo, String> nameMap = new IdentityHashMap(packages.size());
+ for(PackageInfo info : packages){
+ CharSequence label = manager.getApplicationLabel(info.applicationInfo);
+ if(label == null) label = info.packageName;
+ nameMap.put(info, label.toString());
+ }
+
+ Collections.sort(packages, new Comparator<PackageInfo>() {
+ @Override
+ public int compare(PackageInfo packageInfo, PackageInfo t1) {
+ return nameMap.get(packageInfo)
+ .compareToIgnoreCase(
+ nameMap.get(t1)
+ );
+ }
+ });
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ appList.setAdapter(new ConfigArrayAdapter(QHybridAppChoserActivity.this, R.layout.qhybrid_app_view, packages, manager));
+ findViewById(R.id.qhybrid_packageChooserLoading).setVisibility(GONE);
+ }
+ });
+ }
+ }).start();
+ appList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
+ showPackageDialog(packages.get(i));
+ }
+ });
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ }
+
+ private void setControl(boolean control) {
+ if (hasControl == control) return;
+ Intent intent = new Intent(control ? QHybridSupport.QHYBRID_COMMAND_CONTROL : QHybridSupport.QHYBRID_COMMAND_UNCONTROL);
+ LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
+ this.hasControl = control;
+ }
+
+ private void setHands(NotificationConfiguration config){
+ sendControl(config, QHybridSupport.QHYBRID_COMMAND_SET);
+ }
+
+ private void vibrate(NotificationConfiguration config){
+ sendControl(config, QHybridSupport.QHYBRID_COMMAND_VIBRATE);
+ }
+
+ private void sendControl(NotificationConfiguration config, String request){
+ Intent intent = new Intent(request);
+ intent.putExtra("CONFIG", config);
+ LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ private void showPackageDialog(PackageInfo info) {
+ TimePicker picker = new TimePicker(this, info);
+
+ picker.finishListener = new TimePicker.OnFinishListener() {
+ @Override
+ public void onFinish(boolean success, NotificationConfiguration config) {
+ setControl(false);
+ if(success){
+ try {
+ helper.saveNotificationConfiguration(config);
+ LocalBroadcastManager.getInstance(QHybridAppChoserActivity.this).sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_NOTIFICATION_CONFIG_CHANGED));
+ } catch (Exception e) {
+ GB.toast("error saving configuration", Toast.LENGTH_SHORT, GB.ERROR, e);
+ }
+ finish();
+ }
+ }
+ };
+
+ picker.handsListener = new TimePicker.OnHandsSetListener() {
+ @Override
+ public void onHandsSet(NotificationConfiguration config) {
+ setHands(config);
+ }
+ };
+
+ picker.vibrationListener = new TimePicker.OnVibrationSetListener() {
+ @Override
+ public void onVibrationSet(NotificationConfiguration config) {
+ vibrate(config);
+ }
+ };
+
+ setControl(true);
+ }
+
+
+
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ setControl(false);
+ finish();
+ }
+
+ class ConfigArrayAdapter extends ArrayAdapter<PackageInfo> {
+ PackageManager manager;
+
+ public ConfigArrayAdapter(@NonNull Context context, int resource, @NonNull List<PackageInfo> objects, PackageManager manager) {
+ super(context, resource, objects);
+ this.manager = manager;
+ }
+
+ @NonNull
+ @Override
+ public View getView(int position, @Nullable View view, @NonNull ViewGroup parent) {
+ if (view == null)
+ view = ((LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE)).inflate(R.layout.qhybrid_app_view, null);
+
+ ApplicationInfo info = getItem(position).applicationInfo;
+ ((ImageView) view.findViewById(R.id.qhybrid_appChooserItemIcon)).setImageDrawable(manager.getApplicationIcon(info));
+ ((TextView) view.findViewById(R.id.qhybrid_appChooserItemText)).setText(manager.getApplicationLabel(info));
+
+ return view;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridConfigActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridConfigActivity.java
new file mode 100644
index 0000000..197b5a6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridConfigActivity.java
@@ -0,0 +1,671 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniel Dakhno, Taavi Eom盲e
+
+ 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.devices.qhybrid;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.os.Bundle;
+import android.view.Gravity;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.NumberPicker;
+import android.widget.PopupMenu;
+import android.widget.RelativeLayout;
+import android.widget.SeekBar;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.content.ContextCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.model.ItemWithDetails;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.adapter.fossil.FossilWatchAdapter;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.buttonconfig.ConfigPayload;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class QHybridConfigActivity extends AbstractGBActivity {
+ PackageAdapter adapter;
+ ArrayList<NotificationConfiguration> list;
+ PackageConfigHelper helper;
+
+ final int REQUEST_CODE_ADD_APP = 0;
+
+ private boolean hasControl = false;
+
+ SharedPreferences prefs;
+
+ TextView timeOffsetView, timezoneOffsetView;
+
+ GBDevice device;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
+
+ super.onCreate(savedInstanceState);
+
+ if (device == null) {
+ GB.toast(this, "Device is null", Toast.LENGTH_LONG, GB.ERROR);
+ finish();
+ return;
+ }
+
+ setContentView(R.layout.activity_qhybrid_settings);
+
+ findViewById(R.id.buttonOverwriteButtons).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ LocalBroadcastManager.getInstance(QHybridConfigActivity.this).sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_OVERWRITE_BUTTONS));
+ }
+ });
+
+ prefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
+ timeOffsetView = findViewById(R.id.qhybridTimeOffset);
+ timeOffsetView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ int timeOffset = prefs.getInt("QHYBRID_TIME_OFFSET", 0);
+ LinearLayout layout2 = new LinearLayout(QHybridConfigActivity.this);
+ layout2.setOrientation(LinearLayout.HORIZONTAL);
+
+ final NumberPicker hourPicker = new NumberPicker(QHybridConfigActivity.this);
+ hourPicker.setMinValue(0);
+ hourPicker.setMaxValue(23);
+ hourPicker.setValue(timeOffset / 60);
+
+ final NumberPicker minPicker = new NumberPicker(QHybridConfigActivity.this);
+ minPicker.setMinValue(0);
+ minPicker.setMaxValue(59);
+ minPicker.setValue(timeOffset % 60);
+
+ layout2.addView(hourPicker);
+ TextView tw = new TextView(QHybridConfigActivity.this);
+ tw.setText(":");
+ layout2.addView(tw);
+ layout2.addView(minPicker);
+
+ layout2.setGravity(Gravity.CENTER);
+
+ new MaterialAlertDialogBuilder(QHybridConfigActivity.this)
+ .setTitle(getString(R.string.qhybrid_offset_time_by))
+ .setView(layout2)
+ .setPositiveButton("ok", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ prefs.edit().putInt("QHYBRID_TIME_OFFSET", hourPicker.getValue() * 60 + minPicker.getValue()).apply();
+ updateTimeOffset();
+ LocalBroadcastManager.getInstance(QHybridConfigActivity.this).sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_UPDATE));
+ GB.toast(getString(R.string.qhybrid_changes_delay_prompt), Toast.LENGTH_SHORT, GB.INFO);
+ }
+ })
+ .setNegativeButton("cancel", null)
+ .show();
+ }
+ });
+ updateTimeOffset();
+
+
+ timezoneOffsetView = findViewById(R.id.timezoneOffset);
+ timezoneOffsetView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ int timeOffset = prefs.getInt("QHYBRID_TIMEZONE_OFFSET", 0);
+ LinearLayout layout2 = new LinearLayout(QHybridConfigActivity.this);
+ layout2.setOrientation(LinearLayout.HORIZONTAL);
+
+ final NumberPicker hourPicker = new NumberPicker(QHybridConfigActivity.this);
+ hourPicker.setMinValue(0);
+ hourPicker.setMaxValue(23);
+ hourPicker.setValue(timeOffset / 60);
+
+ final NumberPicker minPicker = new NumberPicker(QHybridConfigActivity.this);
+ minPicker.setMinValue(0);
+ minPicker.setMaxValue(59);
+ minPicker.setValue(timeOffset % 60);
+
+ layout2.addView(hourPicker);
+ TextView tw = new TextView(QHybridConfigActivity.this);
+ tw.setText(":");
+ layout2.addView(tw);
+ layout2.addView(minPicker);
+
+ layout2.setGravity(Gravity.CENTER);
+
+ new MaterialAlertDialogBuilder(QHybridConfigActivity.this)
+ .setTitle(getString(R.string.qhybrid_offset_timezone))
+ .setView(layout2)
+ .setPositiveButton("ok", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ prefs.edit().putInt("QHYBRID_TIMEZONE_OFFSET", hourPicker.getValue() * 60 + minPicker.getValue()).apply();
+ updateTimezoneOffset();
+ LocalBroadcastManager.getInstance(QHybridConfigActivity.this).sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_UPDATE_TIMEZONE));
+ GB.toast(getString(R.string.qhybrid_changes_delay_prompt), Toast.LENGTH_SHORT, GB.INFO);
+ }
+ })
+ .setNegativeButton("cancel", null)
+ .show();
+ }
+ });
+ updateTimezoneOffset();
+
+ setTitle(R.string.preferences_qhybrid_settings);
+
+ ListView appList = findViewById(R.id.qhybrid_appList);
+
+ try {
+ helper = new PackageConfigHelper(getApplicationContext());
+ list = helper.getNotificationConfigurations();
+ } catch (Exception e) {
+ GB.toast("error getting configurations", Toast.LENGTH_SHORT, GB.ERROR, e);
+ list = new ArrayList<>();
+ }
+ // null is added to indicate the plus button added handled in PackageAdapter#getView
+ list.add(null);
+ appList.setAdapter(adapter = new PackageAdapter(this, R.layout.qhybrid_package_settings_item, list));
+ appList.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
+ @Override
+ public boolean onItemLongClick(final AdapterView<?> adapterView, View view, final int i, long l) {
+ PopupMenu menu = new PopupMenu(QHybridConfigActivity.this, view);
+ menu.getMenu().add(0, 0, 0, "edit");
+ menu.getMenu().add(0, 1, 1, "delete");
+ menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem menuItem) {
+ switch (menuItem.getItemId()) {
+ case 0: {
+ TimePicker picker = new TimePicker(QHybridConfigActivity.this, (NotificationConfiguration) adapterView.getItemAtPosition(i));
+ picker.finishListener = new TimePicker.OnFinishListener() {
+ @Override
+ public void onFinish(boolean success, NotificationConfiguration config) {
+ setControl(false, null);
+ if (success) {
+ try {
+ helper.saveNotificationConfiguration(config);
+ LocalBroadcastManager.getInstance(QHybridConfigActivity.this).sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_NOTIFICATION_CONFIG_CHANGED));
+ } catch (Exception e) {
+ GB.toast("error saving notification", Toast.LENGTH_SHORT, GB.ERROR, e);
+ }
+ refreshList();
+ }
+ }
+ };
+ picker.handsListener = new TimePicker.OnHandsSetListener() {
+ @Override
+ public void onHandsSet(NotificationConfiguration config) {
+ setHands(config);
+ }
+ };
+ picker.vibrationListener = new TimePicker.OnVibrationSetListener() {
+ @Override
+ public void onVibrationSet(NotificationConfiguration config) {
+ vibrate(config);
+ }
+ };
+ setControl(true, picker.getSettings());
+ break;
+ }
+ case 1: {
+ try {
+ helper.deleteNotificationConfiguration((NotificationConfiguration) adapterView.getItemAtPosition(i));
+ LocalBroadcastManager.getInstance(QHybridConfigActivity.this).sendBroadcast(new Intent(QHybridSupport.QHYBRID_COMMAND_NOTIFICATION_CONFIG_CHANGED));
+ } catch (Exception e) {
+ GB.toast("error deleting setting", Toast.LENGTH_SHORT, GB.ERROR, e);
+ }
+ refreshList();
+ break;
+ }
+ }
+ return true;
+ }
+ });
+ menu.show();
+ return true;
+ }
+ });
+
+ appList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
+ Intent notificationIntent = new Intent(QHybridSupport.QHYBRID_COMMAND_NOTIFICATION);
+ notificationIntent.putExtra("CONFIG", (NotificationConfiguration) adapterView.getItemAtPosition(i));
+ LocalBroadcastManager.getInstance(QHybridConfigActivity.this).sendBroadcast(notificationIntent);
+ }
+ });
+ SeekBar vibeBar = findViewById(R.id.vibrationStrengthBar);
+ vibeBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ int start;
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ start = seekBar.getProgress();
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ int progress;
+ if ((progress = seekBar.getProgress()) == start) return;
+ String[] values = {"25", "50", "100"};
+ device.addDeviceInfo(new GenericItem(QHybridSupport.ITEM_VIBRATION_STRENGTH, values[progress]));
+ Intent intent = new Intent(QHybridSupport.QHYBRID_COMMAND_UPDATE_SETTINGS);
+ intent.putExtra("EXTRA_SETTING", QHybridSupport.ITEM_VIBRATION_STRENGTH);
+ LocalBroadcastManager.getInstance(QHybridConfigActivity.this).sendBroadcast(intent);
+ }
+ });
+
+ if (device.getType() == DeviceType.FOSSILQHYBRID && device.isInitialized() && device.getFirmwareVersion().charAt(2) == '0') {
+ updateSettings();
+ } else {
+ setSettingsError(getString(R.string.watch_not_connected));
+ }
+ }
+
+ private void updateTimeOffset() {
+ int timeOffset = prefs.getInt("QHYBRID_TIME_OFFSET", 0);
+ DecimalFormat format = new DecimalFormat("00");
+ timeOffsetView.setText(
+ format.format(timeOffset / 60) + ":" +
+ format.format(timeOffset % 60)
+ );
+ }
+
+
+ private void updateTimezoneOffset() {
+ int timeOffset = prefs.getInt("QHYBRID_TIMEZONE_OFFSET", 0);
+ DecimalFormat format = new DecimalFormat("00");
+ timezoneOffsetView.setText(
+ format.format(timeOffset / 60) + ":" +
+ format.format(timeOffset % 60)
+ );
+ }
+
+
+ private void setSettingsEnabled(boolean enables) {
+ findViewById(R.id.settingsLayout).setAlpha(enables ? 1f : 0.2f);
+ }
+
+ private void updateSettings() {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ EditText et = findViewById(R.id.stepGoalEt);
+ et.setOnEditorActionListener(null);
+ final String text = device.getDeviceInfo(QHybridSupport.ITEM_STEP_GOAL).getDetails();
+ et.setText(text);
+ et.setSelection(text.length());
+ et.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView textView, int i, KeyEvent keyEvent) {
+ if (i == EditorInfo.IME_ACTION_DONE || i == EditorInfo.IME_ACTION_NEXT) {
+ String t = textView.getText().toString();
+ if (!t.isEmpty()) {
+ device.addDeviceInfo(new GenericItem(QHybridSupport.ITEM_STEP_GOAL, t));
+ Intent intent = new Intent(QHybridSupport.QHYBRID_COMMAND_UPDATE_SETTINGS);
+ intent.putExtra("EXTRA_SETTING", QHybridSupport.ITEM_STEP_GOAL);
+ LocalBroadcastManager.getInstance(QHybridConfigActivity.this).sendBroadcast(intent);
+ updateSettings();
+ }
+ ((InputMethodManager) getApplicationContext().getSystemService(Activity.INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(getCurrentFocus().getWindowToken(), 0);
+ }
+ return true;
+ }
+ });
+
+ if ("true".equals(device.getDeviceInfo(QHybridSupport.ITEM_EXTENDED_VIBRATION_SUPPORT).getDetails())) {
+ final int strengthProgress = (int) (Math.log(Double.parseDouble(device.getDeviceInfo(QHybridSupport.ITEM_VIBRATION_STRENGTH).getDetails()) / 25) / Math.log(2));
+
+ setSettingsEnabled(true);
+ SeekBar seekBar = findViewById(R.id.vibrationStrengthBar);
+ seekBar.setProgress(strengthProgress);
+ } else {
+ findViewById(R.id.vibrationStrengthBar).setEnabled(false);
+ findViewById(R.id.vibrationStrengthLayout).setAlpha(0.5f);
+ }
+ CheckBox activityHandCheckbox = findViewById(R.id.checkBoxUserActivityHand);
+ if (device.getDeviceInfo(QHybridSupport.ITEM_HAS_ACTIVITY_HAND).getDetails().equals("true")) {
+ if (device.getDeviceInfo(QHybridSupport.ITEM_USE_ACTIVITY_HAND).getDetails().equals("true")) {
+ activityHandCheckbox.setChecked(true);
+ }
+ activityHandCheckbox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean checked) {
+ if (!device.getDeviceInfo(QHybridSupport.ITEM_STEP_GOAL).getDetails().equals("1000000")) {
+ new MaterialAlertDialogBuilder(QHybridConfigActivity.this)
+ .setMessage(getString(R.string.qhybrid_prompt_million_steps))
+ .setPositiveButton("ok", null)
+ .show();
+ buttonView.setChecked(false);
+ return;
+ }
+ device.addDeviceInfo(new GenericItem(QHybridSupport.ITEM_USE_ACTIVITY_HAND, String.valueOf(checked)));
+ Intent intent = new Intent(QHybridSupport.QHYBRID_COMMAND_UPDATE_SETTINGS);
+ intent.putExtra("EXTRA_SETTING", QHybridSupport.ITEM_USE_ACTIVITY_HAND);
+ LocalBroadcastManager.getInstance(QHybridConfigActivity.this).sendBroadcast(intent);
+ }
+ });
+ } else {
+ // activityHandCheckbox.setEnabled(false);
+ activityHandCheckbox.setAlpha(0.2f);
+ activityHandCheckbox.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GB.toast("nah.", Toast.LENGTH_SHORT, GB.INFO);
+ ((CheckBox) v).setChecked(false);
+ }
+ });
+ }
+
+ ItemWithDetails item = device.getDeviceInfo(FossilWatchAdapter.ITEM_BUTTONS);
+ String buttonJson = null;
+ if(item != null) {
+ buttonJson = item.getDetails();
+ }
+ try {
+ JSONArray buttonConfig_;
+ if (buttonJson == null || buttonJson.isEmpty()) {
+ buttonConfig_ = new JSONArray(new String[]{"", "", ""});
+ }else{
+ buttonConfig_ = new JSONArray(buttonJson);
+ }
+
+ final JSONArray buttonConfig = buttonConfig_;
+
+ LinearLayout buttonLayout = findViewById(R.id.buttonConfigLayout);
+ buttonLayout.removeAllViews();
+ findViewById(R.id.buttonOverwriteButtons).setVisibility(View.GONE);
+ final ConfigPayload[] payloads = ConfigPayload.values();
+ final String[] names = new String[payloads.length];
+ for (int i = 0; i < payloads.length; i++)
+ names[i] = payloads[i].getDescription();
+ for (int i = 0; i < buttonConfig.length(); i++) {
+ final int currentIndex = i;
+ String configName = buttonConfig.getString(i);
+ TextView buttonTextView = new TextView(QHybridConfigActivity.this);
+ buttonTextView.setTextSize(20);
+ try {
+ ConfigPayload payload = ConfigPayload.valueOf(configName);
+ buttonTextView.setText("Button " + (i + 1) + ": " + payload.getDescription());
+ } catch (IllegalArgumentException e) {
+ buttonTextView.setText("Button " + (i + 1) + ": Unknown");
+ }
+
+ buttonTextView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ AlertDialog dialog = new MaterialAlertDialogBuilder(QHybridConfigActivity.this)
+ .setItems(names, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.cancel();
+ ConfigPayload selected = payloads[which];
+
+ try {
+ buttonConfig.put(currentIndex, selected.toString());
+ device.addDeviceInfo(new GenericItem(FossilWatchAdapter.ITEM_BUTTONS, buttonConfig.toString()));
+ updateSettings();
+ Intent buttonIntent = new Intent(QHybridSupport.QHYBRID_COMMAND_OVERWRITE_BUTTONS);
+ buttonIntent.putExtra(FossilWatchAdapter.ITEM_BUTTONS, buttonConfig.toString());
+ LocalBroadcastManager.getInstance(QHybridConfigActivity.this).sendBroadcast(buttonIntent);
+ } catch (JSONException e) {
+ GB.log("error", GB.ERROR, e);
+ }
+ }
+ })
+ .setTitle("Button " + (currentIndex + 1))
+ .create();
+ dialog.show();
+ }
+ });
+
+ buttonLayout.addView(buttonTextView);
+ }
+ } catch (JSONException e) {
+ GB.toast("error parsing button config", Toast.LENGTH_LONG, GB.ERROR);
+ }
+ }
+ });
+ }
+
+ private void setControl(boolean control, NotificationConfiguration config) {
+ if (hasControl == control) return;
+ Intent intent = new Intent(control ? QHybridSupport.QHYBRID_COMMAND_CONTROL : QHybridSupport.QHYBRID_COMMAND_UNCONTROL);
+ intent.putExtra("CONFIG", config);
+ LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
+ this.hasControl = control;
+ }
+
+ private void setHands(NotificationConfiguration config) {
+ sendControl(config, QHybridSupport.QHYBRID_COMMAND_SET);
+ }
+
+ private void vibrate(NotificationConfiguration config) {
+ sendControl(config, QHybridSupport.QHYBRID_COMMAND_VIBRATE);
+ }
+
+ private void sendControl(NotificationConfiguration config, String request) {
+ Intent intent = new Intent(request);
+ intent.putExtra("CONFIG", config);
+ LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
+ }
+
+ private void refreshList() {
+ list.clear();
+ try {
+ list.addAll(helper.getNotificationConfigurations());
+ } catch (Exception e) {
+ GB.toast("error getting configurations", Toast.LENGTH_SHORT, GB.ERROR, e);
+ }
+ // null is added to indicate the plus button added handled in PackageAdapter#getView
+ list.add(null);
+ adapter.notifyDataSetChanged();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ refreshList();
+ ContextCompat.registerReceiver(this, buttonReceiver, new IntentFilter(QHybridSupport.QHYBRID_EVENT_BUTTON_PRESS), ContextCompat.RECEIVER_NOT_EXPORTED);
+ LocalBroadcastManager.getInstance(this).registerReceiver(settingsReceiver, new IntentFilter(QHybridSupport.QHYBRID_EVENT_SETTINGS_UPDATED));
+ LocalBroadcastManager.getInstance(this).registerReceiver(fileReceiver, new IntentFilter(QHybridSupport.QHYBRID_EVENT_FILE_UPLOADED));
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ unregisterReceiver(buttonReceiver);
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(settingsReceiver);
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(fileReceiver);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ this.onBackPressed();
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void setSettingsError(final String error) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ setSettingsEnabled(false);
+ ((TextView) findViewById(R.id.settingsErrorText)).setVisibility(View.VISIBLE);
+ ((TextView) findViewById(R.id.settingsErrorText)).setText(error);
+ }
+ });
+ }
+
+ class PackageAdapter extends ArrayAdapter<NotificationConfiguration> {
+ PackageManager manager;
+
+ PackageAdapter(@NonNull Context context, int resource, @NonNull List<NotificationConfiguration> objects) {
+ super(context, resource, objects);
+ manager = context.getPackageManager();
+ }
+
+ @NonNull
+ @Override
+ public View getView(int position, @Nullable View view, @NonNull ViewGroup parent) {
+ if (!(view instanceof RelativeLayout))
+ view = ((LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE)).inflate(R.layout.qhybrid_package_settings_item, null);
+ NotificationConfiguration settings = getItem(position);
+
+ if (settings == null) {
+ Button addButton = new Button(QHybridConfigActivity.this);
+ addButton.setText("+");
+ addButton.setLayoutParams(new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ addButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ startActivityForResult(new Intent(QHybridConfigActivity.this, QHybridAppChoserActivity.class), REQUEST_CODE_ADD_APP);
+ }
+ });
+ return addButton;
+ }
+
+ try {
+ ((ImageView) view.findViewById(R.id.packageIcon)).setImageDrawable(manager.getApplicationIcon(settings.getPackageName()));
+ } catch (PackageManager.NameNotFoundException e) {
+ GB.log("error", GB.ERROR, e);
+ }
+ final int square_side = 100;
+ ((TextView) view.findViewById(R.id.packageName)).setText(settings.getAppName());
+ Bitmap bitmap = Bitmap.createBitmap(square_side, square_side, Bitmap.Config.ARGB_8888);
+ Canvas c = new Canvas(bitmap);
+
+ Paint black = new Paint();
+ black.setColor(Color.BLACK);
+ black.setStyle(Paint.Style.STROKE);
+ black.setStrokeWidth(5);
+
+ c.drawCircle(square_side / 2, square_side / 2, square_side / 2 - 3, black);
+
+ int center = square_side / 2;
+ if (settings.getHour() != -1) {
+ c.drawLine(
+ center,
+ center,
+ (float) (center + Math.sin(Math.toRadians(settings.getHour())) * (square_side / 4)),
+ (float) (center - Math.cos(Math.toRadians(settings.getHour())) * (square_side / 4)),
+ black
+ );
+ }
+ if (settings.getMin() != -1) {
+ c.drawLine(
+ center,
+ center,
+ (float) (center + Math.sin(Math.toRadians(settings.getMin())) * (square_side / 3)),
+ (float) (center - Math.cos(Math.toRadians(settings.getMin())) * (square_side / 3)),
+ black
+ );
+ }
+
+ ((ImageView) view.findViewById(R.id.packageClock)).setImageBitmap(bitmap);
+
+ return view;
+ }
+ }
+
+ BroadcastReceiver fileReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ boolean error = intent.getBooleanExtra("EXTRA_ERROR", false);
+ if (error) {
+ GB.toast(getString(R.string.qhybrid_buttons_overwrite_error), Toast.LENGTH_SHORT, GB.ERROR);
+ return;
+ }
+ GB.toast(getString(R.string.qhybrid_buttons_overwrite_success), Toast.LENGTH_SHORT, GB.INFO);
+ }
+ };
+
+ BroadcastReceiver buttonReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ GB.toast("Button " + intent.getIntExtra("BUTTON", -1) + " pressed", Toast.LENGTH_SHORT, GB.INFO);
+ }
+ };
+
+ BroadcastReceiver settingsReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ GB.toast("Setting updated", Toast.LENGTH_SHORT, GB.INFO);
+ updateSettings();
+ }
+ };
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridConstants.java
new file mode 100644
index 0000000..7ce5fd8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridConstants.java
@@ -0,0 +1,54 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public final class QHybridConstants {
+ public static final String HYBRIDHR_WATCHFACE_VERSION = "1.13";
+ public static final int HYBRID_HR_WATCHFACE_WIDGET_SIZE = 76;
+
+ public static Map<String, String> KNOWN_WAPP_VERSIONS = new HashMap<String, String>() {
+ {
+ put("buddyChallengeApp", "2.10");
+ put("commuteApp", "2.5");
+ put("launcherApp", "3.9");
+ put("musicApp", "3.13");
+ put("notificationsPanelApp", "3.7");
+ put("ringPhoneApp", "3.8");
+ put("settingApp", "3.13");
+ put("stopwatchApp", "3.8");
+ put("timerApp", "3.9");
+ put("weatherApp", "3.11");
+ put("wellnessApp", "3.16");
+ put("AlexaApp", "3.11");
+ put("navigationApp", "1.1");
+ }
+ };
+
+ public static Map<Integer, ActivityKind> WORKOUT_TYPES_TO_ACTIVITY_KIND = new HashMap<Integer, ActivityKind>() {
+ {
+ put(1, ActivityKind.RUNNING);
+ put(2, ActivityKind.CYCLING);
+ put(8, ActivityKind.WALKING);
+ put(12, ActivityKind.HIKING);
+ }
+ };
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java
new file mode 100644
index 0000000..35bc728
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java
@@ -0,0 +1,353 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Damien Gaignon, Daniel Dakhno, Hasan Ammar, Jos茅 Rebelo, Morten
+ Rieger Hannemose, Petr Van臎k
+
+ 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.devices.qhybrid;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Version;
+
+public class QHybridCoordinator extends AbstractBLEDeviceCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(QHybridCoordinator.class);
+
+ @NonNull
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ for(ParcelUuid uuid : candidate.getServiceUuids()){
+ if(uuid.getUuid().toString().equals("3dda0001-957f-7d4a-34a6-74696673696d")){
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ return Collections.singletonList(new ScanFilter.Builder().setServiceUuid(ParcelUuid.fromString("3dda0001-957f-7d4a-34a6-74696673696d")).build());
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ for(GBDevice device : devices){
+ if(isFossilHybrid(device) && device.getState() == GBDevice.State.INITIALIZED){
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new HybridHRActivitySampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(GBDevice device, DaoSession session) {
+ return new HybridHRSpo2SampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ if (isHybridHR()) {
+ FossilHRInstallHandler installHandler = new FossilHRInstallHandler(uri, context);
+ if (!installHandler.isValid()) {
+ LOG.warn("Not a Fossil Hybrid firmware or app!");
+ return null;
+ } else {
+ return installHandler;
+ }
+ }
+ FossilInstallHandler installHandler = new FossilInstallHandler(uri, context);
+ return installHandler.isValid() ? installHandler : null;
+ }
+
+ private boolean supportsAlarmConfiguration() {
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ LOG.debug("devices count: " + devices.size());
+ for(GBDevice device : devices){
+ if(isFossilHybrid(device) && device.getState() == GBDevice.State.INITIALIZED){
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return this.supportsAlarmConfiguration() ? 5 : 0;
+ }
+
+ @Override
+ public int getCannedRepliesSlotCount(final GBDevice device) {
+ if (isHybridHR(device)) {
+ return 16;
+ }
+
+ return 0;
+ }
+
+ @Override
+ public boolean supportsAlarmTitle(GBDevice device) {
+ return isHybridHR(device);
+ }
+
+ @Override
+ public boolean supportsAlarmDescription(GBDevice device) {
+ return isHybridHR(device);
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return isHybridHR(device);
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Fossil";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsAppListFetching() {
+ return true;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return isHybridHR() ? AppManagerActivity.class : QHybridConfigActivity.class;
+ }
+
+ @Override
+ public Class<? extends Activity> getWatchfaceDesignerActivity() {
+ return isHybridHR() ? HybridHRWatchfaceDesignerActivity.class : null;
+ }
+
+ /**
+ * Returns the directory containing the watch app cache.
+ * @throws IOException when the external files directory cannot be accessed
+ */
+ public File getAppCacheDir() throws IOException {
+ return new File(FileUtils.getExternalFilesDir(), "qhybrid-app-cache");
+ }
+
+ /**
+ * Returns a String containing the device app sort order filename.
+ */
+ @Override
+ public String getAppCacheSortFilename() {
+ return "wappcacheorder.txt";
+ }
+
+ /**
+ * Returns a String containing the file extension for watch apps.
+ */
+ @Override
+ public String getAppFileExtension() {
+ return ".wapp";
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return isHybridHR();
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ return new BatteryConfig[]{
+ new BatteryConfig(
+ 0,
+ GBDevice.BATTERY_ICON_DEFAULT,
+ GBDevice.BATTERY_LABEL_DEFAULT,
+ isHybridHR(device) ? 10 : 2,
+ 100
+ )
+ };
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+ if (!isHybridHR(device)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_fossilqhybrid_legacy);
+ return deviceSpecificSettings;
+ }
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ // Firmware version specific settings
+ if (getFirmwareVersion() != null && getFirmwareVersion().smallerThan(new Version("3.0"))) {
+ generic.add(R.xml.devicesettings_fossilhybridhr_pre_fw300);
+ } else {
+ generic.add(R.xml.devicesettings_fossilhybridhr_post_fw300);
+ }
+ if (getFirmwareVersion() != null && getFirmwareVersion().smallerThan(new Version("2.20"))) {
+ generic.add(R.xml.devicesettings_fossilhybridhr_pre_fw220);
+ }
+ // Settings applicable to all firmware versions
+ generic.add(R.xml.devicesettings_fossilhybridhr_calibration);
+ generic.add(R.xml.devicesettings_fossilhybridhr_navigation);
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_fossilhybridhr_workout_detection);
+ health.add(R.xml.devicesettings_inactivity);
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_fossilhybridhr_vibration);
+ notifications.add(R.xml.devicesettings_autoremove_notifications);
+ notifications.add(R.xml.devicesettings_canned_dismisscall_16);
+ notifications.add(R.xml.devicesettings_reject_call_method);
+ notifications.add(R.xml.devicesettings_transliteration);
+ notifications.add(R.xml.devicesettings_custom_deviceicon);
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_fossilhybridhr_dev);
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new QHybridSettingsCustomizer();
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return QHybridSupport.class;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificAuthenticationSettings() {
+ return new int[]{
+ R.xml.devicesettings_pairingkey
+ };
+ }
+
+ @Deprecated // we should use the isHybridHR(GBDevice) instead of iterating every single device
+ private boolean isHybridHR() {
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ for(GBDevice device : devices){
+ if(isHybridHR(device)){
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean isHybridHR(GBDevice device){
+ if(!isFossilHybrid(device)) return false;
+ return device.getName().startsWith("Hybrid HR") || device.getName().equals("Fossil Gen. 6 Hybrid");
+ }
+
+ private Version getFirmwareVersion() {
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ for (GBDevice device : devices) {
+ if (isFossilHybrid(device)) {
+ return new Version(device.getFirmwareVersion2());
+ }
+ }
+
+ return null;
+ }
+
+ private boolean isFossilHybrid(GBDevice device){
+ return device.getType() == DeviceType.FOSSILQHYBRID;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_qhybrid;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_zetime;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_zetime_disabled;
+ }
+
+ @Override
+ public boolean supportsNavigation() {
+ return isHybridHR();
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return device.getName().equals("Fossil Gen. 6 Hybrid");
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridSettingsCustomizer.java
new file mode 100644
index 0000000..1ebc04d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridSettingsCustomizer.java
@@ -0,0 +1,60 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.qhybrid;
+
+import android.content.Intent;
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+import androidx.preference.Preference;
+
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class QHybridSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ final Preference pref = handler.findPreference("pref_key_qhybrid_legacy");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ final Intent intent = new Intent(handler.getContext(), QHybridConfigActivity.class);
+ intent.putExtra(GBDevice.EXTRA_DEVICE, handler.getDevice());
+ handler.getContext().startActivity(intent);
+ return true;
+ });
+ }
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ public static final Creator<QHybridSettingsCustomizer> CREATOR = new Creator<QHybridSettingsCustomizer>() {
+ @Override
+ public QHybridSettingsCustomizer createFromParcel(final Parcel in) {
+ return new QHybridSettingsCustomizer();
+ }
+
+ @Override
+ public QHybridSettingsCustomizer[] newArray(final int size) {
+ return new QHybridSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull final Parcel dest, final int flags) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/TimePicker.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/TimePicker.java
new file mode 100644
index 0000000..be026be
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/TimePicker.java
@@ -0,0 +1,297 @@
+/* Copyright (C) 2019-2024 Arjan Schrijver, Daniel Dakhno
+
+ 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.devices.qhybrid;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.pm.PackageInfo;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.ScrollView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.PlayNotificationRequest;
+
+public class TimePicker extends MaterialAlertDialogBuilder {
+ ImageView pickerView;
+ Canvas pickerCanvas;
+ Bitmap pickerBitmap;
+
+ NotificationConfiguration settings;
+
+ int height, width, radius;
+ int radius1, radius2, radius3;
+ int controlledHand = 0;
+ int handRadius;
+
+ AlertDialog dialog;
+
+ OnFinishListener finishListener;
+ OnHandsSetListener handsListener;
+ OnVibrationSetListener vibrationListener;
+
+ protected TimePicker(@NonNull Context context, PackageInfo info) {
+ super(context);
+
+ settings = new NotificationConfiguration(info.packageName, context.getApplicationContext().getPackageManager().getApplicationLabel(info.applicationInfo).toString());
+ initGraphics(context);
+ }
+
+ protected TimePicker(Context context, NotificationConfiguration config){
+ super(context);
+
+ settings = config;
+ initGraphics(context);
+ }
+
+ private void initGraphics(Context context){
+ int w = (int) (((WindowManager) context.getApplicationContext().getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay().getWidth() * 0.8);
+ height = w;
+ width = w;
+ radius = (int) (w * 0.06);
+ radius1 = 0;
+ radius2 = (int) (radius * 2.3);
+ radius3 = (int)(radius2 * 2.15);
+ int offset = (int) (w * 0.1);
+ radius1 += offset;
+ radius2 += offset;
+ radius3 += offset;
+
+ pickerView = new ImageView(context);
+ pickerBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+
+ pickerCanvas = new Canvas(pickerBitmap);
+
+ drawClock();
+
+ LinearLayout layout = new LinearLayout(context);
+ layout.setOrientation(LinearLayout.VERTICAL);
+ layout.addView(pickerView);
+
+ CheckBox box = new CheckBox(context);
+ box.setText("Respect silent mode");
+ box.setChecked(settings.getRespectSilentMode());
+ box.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
+ settings.setRespectSilentMode(b);
+ }
+ });
+ layout.addView(box);
+
+ RadioGroup group = new RadioGroup(context);
+ for(PlayNotificationRequest.VibrationType vibe: PlayNotificationRequest.VibrationType.values()){
+ RadioButton button = new RadioButton(context);
+ button.setText(vibe.toString());
+ button.setId(vibe.getValue());
+ group.addView(button);
+ }
+
+ group.check(settings.getVibration().getValue());
+ group.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(RadioGroup radioGroup, int i) {
+ settings.setVibration(PlayNotificationRequest.VibrationType.fromValue((byte)i));
+ if(TimePicker.this.vibrationListener != null) TimePicker.this.vibrationListener.onVibrationSet(settings);
+ }
+ });
+
+ ScrollView scrollView = new ScrollView(context);
+ scrollView.addView(group);
+ layout.addView(scrollView);
+
+ setView(layout);
+
+
+
+ setNegativeButton("cancel", null);
+ setPositiveButton("ok", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ if(finishListener == null) return;
+ finishListener.onFinish(true, settings);
+ }
+ });
+ setOnCancelListener(new DialogInterface.OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialogInterface) {
+ if(finishListener == null) return;
+ finishListener.onFinish(false, settings);
+ }
+ });
+ setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface dialogInterface) {
+ if(finishListener == null) return;
+ finishListener.onFinish(false, settings);
+ }
+ });
+ dialog = show();
+
+ pickerView.setOnTouchListener(new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(View view, MotionEvent motionEvent) {
+ handleTouch(dialog, motionEvent);
+ return true;
+ }
+ });
+ }
+
+ public NotificationConfiguration getSettings() {
+ return settings;
+ }
+
+ private void handleTouch(AlertDialog dialog, MotionEvent event) {
+ int centerX = width / 2;
+ int centerY = height / 2;
+ int difX = centerX - (int) event.getX();
+ int difY = (int) event.getY() - centerY;
+ int dist = (int) Math.sqrt(Math.abs(difX) * Math.abs(difX) + Math.abs(difY) * Math.abs(difY));
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN: {
+ int radiusHalf = radius;
+ if (dist < (radius1 + radiusHalf) && dist > (radius1 - radiusHalf)) {
+ Log.d("Settings", "hit sub");
+ handRadius = (int) (height / 2f - radius1);
+ controlledHand = 3;
+ } else if (dist < (radius2 + radiusHalf) && dist > (radius2 - radiusHalf)) {
+ Log.d("Settings", "hit hour");
+ controlledHand = 1;
+ handRadius = (int) (height / 2f - radius2);
+ } else if (dist < (radius3 + radiusHalf) && dist > (radius3 - radiusHalf)) {
+ Log.d("Settings", "hit minute");
+ controlledHand = 2;
+ handRadius = (int) (height / 2f - radius3);
+ } else {
+ Log.d("Settings", "hit nothing");
+ }
+ break;
+ }
+ case MotionEvent.ACTION_MOVE: {
+ if (controlledHand == 0) return;
+ double degree = difY == 0 ? (difX < 0 ? 90 : 270) : Math.toDegrees(Math.atan((float) difX / (float) difY));
+ if (difY > 0) degree = 180 + degree;
+ if (degree < 0) degree = 360 + degree;
+ switch (controlledHand) {
+ case 1: {
+ settings.setHour((short) (((int)(degree + 15) / 30) * 30 % 360));
+ break;
+ }
+ case 2: {
+ settings.setMin((short) (((int)(degree + 15) / 30) * 30 % 360));
+ break;
+ }
+ }
+ break;
+ }
+ case MotionEvent.ACTION_UP: {
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setClickable(true);
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setAlpha(1f);
+ if(handsListener != null) handsListener.onHandsSet(settings);
+ break;
+ }
+ }
+ drawClock();
+ }
+
+
+ private void drawClock() {
+ //pickerCanvas.drawColor(Color.WHITE);
+ Paint white = new Paint();
+ white.setColor(Color.WHITE);
+ white.setStyle(Paint.Style.FILL);
+ pickerCanvas.drawCircle(width / 2, width / 2, width / 2, white);
+
+ Paint paint = new Paint();
+ paint.setStyle(Paint.Style.FILL_AND_STROKE);
+ paint.setColor(Color.BLUE);
+ Paint text = new Paint();
+ text.setStyle(Paint.Style.FILL);
+ text.setTextSize(radius * 1.5f);
+ text.setColor(Color.BLACK);
+ text.setTextAlign(Paint.Align.CENTER);
+ int textShiftY = (int) ((text.descent() + text.ascent()) / 2);
+
+ Paint linePaint = new Paint();
+ linePaint.setStrokeWidth(10);
+ linePaint.setStyle(Paint.Style.FILL_AND_STROKE);
+ linePaint.setColor(Color.BLACK);
+
+ if (settings.getMin() != -1) {
+ paint.setAlpha(255);
+ float x = (float) (width / 2f + Math.sin(Math.toRadians(settings.getMin())) * (float) radius3);
+ float y = (float) (height / 2f - Math.cos(Math.toRadians(settings.getMin())) * (float) radius3);
+ linePaint.setAlpha(255);
+ pickerCanvas.drawLine(width / 2, height / 2, x, y, linePaint);
+ pickerCanvas.drawCircle(
+ x,
+ y,
+ radius,
+ paint
+ );
+ pickerCanvas.drawText(String.valueOf(settings.getMin() / 6), x, y - textShiftY, text);
+ }
+ if (settings.getHour() != -1) {
+ paint.setAlpha(255);
+ float x = (float) (width / 2f + Math.sin(Math.toRadians(settings.getHour())) * (float) radius2);
+ float y = (float) (height / 2f - Math.cos(Math.toRadians(settings.getHour())) * (float) radius2);
+ linePaint.setAlpha(255);
+ pickerCanvas.drawLine(width / 2, height / 2, x, y, linePaint);
+ pickerCanvas.drawCircle(
+ x,
+ y,
+ radius,
+ paint
+ );
+ pickerCanvas.drawText(settings.getHour() == 0 ? "12" : String.valueOf(settings.getHour() / 30), x, y - textShiftY, text);
+ }
+
+ Paint paint2 = new Paint();
+ paint2.setColor(Color.BLACK);
+ paint2.setStyle(Paint.Style.FILL_AND_STROKE);
+ pickerCanvas.drawCircle(width / 2, height / 2, 5, paint2);
+ pickerView.setImageBitmap(pickerBitmap);
+ }
+
+ interface OnFinishListener{
+ public void onFinish(boolean success, NotificationConfiguration config);
+ }
+
+ interface OnHandsSetListener{
+ public void onHandsSet(NotificationConfiguration config);
+ }
+
+ interface OnVibrationSetListener{
+ public void onVibrationSet(NotificationConfiguration config);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/WidgetSettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/WidgetSettingsActivity.java
new file mode 100644
index 0000000..a284949
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/WidgetSettingsActivity.java
@@ -0,0 +1,220 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Arjan Schrijver, Daniel Dakhno
+
+ 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.devices.qhybrid;
+
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.EditText;
+import android.widget.ListView;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.CustomBackgroundWidgetElement;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.CustomTextWidgetElement;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.CustomWidget;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.widget.CustomWidgetElement;
+
+public class WidgetSettingsActivity extends AbstractGBActivity {
+ private CustomWidget subject;
+ private WidgetElementAdapter widgetElementAdapter;
+
+ public static final int RESULT_CODE_WIDGET_CREATED = 0;
+ public static final int RESULT_CODE_WIDGET_UPDATED = 1;
+ public static final int RESULT_CODE_WIDGET_DELETED = 2;
+ public static final int RESULT_CODE_CANCELED = 3;
+
+ private int resultCode;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.qhybrid_activity_widget_settings);
+
+ setResult(RESULT_CODE_CANCELED);
+
+ if(getIntent().hasExtra("EXTRA_WIDGET")){
+ subject = (CustomWidget) getIntent().getExtras().get("EXTRA_WIDGET");
+ ((EditText) findViewById(R.id.qhybrid_widget_name)).setText(subject.getName());
+ resultCode = RESULT_CODE_WIDGET_UPDATED;
+ }else{
+ subject = new CustomWidget("", 0, 63, "default"); // FIXME: handle force white background
+ resultCode = RESULT_CODE_WIDGET_CREATED;
+ findViewById(R.id.qhybrid_widget_delete).setEnabled(false);
+ }
+
+ findViewById(R.id.qhybrid_widget_save).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ subject.setName(((EditText) findViewById(R.id.qhybrid_widget_name)).getText().toString());
+
+ Intent resultIntent = getIntent();
+ resultIntent.putExtra("EXTRA_WIDGET", WidgetSettingsActivity.this.subject);
+ setResult(resultCode, resultIntent);
+
+ finish();
+ }
+ });
+
+ findViewById(R.id.qhybrid_widget_delete).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ setResult(RESULT_CODE_WIDGET_DELETED, getIntent());
+
+ finish();
+ }
+ });
+
+ widgetElementAdapter = new WidgetElementAdapter(subject.getElements());
+ ListView elementList = findViewById(R.id.qhybrid_widget_elements_list);
+ elementList.setAdapter(widgetElementAdapter);
+ elementList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ showElementDialog(widgetElementAdapter.getItem(position));
+ }
+ });
+
+ findViewById(R.id.qhybrid_widget_elements_add).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ showElementDialog(null);
+ }
+ });
+ }
+
+ private void showElementDialog(@Nullable final CustomWidgetElement element){
+ MaterialAlertDialogBuilder dialogBuilder = new MaterialAlertDialogBuilder(WidgetSettingsActivity.this)
+ .setView(R.layout.qhybrid_element_popup_view);
+
+ if(element == null) {
+ dialogBuilder
+ .setTitle("create element")
+ .setNegativeButton("cancel", null)
+ .setPositiveButton("ok", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if(((RadioButton)((AlertDialog)dialog).findViewById(R.id.qhybrid_widget_elements_type_text)).isChecked()){
+ subject.addElement(new CustomTextWidgetElement(
+ ((EditText)((AlertDialog)dialog).findViewById(R.id.qhybrid_widget_element_id)).getText().toString(),
+ ((EditText)((AlertDialog)dialog).findViewById(R.id.qhybrid_widget_element_value)).getText().toString(),
+ CustomWidgetElement.X_CENTER,
+ ((RadioButton)((AlertDialog)dialog).findViewById(R.id.qhybrid_widget_elements_position_uppper)).isChecked() ? CustomTextWidgetElement.Y_UPPER_HALF : CustomTextWidgetElement.Y_LOWER_HALF
+ ));
+ }else{
+ subject.addElement(new CustomBackgroundWidgetElement(
+ ((EditText)((AlertDialog)dialog).findViewById(R.id.qhybrid_widget_element_id)).getText().toString(),
+ ((EditText)((AlertDialog)dialog).findViewById(R.id.qhybrid_widget_element_value)).getText().toString()
+ ));
+ }
+ refreshElementsList();
+ }
+ });
+ }else{
+ dialogBuilder
+ .setTitle("edit element")
+ .setNegativeButton("delete", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ WidgetSettingsActivity.this.subject.getElements().remove(element);
+
+ refreshElementsList();
+ }
+ })
+ .setPositiveButton("ok", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ element.setId(((EditText)((AlertDialog)dialog).findViewById(R.id.qhybrid_widget_element_id)).getText().toString());
+ element.setValue(((EditText)((AlertDialog)dialog).findViewById(R.id.qhybrid_widget_element_value)).getText().toString());
+ element.setY(((RadioButton)((AlertDialog)dialog).findViewById(R.id.qhybrid_widget_elements_position_uppper)).isChecked() ? CustomTextWidgetElement.Y_UPPER_HALF : CustomTextWidgetElement.Y_LOWER_HALF);
+ element.setWidgetElementType(CustomWidgetElement.WidgetElementType.fromRadioButtonRessource(((RadioGroup)((AlertDialog)dialog).findViewById(R.id.qhybrid_widget_element_type)).getCheckedRadioButtonId()));
+
+ refreshElementsList();
+ }
+ });
+ }
+
+ AlertDialog dialog = dialogBuilder.show();
+
+
+ if(element != null){
+ String elementId = element.getId();
+ String elementValue = element.getValue();
+ CustomWidgetElement.WidgetElementType type = element.getWidgetElementType();
+
+ ((EditText)dialog.findViewById(R.id.qhybrid_widget_element_id)).setText(elementId);
+ ((EditText)dialog.findViewById(R.id.qhybrid_widget_element_value)).setText(elementValue);
+ ((RadioGroup)dialog.findViewById(R.id.qhybrid_widget_element_type)).check(type.getRadioButtonResource());
+ ((RadioGroup)dialog.findViewById(R.id.qhybrid_widget_element_position)).check(element.getY() == CustomWidgetElement.Y_UPPER_HALF ? R.id.qhybrid_widget_elements_position_uppper : R.id.qhybrid_widget_elements_position_lower);
+ }
+ }
+
+ private void refreshElementsList(){
+ this.widgetElementAdapter.notifyDataSetChanged();
+ }
+
+ class WidgetElementAdapter extends ArrayAdapter<CustomWidgetElement>{
+ public WidgetElementAdapter(@NonNull List<CustomWidgetElement> objects) {
+ super(WidgetSettingsActivity.this, 0, objects);
+ }
+
+ @NonNull
+ @Override
+ public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
+ RelativeLayout layout = new RelativeLayout(WidgetSettingsActivity.this);
+ layout.setLayoutParams(new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+
+
+ TextView idView = new TextView(WidgetSettingsActivity.this);
+
+ idView.setText(getItem(position).getId());
+ // view.setTextColor(Color.WHITE);
+ idView.setTextSize(25);
+ RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.addRule(RelativeLayout.ALIGN_PARENT_START, RelativeLayout.TRUE);
+ idView.setLayoutParams(params);
+
+ TextView contentView = new TextView(WidgetSettingsActivity.this);
+ contentView.setText(getItem(position).getValue());
+ contentView.setTextSize(25);
+ params = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.addRule(RelativeLayout.ALIGN_PARENT_END, RelativeLayout.TRUE);
+ contentView.setLayoutParams(params);
+
+ layout.addView(idView);
+ layout.addView(contentView);
+
+ return layout;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/realme/RealmeBudsT110Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/realme/RealmeBudsT110Coordinator.java
new file mode 100644
index 0000000..9ffea1b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/realme/RealmeBudsT110Coordinator.java
@@ -0,0 +1,73 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.realme;
+
+import android.util.Pair;
+
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoHeadphonesCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigSide;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigValue;
+
+public class RealmeBudsT110Coordinator extends OppoHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("realme Buds T110", Pattern.LITERAL);
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Realme";
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_realme_buds_t110;
+ }
+
+ @Override
+ protected Map<Pair<TouchConfigSide, TouchConfigType>, List<TouchConfigValue>> getTouchOptions() {
+ return new LinkedHashMap<Pair<TouchConfigSide, TouchConfigType>, List<TouchConfigValue>>() {{
+ final List<TouchConfigValue> options = Arrays.asList(
+ TouchConfigValue.OFF,
+ TouchConfigValue.PLAY_PAUSE,
+ TouchConfigValue.PREVIOUS,
+ TouchConfigValue.NEXT,
+ TouchConfigValue.VOLUME_UP,
+ TouchConfigValue.VOLUME_DOWN,
+ TouchConfigValue.VOICE_ASSISTANT_REALME
+ );
+ put(Pair.create(TouchConfigSide.LEFT, TouchConfigType.TAP_2), options);
+ put(Pair.create(TouchConfigSide.LEFT, TouchConfigType.TAP_3), options);
+ put(Pair.create(TouchConfigSide.LEFT, TouchConfigType.HOLD), options);
+ put(Pair.create(TouchConfigSide.RIGHT, TouchConfigType.TAP_2), options);
+ put(Pair.create(TouchConfigSide.RIGHT, TouchConfigType.TAP_3), options);
+ put(Pair.create(TouchConfigSide.RIGHT, TouchConfigType.HOLD), options);
+ put(Pair.create(TouchConfigSide.BOTH, TouchConfigType.HOLD), Arrays.asList(
+ TouchConfigValue.OFF,
+ TouchConfigValue.GAME_MODE
+ ));
+ }};
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/Roidmi1Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/Roidmi1Coordinator.java
new file mode 100644
index 0000000..6ab296d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/Roidmi1Coordinator.java
@@ -0,0 +1,65 @@
+/* Copyright (C) 2018-2024 Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.roidmi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.roidmi.RoidmiSupport;
+
+public class Roidmi1Coordinator extends RoidmiCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(Roidmi1Coordinator.class);
+
+ @NonNull
+ @Override
+ public boolean supports(final GBDeviceCandidate candidate) {
+ try {
+ final String name = candidate.getName();
+
+ if (name != null && name.contains("鐫跨背杞﹁浇钃濈墮鎾斁鍣�")) {
+ return true;
+ }
+ } catch (final Exception ex) {
+ LOG.error("unable to check device support", ex);
+ }
+
+ return false;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ // Roidmi 1 does not have voltage support
+ return 0;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return RoidmiSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_roidmi;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/Roidmi3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/Roidmi3Coordinator.java
new file mode 100644
index 0000000..28e7406
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/Roidmi3Coordinator.java
@@ -0,0 +1,68 @@
+/* Copyright (C) 2018-2024 Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.roidmi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.roidmi.RoidmiSupport;
+
+public class Roidmi3Coordinator extends RoidmiCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(Roidmi3Coordinator.class);
+
+ @NonNull
+ @Override
+ public boolean supports(final GBDeviceCandidate candidate) {
+ try {
+ final String name = candidate.getName();
+
+ if (name == null) {
+ return false;
+ }
+
+ if (name.contains("Roidmi Music Blue C") || name.contains("Roidmi C BLE") || name.contains("Mojietu Music Blue C")) {
+ return true;
+ }
+ } catch (Exception ex) {
+ LOG.error("unable to check device support", ex);
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean supportsRgbLedColor() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return RoidmiSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_roidmi3;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/RoidmiConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/RoidmiConst.java
new file mode 100644
index 0000000..cc55564
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/RoidmiConst.java
@@ -0,0 +1,36 @@
+/* Copyright (C) 2018-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.roidmi;
+
+import android.graphics.Color;
+
+public class RoidmiConst {
+ public static final String ACTION_GET_LED_COLOR = "roidmi_get_led_color";
+ public static final String ACTION_GET_FM_FREQUENCY = "roidmi_get_frequency";
+ public static final String ACTION_GET_VOLTAGE = "roidmi_get_voltage";
+
+ public static final int[] COLOR_PRESETS = new int[]{
+ Color.rgb(0xFF, 0x00, 0x00), // red
+ Color.rgb(0x00, 0xFF, 0x00), // green
+ Color.rgb(0x00, 0x00, 0xFF), // blue
+ Color.rgb(0xFF, 0xFF, 0x01), // yellow
+ Color.rgb(0x00, 0xAA, 0xE5), // sky blue
+ Color.rgb(0xF0, 0x6E, 0xAA), // pink
+ Color.rgb(0xFF, 0xFF, 0xFF), // white
+ Color.rgb(0x00, 0x00, 0x00), // black
+ };
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/RoidmiCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/RoidmiCoordinator.java
new file mode 100644
index 0000000..8d19544
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/roidmi/RoidmiCoordinator.java
@@ -0,0 +1,147 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.roidmi;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+
+public abstract class RoidmiCoordinator extends AbstractBLClassicDeviceCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(RoidmiCoordinator.class);
+
+ @Override
+ public String getManufacturer() {
+ return "Roidmi";
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_BOND;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsLedColor() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public int[] getColorPresets() {
+ return RoidmiConst.COLOR_PRESETS;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_roidmi;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_roidmi_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/scannable/ScannableDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/scannable/ScannableDeviceCoordinator.java
new file mode 100644
index 0000000..a3ba837
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/scannable/ScannableDeviceCoordinator.java
@@ -0,0 +1,68 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.scannable;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+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.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.unknown.UnknownDeviceSupport;
+
+public class ScannableDeviceCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ return false;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_scannable
+ };
+ }
+
+ @Override
+ public boolean isConnectable() {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Generic";
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return UnknownDeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_scannable;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 0;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_scannable;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_scannable_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSConstants.java
new file mode 100644
index 0000000..ad02081
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSConstants.java
@@ -0,0 +1,60 @@
+/* Copyright (C) 2021-2024 x29a
+
+ 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.devices.smaq2oss;
+
+import java.util.UUID;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport.BASE_UUID;
+
+
+public class SMAQ2OSSConstants {
+
+ // Nordic UART Service UUID
+// public static final UUID UUID_SERVICE_SMAQ2OSS = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E");
+// public static final UUID UUID_CHARACTERISTIC_WRITE_NORMAL = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E");
+// public static final UUID UUID_CHARACTERISTIC_NOTIFY_NORMAL = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E");
+ // SMA-Q2-OSS watch service UUID 51be0000-c182-4f3a-9359-21337bce51f6
+ public static final UUID UUID_SERVICE_SMAQ2OSS = UUID.fromString("51be0001-c182-4f3a-9359-21337bce51f6");
+ public static final UUID UUID_CHARACTERISTIC_WRITE_NORMAL = UUID.fromString("51be0002-c182-4f3a-9359-21337bce51f6");
+ public static final UUID UUID_CHARACTERISTIC_NOTIFY_NORMAL = UUID.fromString("51be0003-c182-4f3a-9359-21337bce51f6");
+
+ public static final byte MSG_SET_TIME = 0x01;
+ public static final byte MSG_BATTERY_STATE = 0x02;
+ public static final byte MSG_MUSIC_EVENT = 0x03;
+ public static final byte MSG_SET_WEATHER = 0x04;
+ public static final byte MSG_SET_MUSIC_INFO = 0x05;
+ public static final byte MSG_CALL_NOTIFICATION = 0x06;
+ public static final byte MSG_CALL_COMMAND = 0x07;
+ public static final byte MSG_NOTIFICATION = 0x08;
+
+ public static final byte EVT_PLAY_PAUSE = 0x00;
+ public static final byte EVT_FWD = 0x01;
+ public static final byte EVT_REV = 0x02;
+ public static final byte EVT_VOL_UP = 0x03;
+ public static final byte EVT_VOL_DOWN = 0x04;
+
+ public static final int MUSIC_ARTIST_MAX_LEN = 32;
+ public static final int MUSIC_ALBUM_MAX_LEN = 32;
+ public static final int MUSIC_TRACK_MAX_LEN = 64;
+
+ public static final int CALL_NAME_MAX_LEN = 32;
+ public static final int CALL_NUMBER_MAX_LEN = 16;
+
+ public static final int NOTIFICATION_SENDER_MAX_LEN = 32;
+ public static final int NOTIFICATION_SUBJECT_MAX_LEN = 32;
+ public static final int NOTIFICATION_BODY_MAX_LEN = 200;
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSCoordinator.java
new file mode 100644
index 0000000..dec9a0c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSCoordinator.java
@@ -0,0 +1,165 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, Arjan Schrijver, Damien Gaignon,
+ Daniel Dakhno, Jos茅 Rebelo, Petr Van臎k, x29a
+
+ 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.devices.smaq2oss;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+
+public class SMAQ2OSSCoordinator extends AbstractBLEDeviceCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(SMAQ2OSSCoordinator.class);
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid service = new ParcelUuid(SMAQ2OSSConstants.UUID_SERVICE_SMAQ2OSS);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(service).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("SMAQ2-.*|SMA-Q2-OSS", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_NONE;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "SMA";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return SMAQ2OSSSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_smaq2oss;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSSupport.java
new file mode 100644
index 0000000..ea51e51
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/smaq2oss/SMAQ2OSSSupport.java
@@ -0,0 +1,345 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver, Jos茅 Rebelo, x29a
+
+ 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.devices.smaq2oss;
+
+
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.UUID;
+import java.nio.ByteBuffer;
+
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl;
+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.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
+import nodomain.freeyourgadget.gadgetbridge.proto.SMAQ2OSSProtos;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+public class SMAQ2OSSSupport extends AbstractBTLEDeviceSupport {
+ private static final Logger LOG = LoggerFactory.getLogger(SMAQ2OSSSupport.class);
+
+ public BluetoothGattCharacteristic normalWriteCharacteristic = null;
+
+ public SMAQ2OSSSupport() {
+ super(LOG);
+ addSupportedService(GattService.UUID_SERVICE_GENERIC_ACCESS);
+ addSupportedService(GattService.UUID_SERVICE_GENERIC_ATTRIBUTE);
+ addSupportedService(SMAQ2OSSConstants.UUID_SERVICE_SMAQ2OSS);
+ }
+
+ @Override
+ protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
+ normalWriteCharacteristic = getCharacteristic(SMAQ2OSSConstants.UUID_CHARACTERISTIC_WRITE_NORMAL);
+ normalWriteCharacteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
+
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
+
+ setTime(builder)
+ .setInitialized(builder);
+
+ getDevice().setFirmwareVersion("N/A");
+ getDevice().setFirmwareVersion2("N/A");
+
+ builder.notify(getCharacteristic(SMAQ2OSSConstants.UUID_CHARACTERISTIC_NOTIFY_NORMAL), true);
+
+ return builder;
+ }
+
+ @Override
+ public boolean onCharacteristicChanged(BluetoothGatt gatt,
+ BluetoothGattCharacteristic characteristic) {
+ super.onCharacteristicChanged(gatt, characteristic);
+
+ UUID characteristicUUID = characteristic.getUuid();
+ if (SMAQ2OSSConstants.UUID_CHARACTERISTIC_NOTIFY_NORMAL.equals(characteristicUUID)) {
+ handleDeviceEvent(characteristic.getValue());
+ }
+ return true;
+ }
+
+ private void handleDeviceEvent(byte[] value){
+ if (value == null || value.length == 0) {
+ return;
+ }
+
+ switch (value[0]) {
+ case SMAQ2OSSConstants.MSG_MUSIC_EVENT:
+ LOG.info("got music control");
+ handleMusicEvent(value[1]);
+ break;
+ case SMAQ2OSSConstants.MSG_CALL_COMMAND:
+ LOG.info("got call control");
+ handleCallCommand(value[1]);
+ break;
+
+ }
+
+ }
+
+ private void handleMusicEvent(byte value){
+ GBDeviceEventMusicControl deviceEventMusicControl = new GBDeviceEventMusicControl();
+
+ switch (value){
+ case SMAQ2OSSConstants.EVT_PLAY_PAUSE:
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PLAYPAUSE;
+ break;
+ case SMAQ2OSSConstants.EVT_FWD:
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.NEXT;
+ break;
+ case SMAQ2OSSConstants.EVT_REV:
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PREVIOUS;
+ break;
+ case SMAQ2OSSConstants.EVT_VOL_UP:
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEUP;
+ break;
+ case SMAQ2OSSConstants.EVT_VOL_DOWN:
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEDOWN;
+ break;
+ }
+ evaluateGBDeviceEvent(deviceEventMusicControl);
+
+ }
+
+ private void handleCallCommand(byte command){
+ GBDeviceEventCallControl callCmd = new GBDeviceEventCallControl();
+
+ switch (command){
+ case CallSpec.CALL_ACCEPT:
+ callCmd.event = GBDeviceEventCallControl.Event.ACCEPT;
+ evaluateGBDeviceEvent(callCmd);
+ break;
+ case CallSpec.CALL_REJECT:
+ callCmd.event = GBDeviceEventCallControl.Event.REJECT;
+ evaluateGBDeviceEvent(callCmd);
+ break;
+ }
+
+ }
+
+ @Override
+ public boolean useAutoConnect() {
+ return true;
+ }
+
+ @Override
+ public void onNotification(NotificationSpec notificationSpec) {
+
+ SMAQ2OSSProtos.MessageNotification.Builder notification = SMAQ2OSSProtos.MessageNotification.newBuilder();
+
+
+ notification.setTimestamp(getTimestamp());
+ String sender = StringUtils.getFirstOf(StringUtils.getFirstOf(notificationSpec.sender,notificationSpec.phoneNumber),notificationSpec.title);
+ notification.setSender(truncateUTF8(sender,SMAQ2OSSConstants.NOTIFICATION_SENDER_MAX_LEN));
+// notification.setSubject(truncateUTF8(notificationSpec.subject,SMAQ2OSSConstants.NOTIFICATION_SUBJECT_MAX_LEN));
+ notification.setBody(truncateUTF8(notificationSpec.body,SMAQ2OSSConstants.NOTIFICATION_BODY_MAX_LEN));
+
+
+ try {
+ TransactionBuilder builder;
+ builder = performInitialized("Sending notification");
+
+ builder.write(normalWriteCharacteristic,createMessage(SMAQ2OSSConstants.MSG_NOTIFICATION,notification.build().toByteArray()));
+
+ builder.queue(getQueue());
+ } catch (Exception ex) {
+ LOG.error("Error sending notification", ex);
+ }
+ }
+
+ @Override
+ public void onSetTime() {
+ try {
+ TransactionBuilder builder = performInitialized("time");
+ setTime(builder);
+ performConnected(builder.getTransaction());
+ } catch(IOException e) {
+ }
+ }
+
+ @Override
+ public void onSetCallState(CallSpec callSpec) {
+
+ SMAQ2OSSProtos.CallNotification.Builder callnotif = SMAQ2OSSProtos.CallNotification.newBuilder();
+
+ callnotif.setName(truncateUTF8(callSpec.name,SMAQ2OSSConstants.CALL_NAME_MAX_LEN));
+ callnotif.setNumber(truncateUTF8(callSpec.number,SMAQ2OSSConstants.CALL_NUMBER_MAX_LEN));
+ callnotif.setCommand(callSpec.command);
+
+ try {
+ TransactionBuilder builder;
+ builder = performInitialized("Sending call state");
+
+ builder.write(normalWriteCharacteristic,createMessage(SMAQ2OSSConstants.MSG_CALL_NOTIFICATION,callnotif.build().toByteArray()));
+
+ builder.queue(getQueue());
+ } catch (Exception ex) {
+ LOG.error("Error sending call state", ex);
+ }
+ }
+
+ @Override
+ public void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) {
+
+ }
+
+ @Override
+ public void onSetMusicInfo(MusicSpec musicSpec) {
+ SMAQ2OSSProtos.MusicInfo.Builder musicInfo = SMAQ2OSSProtos.MusicInfo.newBuilder();
+
+ musicInfo.setArtist(truncateUTF8(musicSpec.artist,SMAQ2OSSConstants.MUSIC_ARTIST_MAX_LEN));
+ musicInfo.setAlbum(truncateUTF8(musicSpec.album,SMAQ2OSSConstants.MUSIC_ALBUM_MAX_LEN));
+ musicInfo.setTrack(truncateUTF8(musicSpec.track,SMAQ2OSSConstants.MUSIC_TRACK_MAX_LEN));
+
+ try {
+ TransactionBuilder builder;
+ builder = performInitialized("Sending music info");
+ LOG.info(musicInfo.getArtist());
+ LOG.info(musicInfo.getAlbum());
+ LOG.info(musicInfo.getTrack());
+
+ builder.write(normalWriteCharacteristic,createMessage(SMAQ2OSSConstants.MSG_SET_MUSIC_INFO,musicInfo.build().toByteArray()));
+
+ builder.queue(getQueue());
+ } catch (Exception ex) {
+ LOG.error("Error sending music info", ex);
+ }
+ }
+
+ @Override
+ public void onReset(int flags) {
+// try {
+// getQueue().clear();
+//
+// TransactionBuilder builder = performInitialized("reboot");
+// builder.write(normalWriteCharacteristic, new byte[] {
+// SMAQ2OSSConstants.CMD_ID_DEVICE_RESTART, SMAQ2OSSConstants.CMD_KEY_REBOOT
+// });
+// performConnected(builder.getTransaction());
+// } catch(Exception e) {
+// }
+ }
+
+ @Override
+ public void onSendWeather(ArrayList<WeatherSpec> weatherSpecs) {
+ WeatherSpec weatherSpec = weatherSpecs.get(0);
+ try {
+ TransactionBuilder builder;
+ builder = performInitialized("Sending current weather");
+
+ SMAQ2OSSProtos.SetWeather.Builder setWeather= SMAQ2OSSProtos.SetWeather.newBuilder();
+
+ setWeather.setTimestamp(weatherSpec.timestamp);
+ setWeather.setCondition(weatherSpec.currentConditionCode);
+ setWeather.setTemperature(weatherSpec.currentTemp-273);
+ setWeather.setTemperatureMin(weatherSpec.todayMinTemp-273);
+ setWeather.setTemperatureMax(weatherSpec.todayMaxTemp-273);
+ setWeather.setHumidity(weatherSpec.currentHumidity);
+
+ for (WeatherSpec.Daily f:weatherSpec.forecasts) {
+
+ SMAQ2OSSProtos.Forecast.Builder fproto = SMAQ2OSSProtos.Forecast.newBuilder();
+
+ fproto.setCondition(f.conditionCode);
+ fproto.setTemperatureMin(f.minTemp-273);
+ fproto.setTemperatureMax(f.maxTemp-273);
+
+ setWeather.addForecasts(fproto);
+ }
+
+ builder.write(normalWriteCharacteristic,createMessage(SMAQ2OSSConstants.MSG_SET_WEATHER,setWeather.build().toByteArray()));
+ builder.queue(getQueue());
+ } catch (Exception ex) {
+ LOG.error("Error sending current weather", ex);
+ }
+ }
+
+ private void setInitialized(TransactionBuilder builder) {
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext()));
+ }
+
+ byte[] createMessage(byte msgid, byte[] data){
+
+ ByteBuffer buf=ByteBuffer.allocate(data.length+1);
+ buf.put(msgid);
+ buf.put(data);
+
+ return buf.array();
+ }
+
+ String truncateUTF8(String str, int len){
+
+ if (str ==null)
+ return new String();
+
+ int currLen = str.getBytes(UTF_8).length;
+
+ while (currLen>len-1){
+
+ str=str.substring(0,str.length()-1);
+ currLen = str.getBytes(UTF_8).length;
+ }
+
+ return str;
+ }
+
+ private int getTimestamp(){
+
+ Calendar c = GregorianCalendar.getInstance();
+
+ long offset = (c.get(Calendar.ZONE_OFFSET) + c.get(Calendar.DST_OFFSET));
+ long ts = (c.getTimeInMillis()+ offset)/1000 ;
+
+ return (int)ts;
+
+ }
+
+ SMAQ2OSSSupport setTime(TransactionBuilder builder) {
+
+ SMAQ2OSSProtos.SetTime.Builder settime = SMAQ2OSSProtos.SetTime.newBuilder();
+ settime.setTimestamp(getTimestamp());
+ builder.write(normalWriteCharacteristic,createMessage(SMAQ2OSSConstants.MSG_SET_TIME,settime.build().toByteArray()));
+ return this;
+ }
+
+ @Override
+ public boolean getImplicitCallbackModify() {
+ return true;
+ }
+
+ @Override
+ public boolean getSendWriteRequestResponse() {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soflow/SoFlowCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soflow/SoFlowCoordinator.java
new file mode 100644
index 0000000..cd42ae8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soflow/SoFlowCoordinator.java
@@ -0,0 +1,166 @@
+/* Copyright (C) 2022-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.soflow;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.soflow.SoFlowSupport;
+
+public class SoFlowCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("SoFlow-.*");
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "SoFlow";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ // nothing to delete, yet
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificAuthenticationSettings() {
+ return new int[]{R.xml.devicesettings_pairingkey};
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_lock_unlock
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return SoFlowSupport.class;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_soflow_s06;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_vesc;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_vesc_disabled;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesCapabilities.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesCapabilities.java
new file mode 100644
index 0000000..49a9f3a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesCapabilities.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones;
+
+public enum SonyHeadphonesCapabilities {
+ BatterySingle,
+ BatteryDual,
+ BatteryDual2,
+ BatteryCase,
+ PowerOffFromPhone,
+ AmbientSoundControl,
+ AmbientSoundControl2,
+ WindNoiseReduction,
+ SpeakToChatEnabled,
+ SpeakToChatConfig,
+ SpeakToChatFocusOnVoice,
+ AncOptimizer,
+ AudioSettingsOnlyOnSbcCodec,
+ AudioUpsampling,
+ ButtonModesLeftRight,
+ AmbientSoundControlButtonMode,
+ VoiceNotifications,
+ AutomaticPowerOffWhenTakenOff,
+ AutomaticPowerOffByTime,
+ TouchSensorSingle,
+ EqualizerSimple,
+ EqualizerWithCustomBands,
+ SoundPosition,
+ SurroundMode,
+ QuickAccess,
+ PauseWhenTakenOff,
+ Volume,
+ WideAreaTap,
+ AdaptiveVolumeControl,
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesCoordinator.java
new file mode 100644
index 0000000..8172f4e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesCoordinator.java
@@ -0,0 +1,232 @@
+/* Copyright (C) 2021-2024 Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.sony.headphones;
+
+import androidx.annotation.NonNull;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sony.headphones.SonyHeadphonesSupport;
+
+public abstract class SonyHeadphonesCoordinator extends AbstractBLClassicDeviceCoordinator {
+ @Override
+ public String getManufacturer() {
+ return "Sony";
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new SonyHeadphonesSettingsCustomizer(device);
+ }
+
+ @Override
+ public boolean suggestUnbindBeforePair() {
+ return false;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ }
+
+ @Override
+ public boolean supportsPowerOff() {
+ return supports(SonyHeadphonesCapabilities.PowerOffFromPhone);
+ }
+
+ @Override
+ public int getBatteryCount() {
+ if (supports(SonyHeadphonesCapabilities.BatterySingle)) {
+ if (supports(SonyHeadphonesCapabilities.BatteryDual) || supports(SonyHeadphonesCapabilities.BatteryDual2)) {
+ throw new IllegalStateException("A device can't have both single and dual battery");
+ } else if (supports(SonyHeadphonesCapabilities.BatteryCase)) {
+ throw new IllegalStateException("Devices with single battery + case are not supported by the protocol");
+ }
+ }
+
+ int batteryCount = 0;
+
+ if (supports(SonyHeadphonesCapabilities.BatterySingle)) {
+ batteryCount += 1;
+ }
+
+ if (supports(SonyHeadphonesCapabilities.BatteryCase)) {
+ batteryCount += 1;
+ }
+
+ if (supports(SonyHeadphonesCapabilities.BatteryDual) || supports(SonyHeadphonesCapabilities.BatteryDual2)) {
+ batteryCount += 2;
+ }
+
+ return batteryCount;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ final List<BatteryConfig> batteries = new ArrayList<>(3);
+
+ if (supports(SonyHeadphonesCapabilities.BatterySingle)) {
+ batteries.add(new BatteryConfig(batteries.size()));
+ }
+
+ if (supports(SonyHeadphonesCapabilities.BatteryCase)) {
+ batteries.add(new BatteryConfig(batteries.size(), R.drawable.ic_tws_case, R.string.battery_case));
+ }
+
+ if (supports(SonyHeadphonesCapabilities.BatteryDual) || supports(SonyHeadphonesCapabilities.BatteryDual2)) {
+ batteries.add(new BatteryConfig(batteries.size(), R.drawable.ic_galaxy_buds_l, R.string.left_earbud));
+ batteries.add(new BatteryConfig(batteries.size(), R.drawable.ic_galaxy_buds_r, R.string.right_earbud));
+ }
+
+ return batteries.toArray(new BatteryConfig[0]);
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ if (supports(SonyHeadphonesCapabilities.AmbientSoundControl) || supports(SonyHeadphonesCapabilities.AmbientSoundControl2)) {
+ if (supports(SonyHeadphonesCapabilities.WindNoiseReduction)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_sony_headphones_ambient_sound_control_wind_noise_reduction);
+ } else {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_sony_headphones_ambient_sound_control);
+ }
+
+ if (supports(SonyHeadphonesCapabilities.AncOptimizer)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_sony_headphones_anc_optimizer);
+ }
+ }
+
+ if (supports(SonyHeadphonesCapabilities.AdaptiveVolumeControl)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_sony_headphones_adaptive_volume_control);
+ }
+
+ if (supports(SonyHeadphonesCapabilities.SpeakToChatConfig)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_sony_headphones_speak_to_chat_with_settings);
+ } else if (supports(SonyHeadphonesCapabilities.SpeakToChatEnabled)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_sony_headphones_speak_to_chat_simple);
+ }
+
+ addSettingsUnderHeader(deviceSpecificSettings, R.xml.devicesettings_header_other, new LinkedHashMap<SonyHeadphonesCapabilities, Integer>() {{
+ put(SonyHeadphonesCapabilities.AudioSettingsOnlyOnSbcCodec, R.xml.devicesettings_sony_warning_wh1000xm3);
+ put(SonyHeadphonesCapabilities.EqualizerSimple, R.xml.devicesettings_sony_headphones_equalizer);
+ put(SonyHeadphonesCapabilities.EqualizerWithCustomBands, R.xml.devicesettings_sony_headphones_equalizer_with_custom_bands);
+ put(SonyHeadphonesCapabilities.SoundPosition, R.xml.devicesettings_sony_headphones_sound_position);
+ put(SonyHeadphonesCapabilities.SurroundMode, R.xml.devicesettings_sony_headphones_surround_mode);
+ put(SonyHeadphonesCapabilities.AudioUpsampling, R.xml.devicesettings_sony_headphones_audio_upsampling);
+ put(SonyHeadphonesCapabilities.Volume, R.xml.devicesettings_volume);
+ }});
+
+ final List<Integer> callsAndNotif = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
+ callsAndNotif.add(R.xml.devicesettings_headphones);
+
+ addSettingsUnderHeader(deviceSpecificSettings, R.xml.devicesettings_header_system, new LinkedHashMap<SonyHeadphonesCapabilities, Integer>() {{
+ put(SonyHeadphonesCapabilities.WideAreaTap, R.xml.devicesettings_sony_headphones_wide_area_tap);
+ put(SonyHeadphonesCapabilities.ButtonModesLeftRight, R.xml.devicesettings_sony_headphones_button_modes_left_right);
+ put(SonyHeadphonesCapabilities.AmbientSoundControlButtonMode, R.xml.devicesettings_sony_headphones_ambient_sound_control_button_modes);
+ put(SonyHeadphonesCapabilities.QuickAccess, R.xml.devicesettings_sony_headphones_quick_access);
+ put(SonyHeadphonesCapabilities.TouchSensorSingle, R.xml.devicesettings_sony_headphones_touch_sensor_single);
+ put(SonyHeadphonesCapabilities.PauseWhenTakenOff, R.xml.devicesettings_sony_headphones_pause_when_taken_off);
+ put(SonyHeadphonesCapabilities.AutomaticPowerOffWhenTakenOff, R.xml.devicesettings_automatic_power_off_when_taken_off);
+ put(SonyHeadphonesCapabilities.AutomaticPowerOffByTime, R.xml.devicesettings_automatic_power_off_by_time);
+ put(SonyHeadphonesCapabilities.VoiceNotifications, R.xml.devicesettings_sony_headphones_notifications_voice_guide);
+ }});
+
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_header_developer);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_sony_headphones_protocol_version);
+
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_sony_headphones_device_info);
+
+ return deviceSpecificSettings;
+ }
+
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Collections.emptyList();
+ }
+
+ public boolean supports(final SonyHeadphonesCapabilities capability) {
+ return getCapabilities().contains(capability);
+ }
+
+ /**
+ * Add the preference screens for capabilities under a header. The header is also only added if at least one capability is supported by the device.
+ *
+ * @param deviceSpecificSettings the device specific settings
+ * @param header the header to add, if any capability supported
+ * @param capabilities the map of capability to preference screen
+ */
+ private void addSettingsUnderHeader(final DeviceSpecificSettings deviceSpecificSettings,
+ final int header,
+ final Map<SonyHeadphonesCapabilities, Integer> capabilities) {
+ final Set<SonyHeadphonesCapabilities> supportedCapabilities = new HashSet<>(capabilities.keySet());
+ for (SonyHeadphonesCapabilities capability : capabilities.keySet()) {
+ if (!supports(capability)) {
+ supportedCapabilities.remove(capability);
+ }
+ }
+
+ if (supportedCapabilities.isEmpty()) {
+ // None of the capabilities in the map are supported
+ return;
+ }
+
+ deviceSpecificSettings.addRootScreen(header);
+
+ for (Map.Entry<SonyHeadphonesCapabilities, Integer> capabilitiesSetting : capabilities.entrySet()) {
+ if (supports(capabilitiesSetting.getKey())) {
+ deviceSpecificSettings.addRootScreen(capabilitiesSetting.getValue());
+ }
+ }
+ }
+
+ public boolean preferServiceV2() {
+ return false;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return SonyHeadphonesSupport.class;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_sony_overhead;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_sony_overhead_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesSettingsCustomizer.java
new file mode 100644
index 0000000..20d072c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/SonyHeadphonesSettingsCustomizer.java
@@ -0,0 +1,226 @@
+/* Copyright (C) 2021-2024 Arjan Schrijver, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.sony.headphones;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_CONTROL;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_LEVEL;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_AUDIO_CODEC;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_1000;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_16000;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_2500;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_400;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_6300;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BASS;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_MODE;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_FOCUS_VOICE;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_NOISE_OPTIMIZER_CANCEL;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_NOISE_OPTIMIZER_START;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_NOISE_OPTIMIZER_STATUS;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_SOUND_POSITION;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_SPEAK_TO_CHAT_FOCUS_ON_VOICE;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SONY_SURROUND_MODE;
+
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Parcel;
+
+import androidx.preference.EditTextPreference;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs.AmbientSoundControl;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sony.headphones.protocol.impl.v1.params.NoiseCancellingOptimizerStatus;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class SonyHeadphonesSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ private ProgressDialog ancOptimizerProgressDialog;
+
+ final GBDevice device;
+
+ public SonyHeadphonesSettingsCustomizer(final GBDevice device) {
+ this.device = device;
+ }
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ // Disable equalizer, sound position and surround mode if not in SBC codec, for WH-1000XM3
+ // TODO: Should the coordinator be responsible for this compatibility check?
+ if (preference.getKey().equals(PREF_SONY_AUDIO_CODEC) && device.getType().equals(DeviceType.SONY_WH_1000XM3)) {
+ final boolean isSbcCodec = ((EditTextPreference) preference).getText().equalsIgnoreCase("sbc");
+
+ final List<Preference> prefsToDisable = Arrays.asList(
+ handler.findPreference(PREF_SONY_EQUALIZER),
+ handler.findPreference(PREF_SONY_EQUALIZER_MODE),
+ handler.findPreference(PREF_SONY_EQUALIZER_BAND_400),
+ handler.findPreference(PREF_SONY_EQUALIZER_BAND_1000),
+ handler.findPreference(PREF_SONY_EQUALIZER_BAND_2500),
+ handler.findPreference(PREF_SONY_EQUALIZER_BAND_6300),
+ handler.findPreference(PREF_SONY_EQUALIZER_BAND_16000),
+ handler.findPreference(PREF_SONY_EQUALIZER_BASS),
+ handler.findPreference(PREF_SONY_SOUND_POSITION),
+ handler.findPreference(PREF_SONY_SURROUND_MODE)
+ );
+
+ for (Preference pref : prefsToDisable) {
+ if (pref != null) {
+ pref.setEnabled(isSbcCodec);
+ }
+ }
+ }
+
+ // Handle ANC Optimizer status
+ if (preference.getKey().equals(PREF_SONY_NOISE_OPTIMIZER_STATUS)) {
+ final EditTextPreference optimizerStatusPreference = (EditTextPreference) preference;
+ final NoiseCancellingOptimizerStatus optimizerStatus = NoiseCancellingOptimizerStatus.valueOf(optimizerStatusPreference.getText().toUpperCase(Locale.ROOT));
+
+ if (ancOptimizerProgressDialog != null) {
+ switch (optimizerStatus) {
+ case FINISHED:
+ case NOT_RUNNING:
+ ancOptimizerProgressDialog.dismiss();
+ ancOptimizerProgressDialog = null;
+ break;
+ default:
+ ancOptimizerProgressDialog.setMessage(optimizerStatus.i18n(preference.getContext()));
+ }
+ }
+ }
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, Prefs prefs, final String rootKey) {
+ final SonyHeadphonesCoordinator coordinator = (SonyHeadphonesCoordinator) device.getDeviceCoordinator();
+
+ // Only enable the focus on voice check and voice level slider if the ambient sound control mode is ambient sound
+
+ final ListPreference ambientSoundControl = handler.findPreference(PREF_SONY_AMBIENT_SOUND_CONTROL);
+ if (ambientSoundControl != null) {
+ final Preference focusOnVoice = handler.findPreference(PREF_SONY_FOCUS_VOICE);
+ final Preference ambientSoundLevel = handler.findPreference(PREF_SONY_AMBIENT_SOUND_LEVEL);
+
+ final Preference.OnPreferenceChangeListener ambientSoundControlPrefListener = new Preference.OnPreferenceChangeListener() {
+ public boolean onPreferenceChange(Preference preference, Object newVal) {
+ boolean isAmbientSoundEnabled = AmbientSoundControl.Mode.AMBIENT_SOUND.name().toLowerCase(Locale.getDefault()).equals(newVal);
+ focusOnVoice.setEnabled(isAmbientSoundEnabled);
+ ambientSoundLevel.setEnabled(isAmbientSoundEnabled);
+
+ return true;
+ }
+ };
+
+ ambientSoundControlPrefListener.onPreferenceChange(ambientSoundControl, ambientSoundControl.getValue());
+ handler.addPreferenceHandlerFor(PREF_SONY_AMBIENT_SOUND_CONTROL, ambientSoundControlPrefListener);
+ }
+
+ // ANC Optimizer
+
+ final Preference ancOptimizer = handler.findPreference("pref_sony_anc_optimizer");
+
+ if (ancOptimizer != null) {
+ ancOptimizer.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(final Preference preference) {
+ if (ancOptimizerProgressDialog != null) {
+ // Already optimizing
+ return true;
+ }
+
+ final Context context = preference.getContext();
+
+ new MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.sony_anc_optimize_confirmation_title)
+ .setMessage(R.string.sony_anc_optimize_confirmation_description)
+ .setIcon(R.drawable.ic_hearing)
+ .setPositiveButton(R.string.start, new DialogInterface.OnClickListener() {
+ public void onClick(final DialogInterface dialog, final int whichButton) {
+ handler.notifyPreferenceChanged(PREF_SONY_NOISE_OPTIMIZER_START);
+
+ ancOptimizerProgressDialog = new ProgressDialog(context);
+ ancOptimizerProgressDialog.setCancelable(false);
+ ancOptimizerProgressDialog.setMessage(context.getString(R.string.sony_anc_optimizer_status_starting));
+ ancOptimizerProgressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
+ ancOptimizerProgressDialog.setProgress(0);
+ ancOptimizerProgressDialog.setButton(DialogInterface.BUTTON_NEGATIVE, context.getString(R.string.Cancel), new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ dialog.dismiss();
+ ancOptimizerProgressDialog = null;
+ handler.notifyPreferenceChanged(PREF_SONY_NOISE_OPTIMIZER_CANCEL);
+ }
+ });
+
+ ancOptimizerProgressDialog.show();
+ }
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+
+ return true;
+ }
+ });
+ }
+
+ // Hide unsupported preferences
+ final Preference speakToChatFocusVoice = handler.findPreference(PREF_SONY_SPEAK_TO_CHAT_FOCUS_ON_VOICE);
+ if (speakToChatFocusVoice != null && !coordinator.supports(SonyHeadphonesCapabilities.SpeakToChatFocusOnVoice)) {
+ speakToChatFocusVoice.setVisible(false);
+ }
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ public static final Creator<SonyHeadphonesSettingsCustomizer> CREATOR = new Creator<SonyHeadphonesSettingsCustomizer>() {
+ @Override
+ public SonyHeadphonesSettingsCustomizer createFromParcel(final Parcel in) {
+ final GBDevice device = in.readParcelable(SonyHeadphonesSettingsCustomizer.class.getClassLoader());
+ return new SonyHeadphonesSettingsCustomizer(device);
+ }
+
+ @Override
+ public SonyHeadphonesSettingsCustomizer[] newArray(final int size) {
+ return new SonyHeadphonesSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeParcelable(device, 0);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyLinkBudsCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyLinkBudsCoordinator.java
new file mode 100644
index 0000000..43116c0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyLinkBudsCoordinator.java
@@ -0,0 +1,73 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+
+public class SonyLinkBudsCoordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ // TODO fully exclude LE_LinkBuds?
+ return Pattern.compile("^LinkBuds$");
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatteryDual,
+ SonyHeadphonesCapabilities.BatteryCase,
+ SonyHeadphonesCapabilities.SpeakToChatEnabled,
+ SonyHeadphonesCapabilities.SpeakToChatConfig,
+ SonyHeadphonesCapabilities.AdaptiveVolumeControl,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands,
+ SonyHeadphonesCapabilities.AudioUpsampling,
+ SonyHeadphonesCapabilities.ButtonModesLeftRight,
+ SonyHeadphonesCapabilities.PauseWhenTakenOff,
+ SonyHeadphonesCapabilities.AutomaticPowerOffWhenTakenOff,
+ SonyHeadphonesCapabilities.WideAreaTap,
+ SonyHeadphonesCapabilities.VoiceNotifications
+ // TODO spacial sound optimization
+ // TODO factory reset
+ );
+ }
+
+ @Override
+ public boolean preferServiceV2() {
+ return true;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_linkbuds;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyLinkBudsSCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyLinkBudsSCoordinator.java
new file mode 100644
index 0000000..e2dc00f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyLinkBudsSCoordinator.java
@@ -0,0 +1,72 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+
+public class SonyLinkBudsSCoordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*LinkBuds S.*");
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatteryDual,
+ SonyHeadphonesCapabilities.BatteryCase,
+ SonyHeadphonesCapabilities.AmbientSoundControl,
+ SonyHeadphonesCapabilities.AudioUpsampling,
+ SonyHeadphonesCapabilities.ButtonModesLeftRight,
+ SonyHeadphonesCapabilities.AmbientSoundControlButtonMode,
+ SonyHeadphonesCapabilities.QuickAccess,
+ SonyHeadphonesCapabilities.PauseWhenTakenOff,
+ SonyHeadphonesCapabilities.AutomaticPowerOffWhenTakenOff,
+ SonyHeadphonesCapabilities.PowerOffFromPhone,
+ SonyHeadphonesCapabilities.SpeakToChatEnabled,
+ SonyHeadphonesCapabilities.SpeakToChatConfig,
+ SonyHeadphonesCapabilities.VoiceNotifications,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands
+ );
+ }
+
+ @Override
+ public boolean preferServiceV2() {
+ return true;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_linkbuds_s;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM3Coordinator.java
new file mode 100644
index 0000000..dbec95e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM3Coordinator.java
@@ -0,0 +1,81 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo, Quang Ng么
+
+ 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.devices.sony.headphones.coordinators;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class SonyWF1000XM3Coordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WF-1000XM3.*");
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ final BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_tws_case, R.string.battery_case);
+ final BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_galaxy_buds_l, R.string.left_earbud);
+ final BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_galaxy_buds_r, R.string.right_earbud);
+
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatteryDual,
+ SonyHeadphonesCapabilities.BatteryCase,
+ SonyHeadphonesCapabilities.PowerOffFromPhone,
+ SonyHeadphonesCapabilities.AmbientSoundControl,
+ SonyHeadphonesCapabilities.WindNoiseReduction,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands,
+ SonyHeadphonesCapabilities.AudioUpsampling,
+ SonyHeadphonesCapabilities.ButtonModesLeftRight,
+ SonyHeadphonesCapabilities.PauseWhenTakenOff,
+ SonyHeadphonesCapabilities.AutomaticPowerOffWhenTakenOff,
+ SonyHeadphonesCapabilities.VoiceNotifications
+ );
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wf_1000xm3;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM4Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM4Coordinator.java
new file mode 100644
index 0000000..3eaddac
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM4Coordinator.java
@@ -0,0 +1,74 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+
+public class SonyWF1000XM4Coordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WF-1000XM4.*");
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ final BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_tws_case, R.string.battery_case);
+ final BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_galaxy_buds_l, R.string.left_earbud);
+ final BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_galaxy_buds_r, R.string.right_earbud);
+
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatteryDual,
+ SonyHeadphonesCapabilities.BatteryCase,
+ SonyHeadphonesCapabilities.AmbientSoundControl,
+ SonyHeadphonesCapabilities.WindNoiseReduction,
+ SonyHeadphonesCapabilities.EqualizerSimple,
+ SonyHeadphonesCapabilities.AudioUpsampling,
+ SonyHeadphonesCapabilities.ButtonModesLeftRight,
+ SonyHeadphonesCapabilities.PauseWhenTakenOff,
+ SonyHeadphonesCapabilities.AutomaticPowerOffWhenTakenOff
+ );
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wf_1000xm4;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM5Coordinator.java
new file mode 100644
index 0000000..284e8d0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWF1000XM5Coordinator.java
@@ -0,0 +1,85 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class SonyWF1000XM5Coordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WF-1000XM5.*");
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ final BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_tws_case, R.string.battery_case);
+ final BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_galaxy_buds_l, R.string.left_earbud);
+ final BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_galaxy_buds_r, R.string.right_earbud);
+
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatteryDual,
+ SonyHeadphonesCapabilities.BatteryCase,
+ SonyHeadphonesCapabilities.AmbientSoundControl,
+ SonyHeadphonesCapabilities.WindNoiseReduction,
+ SonyHeadphonesCapabilities.EqualizerSimple,
+ SonyHeadphonesCapabilities.AudioUpsampling,
+ SonyHeadphonesCapabilities.ButtonModesLeftRight,
+ SonyHeadphonesCapabilities.PauseWhenTakenOff,
+ SonyHeadphonesCapabilities.AutomaticPowerOffWhenTakenOff
+ );
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wf_1000xm5;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_disabled;
+ }
+
+ @Override
+ public boolean isExperimental() {
+ // Ambient Sound Control is not 100% working
+ // Volume control from headphones is not working?
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFC500Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFC500Coordinator.java
new file mode 100644
index 0000000..0ae78f4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFC500Coordinator.java
@@ -0,0 +1,59 @@
+/* Copyright (C) 2024 Zahnstocher
+
+ 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.devices.sony.headphones.coordinators;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+
+public class SonyWFC500Coordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WF-C500.*");
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatteryDual2,
+ SonyHeadphonesCapabilities.EqualizerSimple,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands,
+ SonyHeadphonesCapabilities.AudioUpsampling,
+ SonyHeadphonesCapabilities.VoiceNotifications,
+ SonyHeadphonesCapabilities.PowerOffFromPhone
+ );
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wf_c500;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFC700NCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFC700NCoordinator.java
new file mode 100644
index 0000000..1dc6917
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFC700NCoordinator.java
@@ -0,0 +1,66 @@
+/* Copyright (C) 2024 Marcel
+
+ 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.devices.sony.headphones.coordinators;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+
+public class SonyWFC700NCoordinator extends SonyHeadphonesCoordinator {
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WF-C700N.*");
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatteryDual2,
+ SonyHeadphonesCapabilities.BatteryCase,
+ SonyHeadphonesCapabilities.AmbientSoundControl2,
+ SonyHeadphonesCapabilities.EqualizerSimple,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands,
+ SonyHeadphonesCapabilities.AudioUpsampling,
+ SonyHeadphonesCapabilities.ButtonModesLeftRight,
+ SonyHeadphonesCapabilities.PowerOffFromPhone,
+ SonyHeadphonesCapabilities.AmbientSoundControlButtonMode
+ // AutoOff is supported, but current Payload is incorrect.
+ // Available options in Sony App: 15min, 30min, 1h, 3h, off
+ // TODO: SonyHeadphonesCapabilities.AutomaticPowerOffByTime
+ );
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wf_c700n;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFSP800NCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFSP800NCoordinator.java
new file mode 100644
index 0000000..f129961
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWFSP800NCoordinator.java
@@ -0,0 +1,78 @@
+/* Copyright (C) 2021-2024 Daniel Dakhno, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.sony.headphones.coordinators;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+
+public class SonyWFSP800NCoordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WF-SP800N.*");
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ final BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_sony_wf_800n_case, R.string.battery_case);
+ final BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_sony_wf_800n_left, R.string.left_earbud);
+ final BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_sony_wf_800n_right, R.string.right_earbud);
+
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatteryDual,
+ SonyHeadphonesCapabilities.BatteryCase,
+ SonyHeadphonesCapabilities.PowerOffFromPhone,
+ SonyHeadphonesCapabilities.AmbientSoundControl,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands,
+ SonyHeadphonesCapabilities.ButtonModesLeftRight,
+ SonyHeadphonesCapabilities.PauseWhenTakenOff,
+ SonyHeadphonesCapabilities.AutomaticPowerOffWhenTakenOff,
+ SonyHeadphonesCapabilities.VoiceNotifications,
+ SonyHeadphonesCapabilities.Volume
+ );
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wf_sp800n;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_sony_wf_800n;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_sony_wf_800n_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM2Coordinator.java
new file mode 100644
index 0000000..e06c4fd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM2Coordinator.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class SonyWH1000XM2Coordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WH-1000XM2.*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wh_1000xm2;
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatterySingle,
+ SonyHeadphonesCapabilities.AmbientSoundControl,
+ SonyHeadphonesCapabilities.WindNoiseReduction,
+ SonyHeadphonesCapabilities.AncOptimizer,
+ SonyHeadphonesCapabilities.AudioSettingsOnlyOnSbcCodec,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands,
+ SonyHeadphonesCapabilities.SoundPosition,
+ SonyHeadphonesCapabilities.SurroundMode,
+ SonyHeadphonesCapabilities.AudioUpsampling
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM3Coordinator.java
new file mode 100644
index 0000000..b533c87
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM3Coordinator.java
@@ -0,0 +1,59 @@
+/* Copyright (C) 2021-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class SonyWH1000XM3Coordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WH-1000XM3.*");
+ }
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wh_1000xm3;
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatterySingle,
+ SonyHeadphonesCapabilities.AmbientSoundControl,
+ SonyHeadphonesCapabilities.WindNoiseReduction,
+ SonyHeadphonesCapabilities.AncOptimizer,
+ SonyHeadphonesCapabilities.AudioSettingsOnlyOnSbcCodec,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands,
+ SonyHeadphonesCapabilities.SoundPosition,
+ SonyHeadphonesCapabilities.SurroundMode,
+ SonyHeadphonesCapabilities.AudioUpsampling,
+ SonyHeadphonesCapabilities.TouchSensorSingle,
+ SonyHeadphonesCapabilities.AutomaticPowerOffByTime,
+ SonyHeadphonesCapabilities.VoiceNotifications,
+ SonyHeadphonesCapabilities.Volume
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM4Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM4Coordinator.java
new file mode 100644
index 0000000..0ed65d3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM4Coordinator.java
@@ -0,0 +1,58 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+
+public class SonyWH1000XM4Coordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WH-1000XM4.*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wh_1000xm4;
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ // TODO: Function of [CUSTOM] button
+ // TODO R.xml.devicesettings_connect_two_devices,
+ SonyHeadphonesCapabilities.BatterySingle,
+ SonyHeadphonesCapabilities.AmbientSoundControl,
+ SonyHeadphonesCapabilities.WindNoiseReduction,
+ SonyHeadphonesCapabilities.SpeakToChatEnabled,
+ SonyHeadphonesCapabilities.SpeakToChatConfig,
+ SonyHeadphonesCapabilities.SpeakToChatFocusOnVoice,
+ SonyHeadphonesCapabilities.AncOptimizer,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands,
+ SonyHeadphonesCapabilities.AudioUpsampling,
+ SonyHeadphonesCapabilities.TouchSensorSingle,
+ SonyHeadphonesCapabilities.PauseWhenTakenOff,
+ SonyHeadphonesCapabilities.AutomaticPowerOffWhenTakenOff,
+ SonyHeadphonesCapabilities.VoiceNotifications
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM5Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM5Coordinator.java
new file mode 100644
index 0000000..c137ebe
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWH1000XM5Coordinator.java
@@ -0,0 +1,59 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+
+public class SonyWH1000XM5Coordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WH-1000XM5.*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wh_1000xm5;
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ // TODO R.xml.devicesettings_connect_two_devices,
+ // TODO automatic ANC depending on state (might need phone?)
+ SonyHeadphonesCapabilities.BatterySingle,
+ SonyHeadphonesCapabilities.PowerOffFromPhone,
+ SonyHeadphonesCapabilities.AmbientSoundControl,
+ SonyHeadphonesCapabilities.SpeakToChatEnabled,
+ SonyHeadphonesCapabilities.SpeakToChatConfig,
+ SonyHeadphonesCapabilities.SpeakToChatFocusOnVoice,
+ // TODO SonyHeadphonesCapabilities.AudioUpsampling,
+ // TODO SonyHeadphonesCapabilities.AmbientSoundControlButtonMode,
+ SonyHeadphonesCapabilities.VoiceNotifications,
+ SonyHeadphonesCapabilities.AutomaticPowerOffWhenTakenOff,
+ // TODO SonyHeadphonesCapabilities.TouchSensorSingle,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands,
+ SonyHeadphonesCapabilities.QuickAccess,
+ SonyHeadphonesCapabilities.PauseWhenTakenOff
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWIC100Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWIC100Coordinator.java
new file mode 100644
index 0000000..372e9f3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWIC100Coordinator.java
@@ -0,0 +1,59 @@
+/* Copyright (C) 2024 Zahnstocher
+
+ 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.devices.sony.headphones.coordinators;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+
+public class SonyWIC100Coordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WI-C100.*");
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatterySingle,
+ SonyHeadphonesCapabilities.EqualizerSimple,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands,
+ SonyHeadphonesCapabilities.AudioUpsampling,
+ SonyHeadphonesCapabilities.VoiceNotifications,
+ SonyHeadphonesCapabilities.PowerOffFromPhone
+ );
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wi_c100;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWISP600NCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWISP600NCoordinator.java
new file mode 100644
index 0000000..c1c4800
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/coordinators/SonyWISP600NCoordinator.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2021-2024 Daniel Dakhno, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.sony.headphones.coordinators;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCapabilities;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.SonyHeadphonesCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class SonyWISP600NCoordinator extends SonyHeadphonesCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*WI-SP600N.*");
+ }
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wi_sp600n;
+ }
+
+ @Override
+ public List<SonyHeadphonesCapabilities> getCapabilities() {
+ return Arrays.asList(
+ SonyHeadphonesCapabilities.BatterySingle,
+ SonyHeadphonesCapabilities.AmbientSoundControl,
+ SonyHeadphonesCapabilities.WindNoiseReduction,
+ SonyHeadphonesCapabilities.EqualizerWithCustomBands,
+ SonyHeadphonesCapabilities.SoundPosition,
+ SonyHeadphonesCapabilities.SurroundMode,
+ SonyHeadphonesCapabilities.PowerOffFromPhone,
+ SonyHeadphonesCapabilities.AutomaticPowerOffByTime,
+ SonyHeadphonesCapabilities.VoiceNotifications,
+ SonyHeadphonesCapabilities.Volume
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AdaptiveVolumeControl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AdaptiveVolumeControl.java
new file mode 100644
index 0000000..96f1461
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AdaptiveVolumeControl.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class AdaptiveVolumeControl {
+ private final boolean enabled;
+
+ public AdaptiveVolumeControl(final boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_ADAPTIVE_VOLUME_CONTROL, enabled);
+ }};
+ }
+
+ public static AdaptiveVolumeControl fromPreferences(final SharedPreferences prefs) {
+ return new AdaptiveVolumeControl(prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SONY_ADAPTIVE_VOLUME_CONTROL, true));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AmbientSoundControl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AmbientSoundControl.java
new file mode 100644
index 0000000..3cc364e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AmbientSoundControl.java
@@ -0,0 +1,87 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class AmbientSoundControl {
+ public enum Mode {
+ OFF,
+ NOISE_CANCELLING,
+ WIND_NOISE_REDUCTION,
+ AMBIENT_SOUND
+ }
+
+ private final Mode mode;
+ private final boolean focusOnVoice;
+ private final int ambientSound;
+
+ public AmbientSoundControl(final Mode mode, final boolean focusOnVoice, final int ambientSound) {
+ if (ambientSound < 0 || ambientSound > 20) {
+ throw new IllegalArgumentException(String.format("Level must be between 0 and 20 (was %d)", ambientSound));
+ }
+
+ this.mode = mode;
+ this.focusOnVoice = focusOnVoice;
+ this.ambientSound = ambientSound;
+ }
+
+ public String toString() {
+ return String.format(Locale.getDefault(), "AmbientSoundControl{mode=%s, focusOnVoice=%s, ambientSound=%d}", mode, focusOnVoice, ambientSound);
+ }
+
+ public Mode getMode() {
+ return mode;
+ }
+
+ public boolean isFocusOnVoice() {
+ return focusOnVoice;
+ }
+
+ public int getAmbientSound() {
+ return ambientSound;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_CONTROL, mode.name().toLowerCase(Locale.getDefault()));
+
+ if (AmbientSoundControl.Mode.AMBIENT_SOUND.equals(mode)) {
+ // Only use the ambient sound levels and focus on voice if we're on ambient sound mode,
+ // to prevent overriding the user settings
+ put(DeviceSettingsPreferenceConst.PREF_SONY_FOCUS_VOICE, focusOnVoice);
+ // Level is offset by 1 because we can't configure the SeekBarPreference min level on the current api level
+ put(DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_LEVEL, ambientSound - 1);
+ }
+ }};
+ }
+
+ public static AmbientSoundControl fromPreferences(final SharedPreferences prefs) {
+ final String soundControl = prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_CONTROL, "noise_cancelling");
+ final boolean focusVoice = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SONY_FOCUS_VOICE, false);
+ // Level is offset by 1 because we can't configure the SeekBarPreference min level on the current api level
+ final int ambientSound = prefs.getInt(DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_LEVEL, 0) + 1;
+
+ return new AmbientSoundControl(AmbientSoundControl.Mode.valueOf(soundControl.toUpperCase()), focusVoice, ambientSound);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AmbientSoundControlButtonMode.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AmbientSoundControlButtonMode.java
new file mode 100644
index 0000000..0b3c9d2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AmbientSoundControlButtonMode.java
@@ -0,0 +1,63 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public enum AmbientSoundControlButtonMode {
+ NC_AS_OFF((byte) 0x01),
+ NC_AS((byte) 0x02),
+ NC_OFF((byte) 0x03),
+ AS_OFF((byte) 0x04),
+ ;
+
+ private final byte code;
+
+ AmbientSoundControlButtonMode(final byte code) {
+ this.code = code;
+ }
+
+ public byte getCode() {
+ return this.code;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_CONTROL_BUTTON_MODE, name().toLowerCase(Locale.getDefault()));
+ }};
+ }
+
+ public static AmbientSoundControlButtonMode fromCode(final byte code) {
+ for (AmbientSoundControlButtonMode value : AmbientSoundControlButtonMode.values()) {
+ if (value.getCode() == code) {
+ return value;
+ }
+ }
+
+ return null;
+ }
+
+ public static AmbientSoundControlButtonMode fromPreferences(final SharedPreferences prefs) {
+ return AmbientSoundControlButtonMode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_AMBIENT_SOUND_CONTROL_BUTTON_MODE, "nc_as_off").toUpperCase());
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AudioUpsampling.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AudioUpsampling.java
new file mode 100644
index 0000000..9ce9cdc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AudioUpsampling.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class AudioUpsampling {
+ private final boolean enabled;
+
+ public AudioUpsampling(final boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_AUDIO_UPSAMPLING, enabled);
+ }};
+ }
+
+ public static AudioUpsampling fromPreferences(final SharedPreferences prefs) {
+ return new AudioUpsampling(prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SONY_AUDIO_UPSAMPLING, false));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AutomaticPowerOff.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AutomaticPowerOff.java
new file mode 100644
index 0000000..3c0c00b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/AutomaticPowerOff.java
@@ -0,0 +1,64 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public enum AutomaticPowerOff {
+ OFF(new byte[]{(byte) 0x11, (byte) 0x00}),
+ AFTER_5_MIN(new byte[]{(byte) 0x00, (byte) 0x00}),
+ AFTER_30_MIN(new byte[]{(byte) 0x01, (byte) 0x01}),
+ AFTER_1_HOUR(new byte[]{(byte) 0x02, (byte) 0x02}),
+ AFTER_3_HOUR(new byte[]{(byte) 0x03, (byte) 0x03}),
+ WHEN_TAKEN_OFF(new byte[]{(byte) 0x10, (byte) 0x00});
+
+ private final byte[] code;
+
+ AutomaticPowerOff(final byte[] code) {
+ this.code = code;
+ }
+
+ public byte[] getCode() {
+ return this.code;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_AUTOMATIC_POWER_OFF, name().toLowerCase(Locale.getDefault()));
+ }};
+ }
+
+ public static AutomaticPowerOff fromCode(final byte b1, final byte b2) {
+ for (AutomaticPowerOff value : AutomaticPowerOff.values()) {
+ if (value.getCode()[0] == b1 && value.getCode()[1] == b2) {
+ return value;
+ }
+ }
+
+ return null;
+ }
+
+ public static AutomaticPowerOff fromPreferences(final SharedPreferences prefs) {
+ return AutomaticPowerOff.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_AUTOMATIC_POWER_OFF, "off").toUpperCase());
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/ButtonModes.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/ButtonModes.java
new file mode 100644
index 0000000..4971848
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/ButtonModes.java
@@ -0,0 +1,65 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class ButtonModes {
+ public enum Mode {
+ OFF,
+ AMBIENT_SOUND_CONTROL,
+ PLAYBACK_CONTROL,
+ VOLUME_CONTROL,
+ ;
+ }
+
+ final Mode left;
+ final Mode right;
+
+ public ButtonModes(final Mode left, final Mode right) {
+ this.left = left;
+ this.right = right;
+ }
+
+ public Mode getModeLeft() {
+ return left;
+ }
+
+ public Mode getModeRight() {
+ return right;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_BUTTON_MODE_LEFT, left.name().toLowerCase(Locale.getDefault()));
+ put(DeviceSettingsPreferenceConst.PREF_SONY_BUTTON_MODE_RIGHT, right.name().toLowerCase(Locale.getDefault()));
+ }};
+ }
+
+ public static ButtonModes fromPreferences(final SharedPreferences prefs) {
+ return new ButtonModes(
+ Mode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_BUTTON_MODE_LEFT, "off").toUpperCase()),
+ Mode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_BUTTON_MODE_RIGHT, "off").toUpperCase())
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/EqualizerCustomBands.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/EqualizerCustomBands.java
new file mode 100644
index 0000000..6d424cc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/EqualizerCustomBands.java
@@ -0,0 +1,86 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class EqualizerCustomBands {
+ private final List<Integer> bands;
+ private final int bass;
+
+ public EqualizerCustomBands(final List<Integer> bands, final int bass) {
+ if (bands.size() != 5) {
+ throw new IllegalArgumentException("Equalizer needs exactly 5 bands");
+ }
+
+ for (final Integer band : bands) {
+ if (band < -10 || band > 10) {
+ throw new IllegalArgumentException(String.format("Bands should be between -10 and 10, got %d", band));
+ }
+ }
+
+ if (bass < -10 || bass > 10) {
+ throw new IllegalArgumentException(String.format("Clear Bass value shoulud be between -10 and 10, got %d", bass));
+ }
+
+ this.bands = bands;
+ this.bass = bass;
+ }
+
+ public List<Integer> getBands() {
+ return bands;
+ }
+
+ public int getBass() {
+ return bass;
+ }
+
+ public String toString() {
+ return String.format(Locale.ROOT, "EqualizerCustomBands{clearBass=%d, bands=%s}", bass, bands);
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_400, bands.get(0) + 10);
+ put(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_1000, bands.get(1) + 10);
+ put(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_2500, bands.get(2) + 10);
+ put(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_6300, bands.get(3) + 10);
+ put(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_16000, bands.get(4) + 10);
+ put(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BASS, bass + 10);
+ ;
+ }};
+ }
+
+ public static EqualizerCustomBands fromPreferences(final SharedPreferences prefs) {
+ int band1 = prefs.getInt(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_400, 10) - 10;
+ int band2 = prefs.getInt(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_1000, 10) - 10;
+ int band3 = prefs.getInt(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_2500, 10) - 10;
+ int band4 = prefs.getInt(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_6300, 10) - 10;
+ int band5 = prefs.getInt(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BAND_16000, 10) - 10;
+ int bass = prefs.getInt(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_BASS, 10) - 10;
+
+ return new EqualizerCustomBands(Arrays.asList(band1, band2, band3, band4, band5), bass);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/EqualizerPreset.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/EqualizerPreset.java
new file mode 100644
index 0000000..9a2b066
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/EqualizerPreset.java
@@ -0,0 +1,70 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public enum EqualizerPreset {
+ OFF((byte) 0x00),
+ BRIGHT((byte) 0x10),
+ EXCITED((byte) 0x11),
+ MELLOW((byte) 0x12),
+ RELAXED((byte) 0x13),
+ VOCAL((byte) 0x14),
+ TREBLE_BOOST((byte) 0x15),
+ BASS_BOOST((byte) 0x16),
+ SPEECH((byte) 0x17),
+ MANUAL((byte) 0xa0),
+ CUSTOM_1((byte) 0xa1),
+ CUSTOM_2((byte) 0xa2);
+
+ private final byte code;
+
+ EqualizerPreset(final byte code) {
+ this.code = code;
+ }
+
+ public byte getCode() {
+ return this.code;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_MODE, name().toLowerCase(Locale.getDefault()));
+ }};
+ }
+
+ public static EqualizerPreset fromCode(final byte code) {
+ for (EqualizerPreset value : EqualizerPreset.values()) {
+ if (value.getCode() == code) {
+ return value;
+ }
+ }
+
+ return null;
+ }
+
+ public static EqualizerPreset fromPreferences(final SharedPreferences prefs) {
+ return EqualizerPreset.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_EQUALIZER_MODE, "off").toUpperCase());
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/PauseWhenTakenOff.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/PauseWhenTakenOff.java
new file mode 100644
index 0000000..53fd2aa
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/PauseWhenTakenOff.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class PauseWhenTakenOff {
+ private final boolean enabled;
+
+ public PauseWhenTakenOff(final boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_PAUSE_WHEN_TAKEN_OFF, enabled);
+ }};
+ }
+
+ public static PauseWhenTakenOff fromPreferences(final SharedPreferences prefs) {
+ return new PauseWhenTakenOff(prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SONY_PAUSE_WHEN_TAKEN_OFF, false));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/QuickAccess.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/QuickAccess.java
new file mode 100644
index 0000000..40e68b9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/QuickAccess.java
@@ -0,0 +1,83 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class QuickAccess {
+ public enum Mode {
+ OFF((byte) 0x00),
+ SPOTIFY((byte) 0x01),
+ ;
+
+ private final byte code;
+
+ Mode(final byte code) {
+ this.code = code;
+ }
+
+ public byte getCode() {
+ return this.code;
+ }
+
+ public static Mode fromCode(final byte code) {
+ for (Mode value : Mode.values()) {
+ if (value.getCode() == code) {
+ return value;
+ }
+ }
+
+ return null;
+ }
+ }
+
+ final Mode doubleTap;
+ final Mode tripleTap;
+
+ public QuickAccess(final Mode doubleTap, final Mode tripleTap) {
+ this.doubleTap = doubleTap;
+ this.tripleTap = tripleTap;
+ }
+
+ public Mode getModeDoubleTap() {
+ return doubleTap;
+ }
+
+ public Mode getModeTripleTap() {
+ return tripleTap;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_QUICK_ACCESS_DOUBLE_TAP, doubleTap.name().toLowerCase(Locale.getDefault()));
+ put(DeviceSettingsPreferenceConst.PREF_SONY_QUICK_ACCESS_TRIPLE_TAP, tripleTap.name().toLowerCase(Locale.getDefault()));
+ }};
+ }
+
+ public static QuickAccess fromPreferences(final SharedPreferences prefs) {
+ return new QuickAccess(
+ Mode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_QUICK_ACCESS_DOUBLE_TAP, "off").toUpperCase()),
+ Mode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_QUICK_ACCESS_TRIPLE_TAP, "off").toUpperCase())
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SoundPosition.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SoundPosition.java
new file mode 100644
index 0000000..97f80a8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SoundPosition.java
@@ -0,0 +1,54 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public enum SoundPosition {
+ OFF((byte) 0x00),
+ FRONT((byte) 0x03),
+ FRONT_LEFT((byte) 0x01),
+ FRONT_RIGHT((byte) 0x02),
+ REAR_LEFT((byte) 0x11),
+ REAR_RIGHT((byte) 0x12);
+
+ private final byte code;
+
+ SoundPosition(final byte code) {
+ this.code = code;
+ }
+
+ public byte getCode() {
+ return this.code;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_SOUND_POSITION, name().toLowerCase(Locale.getDefault()));
+ }};
+ }
+
+ public static SoundPosition fromPreferences(final SharedPreferences prefs) {
+ return SoundPosition.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_SOUND_POSITION, "off").toUpperCase());
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SpeakToChatConfig.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SpeakToChatConfig.java
new file mode 100644
index 0000000..e66386c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SpeakToChatConfig.java
@@ -0,0 +1,124 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class SpeakToChatConfig {
+ public enum Sensitivity {
+ AUTO(0x00),
+ HIGH(0x01),
+ LOW(0x02),
+ ;
+
+ private final byte code;
+
+ Sensitivity(final int code) {
+ this.code = (byte) code;
+ }
+
+ public byte getCode() {
+ return this.code;
+ }
+
+ public static Sensitivity fromCode(final byte b) {
+ for (Sensitivity value : Sensitivity.values()) {
+ if (value.getCode() == b) {
+ return value;
+ }
+ }
+
+ return null;
+ }
+ }
+
+ public enum Timeout {
+ SHORT(0x00),
+ STANDARD(0x01),
+ LONG(0x02),
+ OFF(0x03),
+ ;
+
+ private final byte code;
+
+ Timeout(final int code) {
+ this.code = (byte) code;
+ }
+
+ public byte getCode() {
+ return this.code;
+ }
+
+ public static Timeout fromCode(final byte b) {
+ for (Timeout value : Timeout.values()) {
+ if (value.getCode() == b) {
+ return value;
+ }
+ }
+
+ return null;
+ }
+ }
+
+ private final boolean focusOnVoice;
+ private final Sensitivity sensitivity;
+ private final Timeout timeout;
+
+ public SpeakToChatConfig(final boolean focusOnVoice, final Sensitivity sensitivity, final Timeout timeout) {
+ this.focusOnVoice = focusOnVoice;
+ this.sensitivity = sensitivity;
+ this.timeout = timeout;
+ }
+
+ public boolean isFocusOnVoice() {
+ return focusOnVoice;
+ }
+
+ public Sensitivity getSensitivity() {
+ return sensitivity;
+ }
+
+ public Timeout getTimeout() {
+ return timeout;
+ }
+
+ public String toString() {
+ return String.format(Locale.getDefault(), "SpeakToChatConfig{focusOnVoice=%s, sensitivity=%s, timeout=%s}", focusOnVoice, sensitivity, timeout);
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_SPEAK_TO_CHAT_FOCUS_ON_VOICE, focusOnVoice);
+ put(DeviceSettingsPreferenceConst.PREF_SONY_SPEAK_TO_CHAT_SENSITIVITY, sensitivity.name().toLowerCase(Locale.getDefault()));
+ put(DeviceSettingsPreferenceConst.PREF_SONY_SPEAK_TO_CHAT_TIMEOUT, timeout.name().toLowerCase(Locale.getDefault()));
+ }};
+ }
+
+ public static SpeakToChatConfig fromPreferences(final SharedPreferences prefs) {
+ final boolean focusOnVoice = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SONY_SPEAK_TO_CHAT_FOCUS_ON_VOICE, false);
+ final Sensitivity sensitivity = Sensitivity.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_SPEAK_TO_CHAT_SENSITIVITY, "auto").toUpperCase());
+ final Timeout timeout = Timeout.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_SPEAK_TO_CHAT_TIMEOUT, "standard").toUpperCase());
+
+ return new SpeakToChatConfig(focusOnVoice, sensitivity, timeout);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SpeakToChatEnabled.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SpeakToChatEnabled.java
new file mode 100644
index 0000000..f38a1a5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SpeakToChatEnabled.java
@@ -0,0 +1,53 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class SpeakToChatEnabled {
+ private final boolean enabled;
+
+ public SpeakToChatEnabled(final boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public String toString() {
+ return String.format(Locale.getDefault(), "SpeakToChatEnabled{enabled=%s}", enabled);
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_SPEAK_TO_CHAT, enabled);
+ }};
+ }
+
+ public static SpeakToChatEnabled fromPreferences(final SharedPreferences prefs) {
+ final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SONY_SPEAK_TO_CHAT, false);
+
+ return new SpeakToChatEnabled(enabled);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SurroundMode.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SurroundMode.java
new file mode 100644
index 0000000..8eea63e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/SurroundMode.java
@@ -0,0 +1,53 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public enum SurroundMode {
+ OFF((byte) 0x00),
+ ARENA((byte) 0x02),
+ CLUB((byte) 0x04),
+ OUTDOOR_STAGE((byte) 0x01),
+ CONCERT_HALL((byte) 0x03);
+
+ private final byte code;
+
+ SurroundMode(final byte code) {
+ this.code = code;
+ }
+
+ public byte getCode() {
+ return this.code;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_SURROUND_MODE, name().toLowerCase(Locale.getDefault()));
+ }};
+ }
+
+ public static SurroundMode fromPreferences(final SharedPreferences prefs) {
+ return SurroundMode.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_SONY_SURROUND_MODE, "off").toUpperCase());
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/TouchSensor.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/TouchSensor.java
new file mode 100644
index 0000000..ae2567d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/TouchSensor.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class TouchSensor {
+ private final boolean enabled;
+
+ public TouchSensor(final boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_TOUCH_SENSOR, enabled);
+ }};
+ }
+
+ public static TouchSensor fromPreferences(final SharedPreferences prefs) {
+ return new TouchSensor(prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SONY_TOUCH_SENSOR, true));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/VoiceNotifications.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/VoiceNotifications.java
new file mode 100644
index 0000000..00083e6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/VoiceNotifications.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class VoiceNotifications {
+ private final boolean enabled;
+
+ public VoiceNotifications(final boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_NOTIFICATION_VOICE_GUIDE, enabled);
+ }};
+ }
+
+ public static VoiceNotifications fromPreferences(final SharedPreferences prefs) {
+ return new VoiceNotifications(prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SONY_NOTIFICATION_VOICE_GUIDE, true));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/WideAreaTap.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/WideAreaTap.java
new file mode 100644
index 0000000..0e9b08e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/headphones/prefs/WideAreaTap.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.prefs;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+
+public class WideAreaTap {
+ private final boolean enabled;
+
+ public WideAreaTap(final boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public Map<String, Object> toPreferences() {
+ return new HashMap<String, Object>() {{
+ put(DeviceSettingsPreferenceConst.PREF_SONY_WIDE_AREA_TAP, enabled);
+ }};
+ }
+
+ public static WideAreaTap fromPreferences(final SharedPreferences prefs) {
+ return new WideAreaTap(prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SONY_WIDE_AREA_TAP, true));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3ActivitySampleCombiner.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3ActivitySampleCombiner.java
new file mode 100644
index 0000000..a3baed0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3ActivitySampleCombiner.java
@@ -0,0 +1,78 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.devices.sony.wena3;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3BehaviorSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sony.wena3.protocol.packets.activity.BehaviorSample;
+
+// The sole purpose of this part of code is to take the Behavior samples
+// and drop them onto Steps samples (which are just your average activity data with the current
+// architecture of Gadgetbridge)
+public class SonyWena3ActivitySampleCombiner {
+ private static final Logger LOG = LoggerFactory.getLogger(SonyWena3ActivitySampleCombiner.class);
+ final SonyWena3ActivitySampleProvider activitySampleProvider;
+
+ public SonyWena3ActivitySampleCombiner(SonyWena3ActivitySampleProvider activitySampleProvider) {
+ this.activitySampleProvider = activitySampleProvider;
+ }
+
+ public void overlayBehaviorStartingAt(Date startDate, SonyWena3BehaviorSampleProvider behaviorSampleProvider) {
+ List<Wena3BehaviorSample> behaviorSamples = behaviorSampleProvider.getAllSamples(startDate.getTime(), Long.MAX_VALUE);
+ List<Wena3ActivitySample> alteredSamples = new ArrayList<>();
+ for(Wena3BehaviorSample behaviorSample: behaviorSamples) {
+ List<Wena3ActivitySample> activitySamplesForThisRange = activitySampleProvider.getAllActivitySamples((int)(behaviorSample.getTimestampFrom() / 1000L), (int)(behaviorSample.getTimestampTo() / 1000L));
+
+ LOG.debug("Changing " + activitySamplesForThisRange.size() + " samples to behavior type: " + BehaviorSample.Type.LUT[behaviorSample.getRawKind()].name());
+ for(Wena3ActivitySample activitySample: activitySamplesForThisRange) {
+ activitySample.setRawKind(behaviorSample.getRawKind());
+ alteredSamples.add(activitySample);
+ }
+ }
+ activitySampleProvider.addGBActivitySamples(alteredSamples.toArray(new Wena3ActivitySample[alteredSamples.size()]));
+ }
+
+ public void overlayHeartRateStartingAt(Date startDate, SonyWena3HeartRateSampleProvider heartRateSampleProvider) {
+ List<Wena3HeartRateSample> heartRateSamples = heartRateSampleProvider.getAllSamples(startDate.getTime(), Long.MAX_VALUE);
+ List<Wena3ActivitySample> alteredSamples = new ArrayList<>();
+ for(int i = 0; i < heartRateSamples.size(); i++) {
+ HeartRateSample currentSample = heartRateSamples.get(i);
+ HeartRateSample nextSample = (i == heartRateSamples.size() - 1) ? null : heartRateSamples.get(i + 1);
+
+ List<Wena3ActivitySample> activitySamplesInRange = activitySampleProvider.getAllActivitySamples(
+ (int) (currentSample.getTimestamp() / 1000L),
+ (nextSample == null ? Integer.MAX_VALUE : ((int) (nextSample.getTimestamp() / 1000L)))
+ );
+
+ LOG.debug("Changing " + activitySamplesInRange.size() + " samples to heart rate: " + currentSample.getHeartRate());
+ for(Wena3ActivitySample activitySample: activitySamplesInRange) {
+ activitySample.setHeartRate(currentSample.getHeartRate());
+ alteredSamples.add(activitySample);
+ }
+ }
+ activitySampleProvider.addGBActivitySamples(alteredSamples.toArray(new Wena3ActivitySample[alteredSamples.size()]));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3ActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3ActivitySampleProvider.java
new file mode 100644
index 0000000..7a499d5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3ActivitySampleProvider.java
@@ -0,0 +1,123 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.devices.sony.wena3;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sony.wena3.protocol.packets.activity.BehaviorSample;
+
+public class SonyWena3ActivitySampleProvider extends AbstractSampleProvider<Wena3ActivitySample> {
+ public SonyWena3ActivitySampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<Wena3ActivitySample, ?> getSampleDao() {
+ return getSession().getWena3ActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return null;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return Wena3ActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return Wena3ActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ if(rawType < 0 || rawType >= BehaviorSample.Type.LUT.length) return ActivityKind.UNKNOWN;
+
+ BehaviorSample.Type internalType = BehaviorSample.Type.LUT[rawType];
+ switch(internalType) {
+ case NOT_WEARING:
+ return ActivityKind.NOT_WORN;
+
+ case WALK:
+ return ActivityKind.WALKING;
+ case RUN:
+ return ActivityKind.RUNNING;
+ case EXERCISE:
+ return ActivityKind.EXERCISE;
+
+ case SLEEP_LIGHT:
+ return ActivityKind.LIGHT_SLEEP;
+ case SLEEP_REM:
+ return ActivityKind.REM_SLEEP;
+ case SLEEP_DEEP:
+ return ActivityKind.DEEP_SLEEP;
+
+ case STATIC:
+ case SLEEP_AWAKE:
+ case UNKNOWN:
+ default:
+ return ActivityKind.UNKNOWN;
+ }
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ switch(activityKind) {
+ case NOT_MEASURED:
+ case NOT_WORN:
+ return BehaviorSample.Type.NOT_WEARING.ordinal();
+ case WALKING:
+ return BehaviorSample.Type.WALK.ordinal();
+ case RUNNING:
+ return BehaviorSample.Type.RUN.ordinal();
+ case LIGHT_SLEEP:
+ return BehaviorSample.Type.SLEEP_LIGHT.ordinal();
+ case REM_SLEEP:
+ return BehaviorSample.Type.SLEEP_REM.ordinal();
+ case DEEP_SLEEP:
+ return BehaviorSample.Type.SLEEP_DEEP.ordinal();
+ case EXERCISE:
+ return BehaviorSample.Type.EXERCISE.ordinal();
+ default:
+ return BehaviorSample.Type.UNKNOWN.ordinal();
+ }
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return 0;
+ }
+
+ @Override
+ public Wena3ActivitySample createActivitySample() {
+ return new Wena3ActivitySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3BehaviorSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3BehaviorSampleProvider.java
new file mode 100644
index 0000000..952662f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3BehaviorSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.devices.sony.wena3;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3BehaviorSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3BehaviorSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class SonyWena3BehaviorSampleProvider extends AbstractTimeSampleProvider<Wena3BehaviorSample> {
+ public SonyWena3BehaviorSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<Wena3BehaviorSample, ?> getSampleDao() {
+ return getSession().getWena3BehaviorSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return Wena3BehaviorSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return Wena3BehaviorSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public Wena3BehaviorSample createSample() {
+ return new Wena3BehaviorSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3CaloriesSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3CaloriesSampleProvider.java
new file mode 100644
index 0000000..29bb304
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3CaloriesSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.devices.sony.wena3;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3CaloriesSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3CaloriesSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class SonyWena3CaloriesSampleProvider extends AbstractTimeSampleProvider<Wena3CaloriesSample> {
+ public SonyWena3CaloriesSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<Wena3CaloriesSample, ?> getSampleDao() {
+ return getSession().getWena3CaloriesSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return Wena3CaloriesSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return Wena3CaloriesSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public Wena3CaloriesSample createSample() {
+ return new Wena3CaloriesSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Constants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Constants.java
new file mode 100644
index 0000000..d76ca48
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Constants.java
@@ -0,0 +1,64 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.devices.sony.wena3;
+
+import android.graphics.Color;
+
+import java.util.UUID;
+
+public class SonyWena3Constants {
+ public static final String BT_DEVICE_NAME = "WNW-21A";
+ private static final String uuidTemplate = "4EFD%s-A6C1-16F0-062F-F196CF496695";
+
+ public static final UUID COMMON_SERVICE_UUID = UUID.fromString(String.format(uuidTemplate, "1501"));
+ public static final UUID COMMON_SERVICE_CHARACTERISTIC_MODE_UUID = UUID.fromString(String.format(uuidTemplate, "1503"));
+ public static final UUID COMMON_SERVICE_CHARACTERISTIC_CONTROL_UUID = UUID.fromString(String.format(uuidTemplate, "1514"));
+ public static final UUID COMMON_SERVICE_CHARACTERISTIC_INFO_UUID = UUID.fromString(String.format(uuidTemplate, "1520"));
+ public static final UUID COMMON_SERVICE_CHARACTERISTIC_STATE_UUID = UUID.fromString(String.format(uuidTemplate, "1521"));
+
+ public static final UUID NOTIFICATION_SERVICE_UUID = UUID.fromString(String.format(uuidTemplate, "4001"));
+ public static final UUID NOTIFICATION_SERVICE_CHARACTERISTIC_UUID = UUID.fromString(String.format(uuidTemplate, "4002"));
+
+ public static final UUID ACTIVITY_LOG_SERVICE_UUID = UUID.fromString(String.format(uuidTemplate, "3001"));
+ public static final UUID ACTIVITY_LOG_CHARACTERISTIC_UUID = UUID.fromString(String.format(uuidTemplate, "3002"));
+
+
+ public static int[] LED_PRESETS = {
+ Color.rgb(255, 0, 0),
+ Color.rgb(255, 255, 0),
+ Color.rgb(0, 255, 0),
+ Color.rgb(0, 255, 255),
+ Color.rgb(0, 0, 255),
+ Color.rgb(255, 0, 255),
+ Color.rgb(255, 255, 255)
+ };
+
+ public static final long EPOCH_START = 1577836800000L;
+
+ public static final int ALARM_SLOTS = 9;
+ public static final int ALARM_DEFAULT_SMART_WAKEUP_MARGIN_MINUTES = 10;
+
+ public static final String DB_FIELD_VO2_DATAPOINT_NUMBER = "datapoint";
+ public static final String DB_FIELD_VO2_VALUE = "vo2";
+ public static final String DB_FIELD_STRESS = "stress";
+ public static final String DB_FIELD_STEPS = "steps";
+ public static final String DB_FIELD_HEART_RATE = "heartRate";
+ public static final String DB_FIELD_ENERGY = "energy";
+ public static final String DB_FIELD_CALORIES = "calories";
+
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Coordinator.java
new file mode 100644
index 0000000..38af0c7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Coordinator.java
@@ -0,0 +1,297 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs, Daniel Dakhno
+
+ 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.devices.sony.wena3;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.regex.Pattern;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3BehaviorSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3CaloriesSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3EnergySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3HeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3StressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3Vo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.AbstractNotificationPattern;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sony.wena3.SonyWena3DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sony.wena3.protocol.packets.notification.defines.LedColor;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sony.wena3.protocol.packets.notification.defines.VibrationCount;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sony.wena3.protocol.packets.notification.defines.VibrationKind;
+
+public class SonyWena3Coordinator extends AbstractBLEDeviceCoordinator {
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Sony";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return SonyWena3DeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sony_wena3;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(GBDevice device) {
+ return new SonyWena3SettingsCustomizer();
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ScanFilter.Builder builder = new ScanFilter.Builder();
+ builder.setDeviceName(SonyWena3Constants.BT_DEVICE_NAME);
+ ArrayList<ScanFilter> result = new ArrayList<>();
+ result.add(builder.build());
+ return result;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb;
+
+ qb = session.getWena3HeartRateSampleDao().queryBuilder();
+ qb.where(Wena3HeartRateSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getWena3BehaviorSampleDao().queryBuilder();
+ qb.where(Wena3BehaviorSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getWena3CaloriesSampleDao().queryBuilder();
+ qb.where(Wena3CaloriesSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getWena3EnergySampleDao().queryBuilder();
+ qb.where(Wena3EnergySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getWena3ActivitySampleDao().queryBuilder();
+ qb.where(Wena3ActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getWena3Vo2SampleDao().queryBuilder();
+ qb.where(Wena3Vo2SampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getWena3StressSampleDao().queryBuilder();
+ qb.where(Wena3StressSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(SonyWena3Constants.BT_DEVICE_NAME);
+ }
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_BOND;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+
+ @Override
+ public boolean supportsAppReordering() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public TimeSampleProvider<? extends StressSample> getStressSampleProvider(GBDevice device, DaoSession session) {
+ return new SonyWena3StressSampleProvider(device, session);
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateStats() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new SonyWena3ActivitySampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HeartRateSample> getHeartRateRestingSampleProvider(GBDevice device, DaoSession session) {
+ return new SonyWena3HeartRateSampleProvider(device, session);
+ }
+
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return SonyWena3Constants.ALARM_SLOTS;
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(GBDevice device, int position) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return false;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(GBDevice device) {
+ return new String[]{
+ "auto",
+ "en_US",
+ "ja_JP"
+ };
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_notifications_enable,
+ R.xml.devicesettings_sync_calendar,
+ R.xml.devicesettings_wearlocation,
+ R.xml.devicesettings_donotdisturb_no_auto,
+ R.xml.devicesettings_wena3_auto_power_off,
+ R.xml.devicesettings_goal_notification,
+ R.xml.devicesettings_wena3,
+ };
+ }
+
+
+ public boolean supportsNotificationVibrationPatterns() {
+ return true;
+ }
+
+ public boolean supportsNotificationVibrationRepetitionPatterns() {
+ return true;
+ }
+
+ public boolean supportsNotificationLedPatterns() {
+ return true;
+ }
+
+ public AbstractNotificationPattern[] getNotificationVibrationPatterns() {
+ return new AbstractNotificationPattern[] {
+ VibrationKind.NONE, VibrationKind.BASIC,
+ VibrationKind.CONTINUOUS, VibrationKind.RAPID,
+ VibrationKind.TRIPLE, VibrationKind.STEP_UP, VibrationKind.STEP_DOWN,
+ VibrationKind.WARNING, VibrationKind.SIREN, VibrationKind.SHORT
+ };
+ }
+
+ public AbstractNotificationPattern[] getNotificationVibrationRepetitionPatterns() {
+ return new AbstractNotificationPattern[] {
+ VibrationCount.ONCE, VibrationCount.TWICE, VibrationCount.THREE, VibrationCount.FOUR,
+ VibrationCount.INDEFINITE
+ };
+ }
+
+ public AbstractNotificationPattern[] getNotificationLedPatterns() {
+ return new AbstractNotificationPattern[] {
+ LedColor.NONE, LedColor.RED, LedColor.YELLOW, LedColor.GREEN,
+ LedColor.CYAN, LedColor.BLUE, LedColor.PURPLE, LedColor.WHITE
+ };
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3EnergySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3EnergySampleProvider.java
new file mode 100644
index 0000000..ae7b63c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3EnergySampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.devices.sony.wena3;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3EnergySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3EnergySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class SonyWena3EnergySampleProvider extends AbstractTimeSampleProvider<Wena3EnergySample> {
+ public SonyWena3EnergySampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<Wena3EnergySample, ?> getSampleDao() {
+ return getSession().getWena3EnergySampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return Wena3EnergySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return Wena3EnergySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public Wena3EnergySample createSample() {
+ return new Wena3EnergySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3HeartRateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3HeartRateSampleProvider.java
new file mode 100644
index 0000000..3dbbc65
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3HeartRateSampleProvider.java
@@ -0,0 +1,58 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.devices.sony.wena3;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3HeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class SonyWena3HeartRateSampleProvider extends AbstractTimeSampleProvider<Wena3HeartRateSample> {
+ public SonyWena3HeartRateSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<Wena3HeartRateSample, ?> getSampleDao() {
+ return getSession().getWena3HeartRateSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return Wena3HeartRateSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return Wena3HeartRateSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public Wena3HeartRateSample createSample() {
+ return new Wena3HeartRateSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3SettingKeys.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3SettingKeys.java
new file mode 100644
index 0000000..e8efc76
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3SettingKeys.java
@@ -0,0 +1,51 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.devices.sony.wena3;
+
+public class SonyWena3SettingKeys {
+ // ------ Booleans
+ public static final String RICH_DESIGN_MODE = "pref_wena3_rich_design_mode";
+ public static final String LARGE_FONT_SIZE = "pref_wena3_large_font_size";
+ public static final String WEATHER_IN_STATUSBAR = "pref_wena3_weather_in_statusbar";
+ public static final String SMART_VIBRATION = "pref_wena3_vibration_smart";
+ public static final String RECEIVE_CALLS = "pref_wena3_receive_calls";
+ public static final String BACKGROUND_SYNC = "pref_wena3_background_sync";
+
+ public static final String AUTO_POWER_SCHEDULE_KIND = "pref_wena3_power_schedule_kind";
+ public static final String AUTO_POWER_SCHEDULE_START_HHMM = "pref_wena3_power_schedule_start";
+ public static final String AUTO_POWER_SCHEDULE_END_HHMM = "pref_wena3_power_schedule_end";
+
+ public static final String LEFT_HOME_ICON = "pref_wena3_home_icon_left";
+ public static final String CENTER_HOME_ICON = "pref_wena3_home_icon_center";
+ public static final String RIGHT_HOME_ICON = "pref_wena3_home_icon_right";
+ public static final String MENU_ICON_CSV_KEY = "pref_wena3_menu_icon_csv";
+ public static final String STATUS_PAGE_CSV_KEY = "pref_wena3_status_pages_csv";
+ public static final int MAX_STATUS_PAGES = 7;
+
+ public static final String DEFAULT_CALL_LED_COLOR = "pref_wena3_default_call_led_color";
+ public static final String DEFAULT_LED_COLOR = "pref_wena3_default_led_color";
+ public static final String DEFAULT_CALL_VIBRATION_PATTERN = "pref_wena3_call_default_vibration";
+ public static final String DEFAULT_VIBRATION_PATTERN = "pref_wena3_default_vibration";
+ public static final String BUTTON_LONG_PRESS_ACTION = "pref_wena3_button_long_action";
+ public static final String BUTTON_DOUBLE_PRESS_ACTION = "pref_wena3_button_double_action";
+ public static final String VIBRATION_STRENGTH = "pref_wena3_vibration_strength";
+ // ------ Ints
+ public static final String SMART_WAKEUP_MARGIN_MINUTES = "pref_wena3_smart_wakeup_margin";
+ public static final String DAY_START_HOUR = "pref_wena3_day_start_hour";
+ public static final String DEFAULT_VIBRATION_REPETITION = "pref_wena3_default_vibration_repetition";
+ public static final String DEFAULT_CALL_VIBRATION_REPETITION = "pref_wena3_default_call_vibration_repetition";
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3SettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3SettingsCustomizer.java
new file mode 100644
index 0000000..ba9394e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3SettingsCustomizer.java
@@ -0,0 +1,80 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.devices.sony.wena3;
+
+import android.os.Parcel;
+
+import androidx.preference.Preference;
+
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class SonyWena3SettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ @Override
+ public void customizeSettings(DeviceSpecificSettingsHandler handler, Prefs prefs, final String rootKey) {
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.RICH_DESIGN_MODE);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.LARGE_FONT_SIZE);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.WEATHER_IN_STATUSBAR);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.SMART_VIBRATION);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.SMART_WAKEUP_MARGIN_MINUTES);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.VIBRATION_STRENGTH);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.LEFT_HOME_ICON);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.CENTER_HOME_ICON);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.RIGHT_HOME_ICON);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.DAY_START_HOUR);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.AUTO_POWER_SCHEDULE_KIND);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.AUTO_POWER_SCHEDULE_START_HHMM);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.AUTO_POWER_SCHEDULE_END_HHMM);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.BUTTON_DOUBLE_PRESS_ACTION);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.BUTTON_LONG_PRESS_ACTION);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.MENU_ICON_CSV_KEY);
+ handler.addPreferenceHandlerFor(SonyWena3SettingKeys.STATUS_PAGE_CSV_KEY);
+ }
+
+ @Override
+ public void onPreferenceChange(Preference preference, DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+ public static final Creator<SonyWena3SettingsCustomizer> CREATOR = new Creator<SonyWena3SettingsCustomizer>() {
+ @Override
+ public SonyWena3SettingsCustomizer createFromParcel(final Parcel in) {
+ return new SonyWena3SettingsCustomizer();
+ }
+
+ @Override
+ public SonyWena3SettingsCustomizer[] newArray(final int size) {
+ return new SonyWena3SettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3StressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3StressSampleProvider.java
new file mode 100644
index 0000000..f64e399
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3StressSampleProvider.java
@@ -0,0 +1,73 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.devices.sony.wena3;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3StressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3StressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class SonyWena3StressSampleProvider extends AbstractTimeSampleProvider<Wena3StressSample> {
+ public SonyWena3StressSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<Wena3StressSample, ?> getSampleDao() {
+ return getSession().getWena3StressSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return Wena3StressSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return Wena3StressSampleDao.Properties.DeviceId;
+ }
+
+ @NonNull
+ @Override
+ public List<Wena3StressSample> getAllSamples(long timestampFrom, long timestampTo) {
+ List<Wena3StressSample> samples = super.getAllSamples(timestampFrom, timestampTo);
+ for(Wena3StressSample sample: samples) {
+ if(sample.getStress() < -100 || sample.getStress() > 100) {
+ sample.setStress(-1);
+ } else {
+ // Move from the original -100 .. 100 range to 0 .. 100
+ sample.setStress(Math.round(((float)sample.getStress() + 100.0f) / 2.0f));
+ }
+ }
+ return samples;
+ }
+
+ @Override
+ public Wena3StressSample createSample() {
+ return new Wena3StressSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Vo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Vo2SampleProvider.java
new file mode 100644
index 0000000..9e38dfd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sony/wena3/SonyWena3Vo2SampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.devices.sony.wena3;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3Vo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.Wena3Vo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class SonyWena3Vo2SampleProvider extends AbstractTimeSampleProvider<Wena3Vo2Sample> {
+ public SonyWena3Vo2SampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<Wena3Vo2Sample, ?> getSampleDao() {
+ return getSession().getWena3Vo2SampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return Wena3Vo2SampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return Wena3Vo2SampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public Wena3Vo2Sample createSample() {
+ return new Wena3Vo2Sample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sonyswr12/SonySWR12DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sonyswr12/SonySWR12DeviceCoordinator.java
new file mode 100644
index 0000000..b91b838
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sonyswr12/SonySWR12DeviceCoordinator.java
@@ -0,0 +1,150 @@
+/* Copyright (C) 2020-2024 Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo,
+ opavlov, Petr Van臎k
+
+ 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.devices.sonyswr12;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sonyswr12.SonySWR12DeviceSupport;
+
+public class SonySWR12DeviceCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(".*swr12.*", Pattern.CASE_INSENSITIVE);
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Sony";
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new SonySWR12SampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 5;
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(GBDevice device, int position) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{R.xml.devicesettings_sonyswr12};
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return SonySWR12DeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_sonyswr12;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sonyswr12/SonySWR12SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sonyswr12/SonySWR12SampleProvider.java
new file mode 100644
index 0000000..a7fdaf4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/sonyswr12/SonySWR12SampleProvider.java
@@ -0,0 +1,99 @@
+/* Copyright (C) 2020-2024 opavlov
+
+ 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.devices.sonyswr12;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.SonySWR12Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.SonySWR12SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sonyswr12.SonySWR12Constants;
+
+public class SonySWR12SampleProvider extends AbstractSampleProvider<SonySWR12Sample> {
+ public SonySWR12SampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<SonySWR12Sample, ?> getSampleDao() {
+ return getSession().getSonySWR12SampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return SonySWR12SampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return SonySWR12SampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return SonySWR12SampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ switch (rawType) {
+ case SonySWR12Constants.TYPE_ACTIVITY:
+ return ActivityKind.ACTIVITY;
+ case SonySWR12Constants.TYPE_LIGHT:
+ return ActivityKind.LIGHT_SLEEP;
+ case SonySWR12Constants.TYPE_DEEP:
+ return ActivityKind.DEEP_SLEEP;
+ case SonySWR12Constants.TYPE_NOT_WORN:
+ return ActivityKind.NOT_WORN;
+ }
+ return ActivityKind.UNKNOWN;
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ switch (activityKind) {
+ case ACTIVITY:
+ return SonySWR12Constants.TYPE_ACTIVITY;
+ case LIGHT_SLEEP:
+ return SonySWR12Constants.TYPE_LIGHT;
+ case DEEP_SLEEP:
+ return SonySWR12Constants.TYPE_DEEP;
+ case NOT_WORN:
+ return SonySWR12Constants.TYPE_NOT_WORN;
+ }
+ return SonySWR12Constants.TYPE_ACTIVITY;
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity;
+ }
+
+ @Override
+ public SonySWR12Sample createActivitySample() {
+ return new SonySWR12Sample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/liberty3_pro/SoundcoreLiberty3ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/liberty3_pro/SoundcoreLiberty3ProCoordinator.java
new file mode 100644
index 0000000..586de14
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/liberty3_pro/SoundcoreLiberty3ProCoordinator.java
@@ -0,0 +1,83 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.soundcore.liberty3_pro;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+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.AbstractDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.soundcore.liberty.SoundcoreLiberty3ProDeviceSupport;
+
+public class SoundcoreLiberty3ProCoordinator extends AbstractDeviceCoordinator {
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_soundcore_liberty3_pro;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_disabled;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Anker";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Soundcore Liberty 3 Pro");
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ }
+
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_buds_pro_case, R.string.battery_case);
+ BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_nothing_ear_l, R.string.left_earbud);
+ BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_nothing_ear_r, R.string.right_earbud);
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS);
+ deviceSpecificSettings.addSubScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS, R.xml.devicesettings_sony_headphones_ambient_sound_control_button_modes);
+ deviceSpecificSettings.addSubScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS, R.xml.devicesettings_soundcore_touch_options);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_soundcore_headphones);
+ return deviceSpecificSettings;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return SoundcoreLiberty3ProDeviceSupport.class;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/liberty4_nc/SoundcoreLiberty4NCCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/liberty4_nc/SoundcoreLiberty4NCCoordinator.java
new file mode 100644
index 0000000..a630071
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/liberty4_nc/SoundcoreLiberty4NCCoordinator.java
@@ -0,0 +1,83 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.soundcore.liberty4_nc;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+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.AbstractDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.soundcore.liberty.SoundcoreLiberty4NCDeviceSupport;
+
+public class SoundcoreLiberty4NCCoordinator extends AbstractDeviceCoordinator {
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_soundcore_liberty4_nc;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_galaxy_buds;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_galaxy_buds_disabled;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Anker";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("soundcore Liberty 4 NC");
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ }
+
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_buds_pro_case, R.string.battery_case);
+ BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_nothing_ear_l, R.string.left_earbud);
+ BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_nothing_ear_r, R.string.right_earbud);
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+ deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS);
+ deviceSpecificSettings.addSubScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS, R.xml.devicesettings_sony_headphones_ambient_sound_control_button_modes);
+ deviceSpecificSettings.addSubScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS, R.xml.devicesettings_soundcore_touch_options);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_soundcore_headphones);
+ return deviceSpecificSettings;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return SoundcoreLiberty4NCDeviceSupport.class;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/motion300/SoundcoreMotion300Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/motion300/SoundcoreMotion300Coordinator.java
new file mode 100644
index 0000000..1299e86
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/motion300/SoundcoreMotion300Coordinator.java
@@ -0,0 +1,93 @@
+/* Copyright (C) 2024 Severin von Wnuck-Lipinski
+
+ 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.devices.soundcore.motion300;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator;
+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.soundcore.motion300.SoundcoreMotion300DeviceSupport;
+
+public class SoundcoreMotion300Coordinator extends AbstractBLClassicDeviceCoordinator {
+ @Override
+ public String getManufacturer() {
+ return "Anker";
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("soundcore Motion 300");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_soundcore_motion300;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new SoundcoreMotion300SettingsCustomizer();
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 1;
+ }
+
+ @Override
+ public boolean supportsPowerOff() {
+ return true;
+ }
+
+ @Override
+ protected void deleteDevice(
+ @NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session)
+ throws GBException {}
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings settings = new DeviceSpecificSettings();
+
+ settings.addRootScreen(R.xml.devicesettings_soundcore_motion300);
+ settings.addRootScreen(DeviceSpecificSettingsScreen.AUDIO);
+ settings.addSubScreen(
+ DeviceSpecificSettingsScreen.AUDIO,
+ R.xml.devicesettings_soundcore_motion300_audio);
+ settings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
+ settings.addSubScreen(
+ DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS,
+ R.xml.devicesettings_headphones);
+
+ return settings;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return SoundcoreMotion300DeviceSupport.class;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/motion300/SoundcoreMotion300SettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/motion300/SoundcoreMotion300SettingsCustomizer.java
new file mode 100644
index 0000000..799c636
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundcore/motion300/SoundcoreMotion300SettingsCustomizer.java
@@ -0,0 +1,117 @@
+/* Copyright (C) 2024 Severin von Wnuck-Lipinski
+
+ 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.devices.soundcore.motion300;
+
+import android.os.Parcel;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.SeekBarPreference;
+
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.soundcore.motion300.SoundcoreMotion300Protocol;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.*;
+
+public class SoundcoreMotion300SettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ public static final Creator<SoundcoreMotion300SettingsCustomizer> CREATOR = new Creator<SoundcoreMotion300SettingsCustomizer>() {
+ @Override
+ public SoundcoreMotion300SettingsCustomizer createFromParcel(final Parcel in) {
+ return new SoundcoreMotion300SettingsCustomizer();
+ }
+
+ @Override
+ public SoundcoreMotion300SettingsCustomizer[] newArray(final int size) {
+ return new SoundcoreMotion300SettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ if (!preference.getKey().equals(PREF_SOUNDCORE_EQUALIZER_PRESET))
+ return;
+
+ CharSequence preset = ((ListPreference)preference).getEntry();
+
+ if (preset == null)
+ return;
+
+ Preference pref = handler.findPreference(PREF_SOUNDCORE_EQUALIZER_CUSTOM);
+ boolean customEnabled = preset.toString().equals(handler.getContext().getString(R.string.custom));
+
+ if (pref != null)
+ pref.setEnabled(customEnabled);
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ ListPreference equalizerDirection = handler.findPreference(PREF_SOUNDCORE_EQUALIZER_DIRECTION);
+
+ if (equalizerDirection != null) {
+ boolean enabled = prefs.getBoolean(PREF_SOUNDCORE_ADAPTIVE_DIRECTION, true);
+
+ equalizerDirection.setVisible(enabled);
+ }
+
+ Preference equalizerReset = handler.findPreference(PREF_SOUNDCORE_EQUALIZER_RESET);
+
+ if (equalizerReset != null)
+ equalizerReset.setOnPreferenceClickListener(pref -> resetEqualizer(handler));
+ }
+
+ private boolean resetEqualizer(final DeviceSpecificSettingsHandler handler) {
+ // Reset all bands to default settings
+ for (int i = 0; i < SoundcoreMotion300Protocol.EQUALIZER_PREFS_FREQ.length; i++) {
+ ListPreference prefFreq = handler.findPreference(SoundcoreMotion300Protocol.EQUALIZER_PREFS_FREQ[i]);
+ SeekBarPreference prefValue = handler.findPreference(SoundcoreMotion300Protocol.EQUALIZER_PREFS_VALUE[i]);
+
+ // Neutral configuration
+ prefValue.setValue(60);
+
+ // Default configuration: 80 Hz, 150 Hz, 300 Hz, 600 Hz, 1.2 kHz, 2.5 kHz, 5 kHz, 9 kHz, 13 kHz
+ if (i < 7)
+ prefFreq.setValue("7");
+ else if (i == 7)
+ prefFreq.setValue("6");
+ else if (i == 8)
+ prefFreq.setValue("1");
+ }
+
+ // Send updated equalizer configuration
+ handler.notifyPreferenceChanged(PREF_SOUNDCORE_EQUALIZER_BAND9_FREQ);
+
+ return true;
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {}
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/ControlActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/ControlActivity.java
new file mode 100644
index 0000000..d80d896
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/ControlActivity.java
@@ -0,0 +1,294 @@
+/* Copyright (C) 2022-2024 Petr Van臎k
+
+ 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.devices.supercars;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.os.CountDownTimer;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.ImageButton;
+import android.widget.TextView;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.supercars.SuperCarsSupport;
+
+public class ControlActivity extends AbstractGBActivity implements JoystickView.JoystickListener {
+ private static final Logger LOG = LoggerFactory.getLogger(ControlActivity.class);
+ LocalBroadcastManager localBroadcastManager;
+ CountDownTimer periodicDataSenderRunner;
+ boolean periodicDataSenderRunnerIsRunning = false;
+
+ private GBDevice device;
+ TextView batteryPercentage;
+ boolean lights = false;
+ boolean blinking = false;
+ boolean turbo = false;
+ int stepCounter = 0;
+
+ SuperCarsConstants.Direction direction = SuperCarsConstants.Direction.CENTER;
+ SuperCarsConstants.Movement movement = SuperCarsConstants.Movement.IDLE;
+ SuperCarsConstants.Speed speed = SuperCarsConstants.Speed.NORMAL;
+ SuperCarsConstants.Light light = SuperCarsConstants.Light.OFF;
+ SuperCarsConstants.Tricks tricks = SuperCarsConstants.Tricks.OFF;
+
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_supercars_control);
+
+ Intent intent = getIntent();
+ Bundle bundle = intent.getExtras();
+ if (bundle != null) {
+ device = bundle.getParcelable(GBDevice.EXTRA_DEVICE);
+ } else {
+ throw new IllegalArgumentException("Must provide a device when invoking this activity");
+ }
+
+ CheckBox turboMode = findViewById(R.id.turboMode);
+ CheckBox lightsOn = findViewById(R.id.lightsOn);
+ CheckBox lightsBlinking = findViewById(R.id.lightsBlinking);
+
+ batteryPercentage = findViewById(R.id.battery_percentage_label);
+ setBatteryLabel();
+
+ localBroadcastManager = LocalBroadcastManager.getInstance(ControlActivity.this);
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(GBDevice.ACTION_DEVICE_CHANGED);
+ localBroadcastManager.registerReceiver(commandReceiver, filter);
+
+ turboMode.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ turbo = isChecked;
+ }
+ });
+
+ lightsOn.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ if (isChecked) {
+ if (lightsBlinking.isChecked()) {
+ lightsBlinking.setChecked(false);
+ blinking = false;
+ }
+ }
+ lights = isChecked;
+ setLights();
+ }
+ });
+
+ lightsBlinking.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+
+ if (isChecked) {
+ if (lightsOn.isChecked()) {
+ lightsOn.setChecked(false);
+ lights = false;
+ }
+ }
+ blinking = isChecked;
+ setLights();
+ }
+ });
+
+ ImageButton trick1 = findViewById(R.id.trick1);
+ ImageButton trick2 = findViewById(R.id.trick2);
+ ImageButton trick3 = findViewById(R.id.trick3);
+ ImageButton trick4 = findViewById(R.id.trick4);
+
+ trick1.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ tricks = SuperCarsConstants.Tricks.CIRCLE_LEFT;
+ stepCounter = 0;
+ }
+ });
+
+ trick2.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ tricks = SuperCarsConstants.Tricks.CIRCLE_RIGHT;
+ stepCounter = 0;
+ }
+ });
+ trick3.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ tricks = SuperCarsConstants.Tricks.U_TURN_LEFT;
+ stepCounter = 0;
+ }
+ });
+ trick4.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ tricks = SuperCarsConstants.Tricks.U_TURN_RIGHT;
+ stepCounter = 0;
+ }
+ });
+
+ //when this activity is open, data is sent continuously every 100ms
+
+ if (!periodicDataSenderRunnerIsRunning) {
+ periodicDataSender();
+ }
+ }
+
+ private void setLights() {
+ if (!blinking) {
+ if (lights) {
+ light = SuperCarsConstants.Light.ON;
+ } else {
+ light = SuperCarsConstants.Light.OFF;
+ }
+ } else if (blinking) {
+ if (light.equals(SuperCarsConstants.Light.ON)) {
+ light = SuperCarsConstants.Light.OFF;
+ } else {
+ light = SuperCarsConstants.Light.ON;
+ }
+ }
+ }
+
+ private void setBatteryLabel() {
+ String level = device.getBatteryLevel() > 0 ? String.format("%1s%%", device.getBatteryLevel()) : device.getName();
+ batteryPercentage.setText(level);
+ }
+
+ private void sendLocalBroadcast(Intent intent) {
+ localBroadcastManager.sendBroadcast(intent);
+ }
+
+ public void periodicDataSender() {
+ periodicDataSenderRunner = new CountDownTimer(Long.MAX_VALUE, 100) {
+
+ public void onTick(long millisUntilFinished) {
+ periodicDataSenderRunnerIsRunning = true;
+ setLights();
+
+ if (tricks != SuperCarsConstants.Tricks.OFF) {
+ Enum[][] trick_steps = SuperCarsConstants.get_trick(tricks);
+ int steps = trick_steps.length;
+ if (stepCounter < steps) {
+ Enum[] step = trick_steps[stepCounter];
+ movement = (SuperCarsConstants.Movement) step[0];
+ direction = (SuperCarsConstants.Direction) step[1];
+ stepCounter++;
+ } else {
+ tricks = SuperCarsConstants.Tricks.OFF;
+ stepCounter = 0;
+ }
+ }
+
+ create_intent_with_data();
+ }
+
+ public void onFinish() {
+ start();
+ }
+ };
+ }
+
+ private void create_intent_with_data() {
+ Intent intent = new Intent(SuperCarsSupport.COMMAND_DRIVE_CONTROL);
+ intent.putExtra(SuperCarsSupport.EXTRA_DIRECTION, direction);
+ intent.putExtra(SuperCarsSupport.EXTRA_MOVEMENT, movement);
+ intent.putExtra(SuperCarsSupport.EXTRA_SPEED, speed);
+ intent.putExtra(SuperCarsSupport.EXTRA_LIGHT, light);
+ sendLocalBroadcast(intent);
+ }
+
+ @Override
+ public void onJoystickMoved(float xPercent, float yPercent, int id) {
+
+ tricks = SuperCarsConstants.Tricks.OFF;
+ stepCounter = 0;
+
+ if (yPercent < 0.2 && yPercent != 0) {
+ movement = SuperCarsConstants.Movement.UP;
+ } else if (yPercent > 0.2) {
+ movement = SuperCarsConstants.Movement.DOWN;
+ } else {
+ movement = SuperCarsConstants.Movement.IDLE;
+ }
+
+ if (xPercent < -0.5) {
+ direction = SuperCarsConstants.Direction.LEFT;
+ } else if (xPercent > 0.5) {
+ direction = SuperCarsConstants.Direction.RIGHT;
+ } else {
+ direction = SuperCarsConstants.Direction.CENTER;
+ }
+
+ if (turbo) {
+ speed = SuperCarsConstants.Speed.TURBO;
+ } else {
+ speed = SuperCarsConstants.Speed.NORMAL;
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ periodicDataSenderRunner.cancel();
+ periodicDataSenderRunnerIsRunning = false;
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(commandReceiver);
+ }
+
+ @Override
+ protected void onPostResume() {
+ super.onPostResume();
+ if (!periodicDataSenderRunnerIsRunning) {
+ periodicDataSenderRunner.start();
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ periodicDataSenderRunner.cancel();
+ periodicDataSenderRunnerIsRunning = false;
+ }
+
+ BroadcastReceiver commandReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ LOG.debug("device receiver received " + intent.getAction());
+ if (intent.getAction().equals(GBDevice.ACTION_DEVICE_CHANGED)) {
+ GBDevice newDevice = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (newDevice.equals(device)) {
+ device = newDevice;
+ setBatteryLabel();
+ }
+
+ }
+ }
+ };
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/JoystickView.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/JoystickView.java
new file mode 100644
index 0000000..44999e0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/JoystickView.java
@@ -0,0 +1,126 @@
+/* Original code taken from https://github.com/efficientisoceles/JoystickView
+ Licenced under the terms of the GNU General Public License version 3
+ Copyright (C) Daniel Su <goldenember@gmail.com>
+ */
+package nodomain.freeyourgadget.gadgetbridge.devices.supercars;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.View;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class JoystickView extends SurfaceView implements SurfaceHolder.Callback, View.OnTouchListener {
+ private float centerX;
+ private float centerY;
+ private float baseRadius;
+ private float hatRadius;
+ private JoystickListener joystickCallback;
+ private final int ratio = 5;
+
+ private void setupDimensions() {
+ centerX = getWidth() / 2;
+ centerY = getHeight() / 2;
+ baseRadius = Math.min(getWidth(), getHeight()) / 3;
+ hatRadius = Math.min(getWidth(), getHeight()) / 5;
+ }
+
+ public JoystickView(Context context) {
+ super(context);
+ getHolder().addCallback(this);
+ setOnTouchListener(this);
+ if (context instanceof JoystickListener)
+ joystickCallback = (JoystickListener) context;
+ }
+
+ public JoystickView(Context context, AttributeSet attributes, int style) {
+ super(context, attributes, style);
+ getHolder().addCallback(this);
+ setOnTouchListener(this);
+ if (context instanceof JoystickListener)
+ joystickCallback = (JoystickListener) context;
+ }
+
+ public JoystickView(Context context, AttributeSet attributes) {
+ super(context, attributes);
+ getHolder().addCallback(this);
+ setOnTouchListener(this);
+ if (context instanceof JoystickListener)
+ joystickCallback = (JoystickListener) context;
+ }
+
+ private void drawJoystick(float newX, float newY) {
+ if (getHolder().getSurface().isValid()) {
+ Canvas myCanvas = this.getHolder().lockCanvas();
+ Paint colors = new Paint();
+ myCanvas.drawColor(GBApplication.getWindowBackgroundColor(getContext()));
+
+ float hypotenuse = (float) Math.sqrt(Math.pow(newX - centerX, 2) + Math.pow(newY - centerY, 2));
+ float sin = (newY - centerY) / hypotenuse; //sin = o/h
+ float cos = (newX - centerX) / hypotenuse; //cos = a/h
+
+ colors.setColor(this.getResources().getColor(R.color.primarydark_light));
+ myCanvas.drawCircle(centerX, centerY, baseRadius, colors);
+
+ colors.setColor(this.getResources().getColor(R.color.chart_deep_sleep_light));
+
+ myCanvas.drawCircle(newX - cos * hypotenuse * (ratio / baseRadius) * 30,
+ newY - sin * hypotenuse * (ratio / baseRadius) * 30, 100, colors);
+
+ colors.setColor(this.getResources().getColor(R.color.accent));
+ myCanvas.drawCircle(newX, newY, hatRadius, colors);
+
+ getHolder().unlockCanvasAndPost(myCanvas);
+ }
+ }
+
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ setupDimensions();
+ drawJoystick(centerX, centerY);
+ }
+
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+
+ }
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+
+ }
+
+ public boolean onTouch(View v, MotionEvent e) {
+ if (v.equals(this)) {
+ if (e.getAction() != MotionEvent.ACTION_UP) {
+ float displacement = (float) Math.sqrt((Math.pow(e.getX() - centerX, 2)) + Math.pow(e.getY() - centerY, 2));
+ if (displacement < baseRadius) {
+ drawJoystick(e.getX(), e.getY());
+ joystickCallback.onJoystickMoved((e.getX() - centerX) / baseRadius, (e.getY() - centerY) / baseRadius, getId());
+ } else {
+ float ratio = baseRadius / displacement;
+ float constrainedX = centerX + (e.getX() - centerX) * ratio;
+ float constrainedY = centerY + (e.getY() - centerY) * ratio;
+ drawJoystick(constrainedX, constrainedY);
+ joystickCallback.onJoystickMoved((constrainedX - centerX) / baseRadius, (constrainedY - centerY) / baseRadius, getId());
+ }
+ } else if (e.getAction() == MotionEvent.ACTION_UP) {
+ drawJoystick(centerX, centerY);
+ joystickCallback.onJoystickMoved(0, 0, getId());
+ } else
+ drawJoystick(centerX, centerY);
+ //joystickCallback.onJoystickMoved(0, 0, getId());
+ }
+ return true;
+ }
+
+ public interface JoystickListener {
+ void onJoystickMoved(float xPercent, float yPercent, int id);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/SuperCarsConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/SuperCarsConstants.java
new file mode 100644
index 0000000..b231e38
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/SuperCarsConstants.java
@@ -0,0 +1,101 @@
+/* Copyright (C) 2022-2024 Petr Van臎k
+
+ 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.devices.supercars;
+
+import java.util.UUID;
+
+public class SuperCarsConstants {
+
+ //https://gist.github.com/scrool/e79d6a4cb50c26499746f4fe473b3768#encryption
+ public static final byte[] aes_key = new byte[]{(byte) 0x34, (byte) 0x52, (byte) 0x2A, (byte) 0x5B, (byte) 0x7A, (byte) 0x6E, (byte) 0x49, (byte) 0x2C, (byte) 0x08, (byte) 0x09, (byte) 0x0A, (byte) 0x9D, (byte) 0x8D, (byte) 0x2A, (byte) 0x23, (byte) 0xF8};
+
+ public static final UUID SERVICE_UUID_FFF = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb");
+ public static final UUID CHARACTERISTIC_UUID_FFF1 = UUID.fromString("d44bc439-abfd-45a2-b575-925416129600");
+ public static final UUID CHARACTERISTIC_UUID_FFF2 = UUID.fromString("d44bc439-abfd-45a2-b575-92541612960a");
+ public static final UUID CHARACTERISTIC_UUID_FFF3 = UUID.fromString("d44bc439-abfd-45a2-b575-92541612960b");
+ public static final UUID CHARACTERISTIC_UUID_FFF4 = UUID.fromString("d44bc439-abfd-45a2-b575-925416129601");
+
+ public static final UUID SERVICE_UUID_FD = UUID.fromString("0000fd00-0000-1000-8000-00805f9b34fb");
+ public static final UUID CHARACTERISTIC_UUID_FD1 = UUID.fromString("0000fd01-0000-1000-8000-00805f9b34fb");
+ public static final UUID CHARACTERISTIC_UUID_FD2 = UUID.fromString("0000fd02-0000-1000-8000-00805f9b34fb");
+
+ public enum Speed {
+ NORMAL, TURBO
+ }
+
+ public enum Light {
+ ON, OFF
+ }
+
+ public enum Movement {
+ UP, DOWN, IDLE
+ }
+
+ public enum Direction {
+ LEFT, RIGHT, CENTER
+ }
+
+ public enum Tricks {
+ OFF, CIRCLE_RIGHT, CIRCLE_LEFT, U_TURN_LEFT, U_TURN_RIGHT
+ }
+
+ static Enum[] fwd_r = {Movement.UP, Direction.RIGHT};
+ static Enum[] fwd_l = {Movement.UP, Direction.LEFT};
+ static Enum[] stop = {Movement.IDLE, Direction.CENTER};
+
+ public static final Enum[][] tricks_circle_right = {
+ fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r,
+ fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r,
+ fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r,
+ stop
+ };
+
+ public static final Enum[][] tricks_circle_left = {
+ fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l,
+ fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l,
+ fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l,
+ stop
+ };
+
+ public static final Enum[][] tricks_u_turn_right = {
+ fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r,
+ fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r, fwd_r,
+ stop
+ };
+
+ public static final Enum[][] tricks_u_turn_left = {
+ fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l,
+ fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l, fwd_l,
+ stop
+ };
+
+
+ public static final Enum[][] get_trick(Tricks trick) {
+ switch (trick) {
+ case CIRCLE_RIGHT:
+ return tricks_circle_right;
+ case CIRCLE_LEFT:
+ return tricks_circle_left;
+ case U_TURN_LEFT:
+ return tricks_u_turn_left;
+ case U_TURN_RIGHT:
+ return tricks_u_turn_right;
+ }
+ return tricks_circle_right;
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/SuperCarsCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/SuperCarsCoordinator.java
new file mode 100644
index 0000000..f5f4b58
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/supercars/SuperCarsCoordinator.java
@@ -0,0 +1,157 @@
+/* Copyright (C) 2022-2024 Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.supercars;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.supercars.SuperCarsSupport;
+
+public class SuperCarsCoordinator extends AbstractDeviceCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(SuperCarsCoordinator.class);
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("QCAR-.*");
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 1;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return SuperCarsSupport.class;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return ControlActivity.class;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Brand Base";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_super_cars;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_supercars;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_supercars_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceConst.java
new file mode 100644
index 0000000..f9a3d6e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceConst.java
@@ -0,0 +1,25 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test;
+
+public class TestDeviceConst {
+ public static final String PREF_TEST_FEATURES = "test_features";
+
+ private TestDeviceConst() {
+ // utility class
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceCoordinator.java
new file mode 100644
index 0000000..9e4459f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceCoordinator.java
@@ -0,0 +1,697 @@
+/* Copyright (C) 2023-2024 Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.ArrayUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.roidmi.RoidmiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.activity.TestActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.samples.TestBodyEnergySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.samples.TestHrvSummarySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.samples.TestHrvValueSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.samples.TestPaiSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.samples.TestRespiratoryRateSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.samples.TestSpo2SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.samples.TestStressSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.samples.TestSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.samples.TestTemperatureSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.AbstractNotificationPattern;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.model.BodyEnergySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
+import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+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.devices.test.TestDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class TestDeviceCoordinator extends AbstractDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull final GBDevice gbDevice,
+ @NonNull final Device device,
+ @NonNull final DaoSession session) throws GBException {
+ // Nothing to do
+ }
+
+ @Override
+ public boolean supports(final GBDeviceCandidate candidate) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Gadgetbridge";
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return TestDeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_test;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(final GBDevice device, final DaoSession session) {
+ return supportsActivityTracking() ? new TestSampleProvider(device, session) : super.getSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends StressSample> getStressSampleProvider(final GBDevice device, final DaoSession session) {
+ return supportsStressMeasurement() ? new TestStressSampleProvider() : super.getStressSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends BodyEnergySample> getBodyEnergySampleProvider(final GBDevice device, final DaoSession session) {
+ return supportsBodyEnergy() ? new TestBodyEnergySampleProvider() : super.getBodyEnergySampleProvider(device ,session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HrvSummarySample> getHrvSummarySampleProvider(final GBDevice device, final DaoSession session) {
+ return supportsBodyEnergy() ? new TestHrvSummarySampleProvider() : super.getHrvSummarySampleProvider(device ,session);
+
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HrvValueSample> getHrvValueSampleProvider(final GBDevice device, final DaoSession session) {
+ return supportsBodyEnergy() ? new TestHrvValueSampleProvider() : super.getHrvValueSampleProvider(device ,session);
+ }
+
+ @Override
+ public int[] getStressRanges() {
+ // TODO getStressRanges
+ return super.getStressRanges();
+ }
+
+ @Override
+ public TimeSampleProvider<? extends TemperatureSample> getTemperatureSampleProvider(final GBDevice device, final DaoSession session) {
+ return supportsTemperatureMeasurement() ? new TestTemperatureSampleProvider() : super.getTemperatureSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ return supportsSpo2(device) ? new TestSpo2SampleProvider() : super.getSpo2SampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HeartRateSample> getHeartRateMaxSampleProvider(final GBDevice device, final DaoSession session) {
+ // TODO getHeartRateMaxSampleProvider
+ return super.getHeartRateMaxSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HeartRateSample> getHeartRateRestingSampleProvider(final GBDevice device, final DaoSession session) {
+ // TODO getHeartRateRestingSampleProvider
+ return super.getHeartRateRestingSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HeartRateSample> getHeartRateManualSampleProvider(final GBDevice device, final DaoSession session) {
+ // TODO getHeartRateManualSampleProvider
+ return super.getHeartRateManualSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends PaiSample> getPaiSampleProvider(final GBDevice device, final DaoSession session) {
+ return supportsPai() ? new TestPaiSampleProvider() : super.getPaiSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends RespiratoryRateSample> getRespiratoryRateSampleProvider(final GBDevice device, final DaoSession session) {
+ return supportsRespiratoryRate() ? new TestRespiratoryRateSampleProvider() : super.getRespiratoryRateSampleProvider(device, session);
+ }
+
+ @Nullable
+ @Override
+ public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
+ return supportsActivityTracks() ? new TestActivitySummaryParser() : super.getActivitySummaryParser(device, context);
+ }
+
+ @Override
+ public File getAppCacheDir() throws IOException {
+ return new File(FileUtils.getExternalFilesDir(), "test-app-cache");
+ }
+
+ @Override
+ public String getAppCacheSortFilename() {
+ return "test-app-cache-order.txt";
+ }
+
+ @Override
+ public String getAppFileExtension() {
+ return ".txt";
+ }
+
+ @Override
+ public boolean supportsAppListFetching() {
+ return supports(getTestDevice(), TestFeature.APP_LIST_FETCHING);
+ }
+
+ @Override
+ public boolean supportsFlashing() {
+ return supports(getTestDevice(), TestFeature.FLASHING);
+ }
+
+ @Nullable
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ // TODO findInstallHandler?
+ return super.findInstallHandler(uri, context);
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.SCREENSHOTS);
+ }
+
+ @Override
+ public int getAlarmSlotCount(final GBDevice device) {
+ /*
+ 0: Forced smart, no interval
+ 1: Forced smart, with interval
+ 2: Unforced smart, no interval
+ 3: Unforced smart, with interval
+ 4: Not smart
+ */
+ return 5;
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(final GBDevice device, int position) {
+ return supports(getTestDevice(), TestFeature.SMART_WAKEUP) && position <= 3;
+ }
+
+ @Override
+ public boolean supportsSmartWakeupInterval(GBDevice device, int alarmPosition) {
+ return supports(getTestDevice(), TestFeature.SMART_WAKEUP_INTERVAL) && (alarmPosition == 1 || alarmPosition == 3);
+ }
+
+ @Override
+ public boolean forcedSmartWakeup(final GBDevice device, final int alarmPosition) {
+ return supports(getTestDevice(), TestFeature.SMART_WAKEUP_FORCED_SLOT) && alarmPosition <= 1;
+ }
+
+ @Override
+ public boolean supportsAppReordering() {
+ return supports(getTestDevice(), TestFeature.APP_REORDERING);
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.APPS_MANAGEMENT);
+ }
+
+ @Override
+ public boolean supportsCachedAppManagement(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.CACHED_APP_MANAGEMENT);
+ }
+
+ @Override
+ public boolean supportsInstalledAppManagement(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.INSTALLED_APP_MANAGEMENT);
+ }
+
+ @Override
+ public boolean supportsWatchfaceManagement(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.WATCHFACE_MANAGEMENT);
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return AppManagerActivity.class;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getWatchfaceDesignerActivity() {
+ // TODO getWatchfaceDesignerActivity
+ return super.getWatchfaceDesignerActivity();
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return supports(getTestDevice(), TestFeature.CALENDAR_EVENTS);
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return supports(getTestDevice(), TestFeature.ACTIVITY_DATA_FETCHING);
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return supports(getTestDevice(), TestFeature.ACTIVITY_TRACKING);
+ }
+
+ @Override
+ public boolean supportsSleepMeasurement() {
+ return supports(getTestDevice(), TestFeature.SLEEP_MEASUREMENT);
+ }
+
+ @Override
+ public boolean supportsStepCounter() {
+ return supports(getTestDevice(), TestFeature.STEP_COUNTER);
+ }
+
+ @Override
+ public boolean supportsSpeedzones() {
+ return supports(getTestDevice(), TestFeature.SPEEDZONES);
+ }
+
+ @Override
+ public boolean supportsActivityTabs() {
+ return supports(getTestDevice(), TestFeature.ACTIVITY_TABS);
+ }
+
+ @Override
+ public boolean supportsTemperatureMeasurement() {
+ return supports(getTestDevice(), TestFeature.TEMPERATURE_MEASUREMENT);
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return supports(getTestDevice(), TestFeature.ACTIVITY_TRACKS);
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return supports(getTestDevice(), TestFeature.STRESS_MEASUREMENT);
+ }
+
+ @Override
+ public boolean supportsBodyEnergy() {
+ return supports(getTestDevice(), TestFeature.BODY_ENERGY);
+ }
+
+ @Override
+ public boolean supportsHrvMeasurement() {
+ return supports(getTestDevice(), TestFeature.HRV_MEASUREMENT);
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return supports(getTestDevice(), TestFeature.SPO2);
+ }
+
+ @Override
+ public boolean supportsHeartRateStats() {
+ return supports(getTestDevice(), TestFeature.HEART_RATE_STATS);
+ }
+
+ @Override
+ public boolean supportsPai() {
+ return supports(getTestDevice(), TestFeature.PAI);
+ }
+
+ @Override
+ public int getPaiName() {
+ return R.string.menuitem_pai;
+ }
+
+ @Override
+ public boolean supportsPaiTime() {
+ return supports(getTestDevice(), TestFeature.PAI_TIME);
+ }
+
+ @Override
+ public boolean supportsRespiratoryRate() {
+ return supports(getTestDevice(), TestFeature.RESPIRATORY_RATE);
+ }
+
+ @Override
+ public boolean supportsSleepRespiratoryRate() {
+ return supports(getTestDevice(), TestFeature.SLEEP_RESPIRATORY_RATE);
+ }
+
+ @Override
+ public boolean supportsAlarmSnoozing() {
+ return supports(getTestDevice(), TestFeature.ALARM_SNOOZING);
+ }
+
+ @Override
+ public boolean supportsAlarmTitle(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.ALARM_TITLE);
+ }
+
+ @Override
+ public int getAlarmTitleLimit(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.ALARM_TITLE_LIMIT) ? 10 : -1;
+ }
+
+ @Override
+ public boolean supportsAlarmDescription(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.ALARM_DESCRIPTION);
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return supports(getTestDevice(), TestFeature.MUSIC_INFO);
+ }
+
+ @Override
+ public boolean supportsLedColor() {
+ return supports(getTestDevice(), TestFeature.LED_COLOR);
+ }
+
+ @Override
+ public int getMaximumReminderMessageLength() {
+ // TODO getMaximumReminderMessageLength
+ return 16;
+ }
+
+ @Override
+ public int getReminderSlotCount(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.REMINDERS) ? 3 : 0;
+ }
+
+ @Override
+ public int getCannedRepliesSlotCount(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.CANNED_REPLIES) ? 3 : 0;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return supports(getTestDevice(), TestFeature.WORLD_CLOCKS) ? 3 : 0;
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ // TODO getWorldClocksLabelLength
+ return 10;
+ }
+
+ @Override
+ public boolean supportsDisabledWorldClocks() {
+ return supports(getTestDevice(), TestFeature.DISABLED_WORLD_CLOCKS);
+ }
+
+ @Override
+ public int getContactsSlotCount(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.CONTACTS) ? 3 : 0;
+ }
+
+ @Override
+ public boolean supportsRgbLedColor() {
+ return supports(getTestDevice(), TestFeature.RGB_LED_COLOR);
+ }
+
+ @NonNull
+ @Override
+ public int[] getColorPresets() {
+ return RoidmiConst.COLOR_PRESETS;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.HEART_RATE_MEASUREMENT);
+ }
+
+ @Override
+ public boolean supportsManualHeartRateMeasurement(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.MANUAL_HEART_RATE_MEASUREMENT);
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return supports(getTestDevice(), TestFeature.REALTIME_DATA);
+ }
+
+ @Override
+ public boolean supportsRemSleep() {
+ return supports(getTestDevice(), TestFeature.REM_SLEEP);
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return supports(getTestDevice(), TestFeature.WEATHER);
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return supports(getTestDevice(), TestFeature.FIND_DEVICE);
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return supports(getTestDevice(), TestFeature.UNICODE_EMOJIS);
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificConnectionSettings() {
+ return super.getSupportedDeviceSpecificConnectionSettings();
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_loyalty_cards);
+
+ if (getWorldClocksSlotCount() > 0) {
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ }
+
+ if (getContactsSlotCount(device) > 0) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_contacts);
+ }
+
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_test_features);
+
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_developer_add_test_activities);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificAuthenticationSettings() {
+ return new int[]{R.xml.devicesettings_pairingkey};
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new TestDeviceSpecificSettingsCustomizer();
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "en_US",
+ };
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getCalibrationActivity() {
+ // TODO getCalibrationActivity
+ return super.getCalibrationActivity();
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return supports(getTestDevice(), TestFeature.BATTERIES_MULTIPLE) ? 3 : 1;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(final GBDevice device) {
+ if (getBatteryCount() == 1) {
+ return super.getBatteryConfig(device);
+ }
+
+ final BatteryConfig[] ret = new BatteryConfig[getBatteryCount()];
+
+ for (int i = 0; i < getBatteryCount(); i++) {
+ ret[i] = new BatteryConfig(i, R.drawable.ic_battery_full, R.string.battery);
+ }
+
+ return ret;
+ }
+
+ @Override
+ public boolean supportsPowerOff() {
+ return supports(getTestDevice(), TestFeature.POWER_OFF);
+ }
+
+ @Override
+ public PasswordCapabilityImpl.Mode getPasswordCapability() {
+ return super.getPasswordCapability();
+ }
+
+ @Override
+ public List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
+ return super.getHeartRateMeasurementIntervals();
+ }
+
+ @Override
+ public boolean supportsWidgets(final GBDevice device) {
+ return supports(getTestDevice(), TestFeature.WIDGETS);
+ }
+
+ @Nullable
+ @Override
+ public WidgetManager getWidgetManager(final GBDevice device) {
+ return super.getWidgetManager(device);
+ }
+
+ @Override
+ public boolean supportsNavigation() {
+ return supports(getTestDevice(), TestFeature.NAVIGATION);
+ }
+
+ @Override
+ public EnumSet<ServiceDeviceSupport.Flags> getInitialFlags() {
+ return EnumSet.noneOf(ServiceDeviceSupport.Flags.class);
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ // TODO getDefaultIconResource
+ return super.getDefaultIconResource();
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ // TODO getDisabledIconResource
+ return super.getDisabledIconResource();
+ }
+
+ @Override
+ public boolean supportsNotificationVibrationPatterns() {
+ return supports(getTestDevice(), TestFeature.NOTIFICATION_VIBRATION_PATTERNS);
+ }
+
+ @Override
+ public boolean supportsNotificationVibrationRepetitionPatterns() {
+ return supports(getTestDevice(), TestFeature.NOTIFICATION_VIBRATION_REPETITION_PATTERNS);
+ }
+
+ @Override
+ public boolean supportsNotificationLedPatterns() {
+ return supports(getTestDevice(), TestFeature.NOTIFICATION_LED_PATTERNS);
+ }
+
+ @Override
+ public boolean addBatteryPollingSettings() {
+ return supports(getTestDevice(), TestFeature.BATTERY_POLLING_SETTINGS);
+ }
+
+ @Override
+ public AbstractNotificationPattern[] getNotificationVibrationPatterns() {
+ // TODO getNotificationVibrationPatterns
+ return new AbstractNotificationPattern[0];
+ }
+
+ @Override
+ public AbstractNotificationPattern[] getNotificationVibrationRepetitionPatterns() {
+ // TODO getNotificationVibrationRepetitionPatterns
+ return new AbstractNotificationPattern[0];
+ }
+
+ @Override
+ public AbstractNotificationPattern[] getNotificationLedPatterns() {
+ // TODO getNotificationLedPatterns
+ return new AbstractNotificationPattern[0];
+ }
+
+ @Override
+ public boolean validateAuthKey(final String authKey) {
+ return authKey.equals("hunter2");
+ }
+
+ public boolean supports(final GBDevice device, final TestFeature feature) {
+ if (device == null) {
+ return false;
+ }
+
+ final Set<String> features = new HashSet<>(getPrefs(device).getStringSet(TestDeviceConst.PREF_TEST_FEATURES, Collections.emptySet()));
+ // if nothing enable, enable everything
+ return features.isEmpty() || features.contains(feature.name());
+ }
+
+ @Nullable
+ private GBDevice getTestDevice() {
+ // HACK: Avoid the refactor of adding a device as a parameter on all functions
+ // This means that it's only possible to have a single test device
+
+ if (GBApplication.app() == null || GBApplication.app().getDeviceManager() == null) {
+ return null;
+ }
+
+ final List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
+ for (GBDevice device : devices) {
+ if (DeviceType.TEST == device.getType()) {
+ return device;
+ }
+ }
+
+ return null;
+ }
+
+ protected static Prefs getPrefs(final GBDevice device) {
+ return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceRand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceRand.java
new file mode 100644
index 0000000..e04d429
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceRand.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test;
+
+import java.util.Random;
+
+/**
+ * Utility class to try and get deterministic random values across multiple users. This is not efficient
+ * and should definitely be improved.
+ */
+public class TestDeviceRand {
+ public static final long BASE_TIMESTAMP = 1420499943000L;
+
+ private TestDeviceRand() {
+ // utility class
+ }
+
+ public static int randInt(final long ts, final int min, final int max) {
+ return new Random(ts - BASE_TIMESTAMP).nextInt((max - min) + 1) + min;
+ }
+
+ public static long randLong(final long ts, final long min, final long max) {
+ return (long) (new Random(ts - BASE_TIMESTAMP).nextFloat() * (max - min) + min);
+ }
+
+ public static float randFloat(final long ts, final float min, final float max) {
+ return new Random(ts - BASE_TIMESTAMP).nextFloat() * (max - min) + min;
+ }
+
+ public static boolean randBool(final long ts, final float pTrue) {
+ return new Random(ts - BASE_TIMESTAMP).nextFloat() < pTrue;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceSpecificSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceSpecificSettingsCustomizer.java
new file mode 100644
index 0000000..30547cb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestDeviceSpecificSettingsCustomizer.java
@@ -0,0 +1,134 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test;
+
+import android.os.Parcel;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.preference.MultiSelectListPreference;
+import androidx.preference.Preference;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class TestDeviceSpecificSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ if (TestDeviceConst.PREF_TEST_FEATURES.equals(preference.getKey())) {
+ handler.getDevice().sendDeviceUpdateIntent(handler.getContext());
+ }
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ final Preference pref = handler.findPreference(TestDeviceConst.PREF_TEST_FEATURES);
+ if (pref != null) {
+ // Populate the preference directly from the enum
+ final CharSequence[] entries = new CharSequence[TestFeature.values().length];
+ final CharSequence[] values = new CharSequence[TestFeature.values().length];
+ for (int i = 0; i < TestFeature.values().length; i++) {
+ entries[i] = TestFeature.values()[i].name();
+ values[i] = TestFeature.values()[i].name();
+ }
+ if (pref instanceof MultiSelectListPreference) {
+ ((MultiSelectListPreference) pref).setEntries(entries);
+ ((MultiSelectListPreference) pref).setEntryValues(values);
+ }
+ }
+
+ final Preference addTestActivities = handler.findPreference("pref_developer_add_test_activities");
+ if (addTestActivities != null) {
+ addTestActivities.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(@NonNull final Preference preference) {
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ final DaoSession session = dbHandler.getDaoSession();
+ final Device device = DBHelper.getDevice(handler.getDevice(), session);
+ final User user = DBHelper.getUser(session);
+
+ //final QueryBuilder<?> qb = session.getBaseActivitySummaryDao().queryBuilder();
+ //qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(device.getId())).buildDelete().executeDeleteWithoutDetachingEntities();
+
+ final List<BaseActivitySummary> summaries = new ArrayList<>();
+
+ for (final ActivityKind activityKind : ActivityKind.values()) {
+ final BaseActivitySummary summary = new BaseActivitySummary();
+ summary.setStartTime(new Date(System.currentTimeMillis() - new Random().nextInt(31 * 24 * 60 * 60) * 1000L));
+ summary.setEndTime(new Date(summary.getStartTime().getTime() + new Random().nextInt(60 * 60 * 2) * 1000L));
+ summary.setDevice(device);
+ summary.setUser(user);
+ summary.setActivityKind(activityKind.getCode());
+ // TODO data
+ summaries.add(summary);
+ }
+
+ session.getBaseActivitySummaryDao().insertOrReplaceInTx(summaries);
+ } catch (final Exception e) {
+ GB.toast(handler.getContext(), "Error saving activity summary", Toast.LENGTH_LONG, GB.ERROR, e);
+ return false;
+ }
+
+ return true;
+ }
+ });
+ }
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ public static final Creator<TestDeviceSpecificSettingsCustomizer> CREATOR = new Creator<TestDeviceSpecificSettingsCustomizer>() {
+ @Override
+ public TestDeviceSpecificSettingsCustomizer createFromParcel(final Parcel in) {
+ return new TestDeviceSpecificSettingsCustomizer();
+ }
+
+ @Override
+ public TestDeviceSpecificSettingsCustomizer[] newArray(final int size) {
+ return new TestDeviceSpecificSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestFeature.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestFeature.java
new file mode 100644
index 0000000..5ee6d41
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/TestFeature.java
@@ -0,0 +1,77 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test;
+
+public enum TestFeature {
+ ACTIVITY_DATA_FETCHING,
+ ACTIVITY_TABS,
+ ACTIVITY_TRACKING,
+ ACTIVITY_TRACKS,
+ ALARM_DESCRIPTION,
+ ALARM_SNOOZING,
+ ALARM_TITLE_LIMIT,
+ ALARM_TITLE,
+ APP_LIST_FETCHING,
+ APP_REORDERING,
+ APPS_MANAGEMENT,
+ BATTERIES_MULTIPLE,
+ CACHED_APP_MANAGEMENT,
+ CALENDAR_EVENTS,
+ CANNED_REPLIES,
+ CONTACTS,
+ DISABLED_WORLD_CLOCKS,
+ FIND_DEVICE,
+ FLASHING,
+ FM_FREQUENCY,
+ HEART_RATE_MEASUREMENT,
+ HEART_RATE_STATS,
+ INSTALLED_APP_MANAGEMENT,
+ LED_COLOR,
+ MANUAL_HEART_RATE_MEASUREMENT,
+ MUSIC_INFO,
+ NAVIGATION,
+ NOTIFICATION_LED_PATTERNS,
+ NOTIFICATION_VIBRATION_PATTERNS,
+ NOTIFICATION_VIBRATION_REPETITION_PATTERNS,
+ PAI_TIME,
+ PAI,
+ POWER_OFF,
+ REALTIME_DATA,
+ REM_SLEEP,
+ REMINDERS,
+ RGB_LED_COLOR,
+ SCREENSHOTS,
+ SLEEP_MEASUREMENT,
+ RESPIRATORY_RATE,
+ SLEEP_RESPIRATORY_RATE,
+ SMART_WAKEUP,
+ SMART_WAKEUP_INTERVAL,
+ SMART_WAKEUP_FORCED_SLOT,
+ SPEEDZONES,
+ SPO2,
+ STEP_COUNTER,
+ STRESS_MEASUREMENT,
+ BODY_ENERGY,
+ HRV_MEASUREMENT,
+ TEMPERATURE_MEASUREMENT,
+ UNICODE_EMOJIS,
+ WATCHFACE_MANAGEMENT,
+ WEATHER,
+ WIDGETS,
+ WORLD_CLOCKS,
+ BATTERY_POLLING_SETTINGS,
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/activity/TestActivitySummaryParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/activity/TestActivitySummaryParser.java
new file mode 100644
index 0000000..a62ceb7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/activity/TestActivitySummaryParser.java
@@ -0,0 +1,27 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test.activity;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+
+public class TestActivitySummaryParser implements ActivitySummaryParser {
+ @Override
+ public BaseActivitySummary parseBinaryData(final BaseActivitySummary summary, final boolean forDetails) {
+ return summary;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/AbstractTestSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/AbstractTestSampleProvider.java
new file mode 100644
index 0000000..ae3b3f2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/AbstractTestSampleProvider.java
@@ -0,0 +1,48 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test.samples;
+
+import androidx.annotation.Nullable;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.model.TimeSample;
+
+public abstract class AbstractTestSampleProvider<S extends TimeSample> implements TimeSampleProvider<S> {
+ @Nullable
+ @Override
+ public S getLatestSample(final long until) {
+ final List<S> allSamples = getAllSamples(until - 60, until);
+ return !allSamples.isEmpty() ? allSamples.get(allSamples.size() - 1) : null;
+ }
+
+ @Override
+ public void addSample(final S timeSample) {
+ throw new UnsupportedOperationException("read-only sample provider");
+ }
+
+ @Override
+ public void addSamples(final List<S> timeSamples) {
+ throw new UnsupportedOperationException("read-only sample provider");
+ }
+
+ @Override
+ public S createSample() {
+ throw new UnsupportedOperationException("read-only sample provider");
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestBodyEnergySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestBodyEnergySampleProvider.java
new file mode 100644
index 0000000..f754dab
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestBodyEnergySampleProvider.java
@@ -0,0 +1,71 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceRand;
+import nodomain.freeyourgadget.gadgetbridge.model.BodyEnergySample;
+
+public class TestBodyEnergySampleProvider extends AbstractTestSampleProvider<BodyEnergySample> {
+ @NonNull
+ @Override
+ public List<BodyEnergySample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<BodyEnergySample> samples = new ArrayList<>();
+
+ for (long ts = timestampFrom; ts < timestampTo; ts += 15 * 60 * 1000L) {
+ samples.add(new TestBodyEnergySample(ts));
+ }
+
+ return samples;
+ }
+
+ @Nullable
+ @Override
+ public BodyEnergySample getLatestSample() {
+ final long ts = System.currentTimeMillis();
+ return new TestBodyEnergySample(ts - TestDeviceRand.randLong(ts, 10 * 1000L, 2 * 60 * 60 * 1000L));
+ }
+
+ @Nullable
+ @Override
+ public BodyEnergySample getFirstSample() {
+ return new TestBodyEnergySample(TestDeviceRand.BASE_TIMESTAMP);
+ }
+
+ protected static class TestBodyEnergySample implements BodyEnergySample {
+ private final long timestamp;
+
+ public TestBodyEnergySample(final long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public int getEnergy() {
+ return (int) Math.round(45 * Math.sin(timestamp / (86400000 / (2 * Math.PI))) + 55);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestHrvSummarySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestHrvSummarySampleProvider.java
new file mode 100644
index 0000000..c937052
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestHrvSummarySampleProvider.java
@@ -0,0 +1,115 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceRand;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
+
+public class TestHrvSummarySampleProvider extends AbstractTestSampleProvider<HrvSummarySample> {
+ @NonNull
+ @Override
+ public List<HrvSummarySample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<HrvSummarySample> samples = new ArrayList<>();
+
+ for (long ts = timestampFrom; ts < timestampTo; ts += 24 * 60 * 60 * 1000L) {
+ samples.add(new TestHrvSummarySample(ts));
+ }
+
+ return samples;
+ }
+
+ @Nullable
+ @Override
+ public HrvSummarySample getLatestSample() {
+ final long ts = System.currentTimeMillis();
+ return new TestHrvSummarySample(ts - TestDeviceRand.randLong(ts, 10 * 1000L, 2 * 60 * 60 * 1000L));
+ }
+
+ @Nullable
+ @Override
+ public HrvSummarySample getFirstSample() {
+ return new TestHrvSummarySample(TestDeviceRand.BASE_TIMESTAMP);
+ }
+
+ protected static class TestHrvSummarySample implements HrvSummarySample {
+ private final long timestamp;
+ private final int weeklyAverage;
+
+ public TestHrvSummarySample(final long timestamp) {
+ this(timestamp, TestDeviceRand.randInt(timestamp, 30, 95));
+ }
+
+ public TestHrvSummarySample(final long timestamp, final int weeklyAverage) {
+ this.timestamp = timestamp;
+ this.weeklyAverage = weeklyAverage;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public Integer getWeeklyAverage() {
+ return weeklyAverage;
+ }
+
+ @Override
+ public Integer getLastNightAverage() {
+ return TestDeviceRand.randInt(timestamp, 55, 90);
+ }
+
+ @Override
+ public Integer getLastNight5MinHigh() {
+ return TestDeviceRand.randInt(timestamp, 90, 110);
+ }
+
+ @Override
+ public Integer getBaselineLowUpper() {
+ return 50;
+ }
+
+ @Override
+ public Integer getBaselineBalancedLower() {
+ return 65;
+ }
+
+ @Override
+ public Integer getBaselineBalancedUpper() {
+ return 80;
+ }
+
+ @Override
+ public Status getStatus() {
+ if (getWeeklyAverage() > getBaselineBalancedUpper()) {
+ return Status.UNBALANCED;
+ } else if (getWeeklyAverage() < getBaselineLowUpper()) {
+ return Status.POOR;
+ } else if (getWeeklyAverage() < getBaselineBalancedLower()) {
+ return Status.LOW;
+ }
+
+ return Status.BALANCED;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestHrvValueSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestHrvValueSampleProvider.java
new file mode 100644
index 0000000..00d845b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestHrvValueSampleProvider.java
@@ -0,0 +1,82 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceRand;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
+
+public class TestHrvValueSampleProvider extends AbstractTestSampleProvider<HrvValueSample> {
+ @NonNull
+ @Override
+ public List<HrvValueSample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<HrvValueSample> samples = new ArrayList<>();
+
+ int hrv = TestDeviceRand.randInt(timestampFrom, 50, 95);
+
+ for (long ts = timestampFrom; ts < timestampTo; ts += 5 * 60 * 1000L) {
+ samples.add(new TestHrvValueSample(ts, hrv));
+ hrv += TestDeviceRand.randInt(ts, (10 - hrv) / 10, (90 - hrv) / 10);
+ }
+
+ return samples;
+ }
+
+ @Nullable
+ @Override
+ public HrvValueSample getLatestSample() {
+ final long ts = System.currentTimeMillis();
+ return new TestHrvValueSample(
+ ts - TestDeviceRand.randLong(ts, 10 * 1000L, 2 * 60 * 60 * 1000L),
+ TestDeviceRand.randInt(ts, 50, 95)
+ );
+ }
+
+ @Nullable
+ @Override
+ public HrvValueSample getFirstSample() {
+ return new TestHrvValueSample(
+ TestDeviceRand.BASE_TIMESTAMP,
+ TestDeviceRand.randInt(TestDeviceRand.BASE_TIMESTAMP, 50, 95)
+ );
+ }
+
+ protected static class TestHrvValueSample implements HrvValueSample {
+ private final long timestamp;
+ private final int hrv;
+
+ public TestHrvValueSample(final long timestamp, final int hrv) {
+ this.timestamp = timestamp;
+ this.hrv = hrv;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public int getValue() {
+ return hrv;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestPaiSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestPaiSampleProvider.java
new file mode 100644
index 0000000..de9c9b8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestPaiSampleProvider.java
@@ -0,0 +1,147 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceRand;
+import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
+
+public class TestPaiSampleProvider extends AbstractTestSampleProvider<PaiSample> {
+ @NonNull
+ @Override
+ public List<PaiSample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<PaiSample> samples = new ArrayList<>();
+
+ int paiLow = TestDeviceRand.randInt(timestampFrom, 0, 15);
+ int paiMod = TestDeviceRand.randInt(timestampFrom, 0, 15);
+ int paiHigh = TestDeviceRand.randInt(timestampFrom, 0, 15);
+ int paiTotal = paiLow + paiMod + paiHigh;
+
+ for (long ts = timestampFrom; ts < timestampTo; ts += 12 * 60 * 60 * 1000L) {
+ if (TestDeviceRand.randBool(ts, 0.75f)) {
+ samples.add(new TestPaiSample(
+ ts,
+ paiLow,
+ paiMod,
+ paiHigh,
+ paiTotal
+ ));
+ }
+ paiTotal += paiLow + paiMod + paiHigh;
+ paiLow += TestDeviceRand.randInt(ts, - paiLow, 15 - paiLow);
+ paiMod += TestDeviceRand.randInt(ts, - paiMod, 15 - paiMod);
+ paiHigh += TestDeviceRand.randInt(ts, - paiHigh, 15 - paiHigh);
+ }
+
+ return samples;
+ }
+
+ @Nullable
+ @Override
+ public PaiSample getLatestSample() {
+ final long ts = System.currentTimeMillis();
+ return new TestPaiSample(
+ ts - TestDeviceRand.randLong(ts, 10 * 1000L, 2 * 60 * 60 * 1000L),
+ TestDeviceRand.randInt(ts, 85, 99),
+ TestDeviceRand.randInt(ts, 85, 99),
+ TestDeviceRand.randInt(ts, 85, 99),
+ TestDeviceRand.randInt(ts, 85, 99)
+ );
+ }
+
+ @Nullable
+ @Override
+ public PaiSample getFirstSample() {
+ return new TestPaiSample(
+ TestDeviceRand.BASE_TIMESTAMP,
+ TestDeviceRand.randInt(TestDeviceRand.BASE_TIMESTAMP, 0, 15),
+ TestDeviceRand.randInt(TestDeviceRand.BASE_TIMESTAMP, 0, 15),
+ TestDeviceRand.randInt(TestDeviceRand.BASE_TIMESTAMP, 0, 15),
+ TestDeviceRand.randInt(TestDeviceRand.BASE_TIMESTAMP, 70, 90)
+ );
+ }
+
+ public static class TestPaiSample implements PaiSample {
+ private final long timestamp;
+ private final int paiLow;
+ private final int paiModerate;
+ private final int paiHigh;
+ private final int paiTotal;
+
+ public TestPaiSample(final long timestamp,
+ final int low,
+ final int moderate,
+ final int high,
+ final int total) {
+ this.timestamp = timestamp;
+ this.paiLow = low;
+ this.paiModerate = moderate;
+ this.paiHigh = high;
+ this.paiTotal = total;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public float getPaiLow() {
+ return paiLow;
+ }
+
+ @Override
+ public float getPaiModerate() {
+ return paiModerate;
+ }
+
+ @Override
+ public float getPaiHigh() {
+ return paiHigh;
+ }
+
+ @Override
+ public int getTimeLow() {
+ return paiLow;
+ }
+
+ @Override
+ public int getTimeModerate() {
+ return paiModerate;
+ }
+
+ @Override
+ public int getTimeHigh() {
+ return paiHigh;
+ }
+
+ @Override
+ public float getPaiToday() {
+ return paiLow + paiModerate + paiHigh;
+ }
+
+ @Override
+ public float getPaiTotal() {
+ return paiTotal;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestRespiratoryRateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestRespiratoryRateSampleProvider.java
new file mode 100644
index 0000000..dc736b1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestRespiratoryRateSampleProvider.java
@@ -0,0 +1,71 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceRand;
+import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
+
+public class TestRespiratoryRateSampleProvider extends AbstractTestSampleProvider<RespiratoryRateSample> {
+ @NonNull
+ @Override
+ public List<RespiratoryRateSample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<RespiratoryRateSample> samples = new ArrayList<>();
+
+ for (long ts = timestampFrom; ts < timestampTo; ts += 15 * 60 * 1000L) {
+ samples.add(new TestRespiratoryRateSample(ts));
+ }
+
+ return samples;
+ }
+
+ @Nullable
+ @Override
+ public RespiratoryRateSample getLatestSample() {
+ final long ts = System.currentTimeMillis();
+ return new TestRespiratoryRateSample(ts - TestDeviceRand.randLong(ts, 10 * 1000L, 2 * 60 * 60 * 1000L));
+ }
+
+ @Nullable
+ @Override
+ public RespiratoryRateSample getFirstSample() {
+ return new TestRespiratoryRateSample(TestDeviceRand.BASE_TIMESTAMP);
+ }
+
+ protected static class TestRespiratoryRateSample implements RespiratoryRateSample {
+ private final long timestamp;
+
+ public TestRespiratoryRateSample(final long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public float getRespiratoryRate() {
+ return TestDeviceRand.randFloat(timestamp, 10, 15);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestSampleProvider.java
new file mode 100644
index 0000000..7f6d188
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestSampleProvider.java
@@ -0,0 +1,262 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.ArrayUtils;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceRand;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class TestSampleProvider extends AbstractSampleProvider<TestSampleProvider.TestActivitySample> {
+ public TestSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<TestActivitySample, ?> getSampleDao() {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return null;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ //noinspection DataFlowIssue not database-backed
+ return null;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ //noinspection DataFlowIssue not database-backed
+ return null;
+ }
+
+ @Override
+ public ActivityKind normalizeType(final int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(final ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(final int rawIntensity) {
+ return rawIntensity / 100f;
+ }
+
+ @Override
+ public TestActivitySample createActivitySample() {
+ throw new UnsupportedOperationException("read-only sample provider");
+ }
+
+ @Override
+ protected List<TestActivitySample> getGBActivitySamples(final int timestamp_from, final int timestamp_to) {
+ final List<TestActivitySample> samples = new ArrayList<>();
+
+ int[] sleepStages = new int[] {
+ ActivityKind.LIGHT_SLEEP.getCode(),
+ ActivityKind.DEEP_SLEEP.getCode(),
+ };
+ if (getDevice().getDeviceCoordinator().supportsRemSleep()) {
+ sleepStages = ArrayUtils.add(sleepStages, ActivityKind.REM_SLEEP.getCode());
+ }
+ int sleepStageCurrent = 0;
+ int sleepStageDirection = 1;
+ int sleepStageDurationRemaining = TestDeviceRand.randInt(timestamp_from * 1000L, 30, 90);
+
+ boolean isActive = false;
+ float dayActivityFactor = TestDeviceRand.randFloat(timestamp_from * 1000L, 0f, 1f);
+ int steps = (int) (TestDeviceRand.randInt(timestamp_from * 1000L, 0, 100) * dayActivityFactor);
+ int intensity = TestDeviceRand.randInt(timestamp_from * 1000L, 0, 100);
+ int hr = TestDeviceRand.randInt(timestamp_from * 1000L, 90, 153);
+
+ final long bedtimeHour = TestDeviceRand.randInt(timestamp_from, 21, 22);
+ final long bedtimeMinute = TestDeviceRand.randInt(timestamp_from, 0, 59);
+ final long wakeHour = TestDeviceRand.randInt(timestamp_from, 6, 9);
+ final long wakeMinute = TestDeviceRand.randInt(timestamp_from, 0, 59);
+
+ final Calendar cal = GregorianCalendar.getInstance();
+
+ for (long ts = timestamp_from * 1000L; ts < timestamp_to * 1000L; ts += 60 * 1000L) {
+ cal.setTimeInMillis(ts);
+ final int h = cal.get(Calendar.HOUR_OF_DAY);
+ final int m = cal.get(Calendar.MINUTE);
+ boolean isSleep = false;
+ if (h == bedtimeHour) {
+ isSleep = m >= bedtimeMinute;
+ } else if (h > bedtimeHour) {
+ isSleep = true;
+ } else if (h == wakeHour) {
+ isSleep = m <= wakeMinute;
+ } else if (h < wakeHour) {
+ isSleep = true;
+ }
+ if (isSleep) {
+ isActive = false;
+ } else if (isActive) {
+ isActive = TestDeviceRand.randBool(ts, 0.8F);
+ } else {
+ isActive = TestDeviceRand.randBool(ts, 0.05F);
+ }
+
+ if (TestDeviceRand.randBool(ts, 0.85f)) {
+ samples.add(new TestActivitySample(
+ (int) (ts / 1000),
+ isSleep ? sleepStages[sleepStageCurrent] : ActivityKind.UNKNOWN.getCode(),
+ isActive ? steps : 0,
+ intensity,
+ hr
+ ));
+ }
+
+ if (isSleep) {
+ sleepStageDurationRemaining--;
+ if (sleepStageDurationRemaining <= 0) {
+ sleepStageDurationRemaining = TestDeviceRand.randInt(ts * 1000L, 30, 90);
+ sleepStageCurrent += sleepStageDirection;
+ if (sleepStageCurrent == 0 || sleepStageCurrent == sleepStages.length - 1) {
+ sleepStageDirection *= -1;
+ }
+ }
+ }
+
+ steps += (int) (TestDeviceRand.randInt(ts, -steps, 100 - steps) * dayActivityFactor);
+ intensity += TestDeviceRand.randInt(ts, -1, 1);
+ hr += TestDeviceRand.randInt(ts, -2, 2);
+ }
+
+ return samples;
+ }
+
+ public class TestActivitySample extends AbstractActivitySample {
+ private final int timestamp;
+ private final int kind;
+ private final int steps;
+ private final int intensity;
+ private final int hr;
+
+ public TestActivitySample(final int timestamp,
+ final int kind,
+ final int steps,
+ final int intensity,
+ final int hr) {
+ this.timestamp = timestamp;
+ this.kind = kind;
+ this.steps = steps;
+ this.intensity = intensity;
+ this.hr = hr;
+ }
+
+ @Override
+ public SampleProvider<?> getProvider() {
+ return TestSampleProvider.this;
+ }
+
+ @Override
+ public void setTimestamp(final int timestamp) {
+
+ }
+
+ @Override
+ public void setUserId(final long userId) {
+
+ }
+
+ @Override
+ public void setDeviceId(final long deviceId) {
+
+ }
+
+ @Override
+ public long getDeviceId() {
+ return 0;
+ }
+
+ @Override
+ public long getUserId() {
+ return 0;
+ }
+
+ @Override
+ public int getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public ActivityKind getKind() {
+ return ActivityKind.fromCode(kind);
+ }
+
+ @Override
+ public int getRawKind() {
+ return kind;
+ }
+
+ @Override
+ public float getIntensity() {
+ return intensity / 100f;
+ }
+
+ @Override
+ public int getHeartRate() {
+ return hr;
+ }
+
+ @Override
+ public int getRawIntensity() {
+ return intensity;
+ }
+
+ @Override
+ public int getSteps() {
+ return steps;
+ }
+
+ @Override
+ public int getDistanceCm() {
+ return steps * 67;
+ }
+
+ @Override
+ public int getActiveCalories() {
+ return (int) Math.round(steps * 0.04);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestSpo2SampleProvider.java
new file mode 100644
index 0000000..2e66f79
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestSpo2SampleProvider.java
@@ -0,0 +1,89 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceRand;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+
+public class TestSpo2SampleProvider extends AbstractTestSampleProvider<Spo2Sample> {
+ @NonNull
+ @Override
+ public List<Spo2Sample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<Spo2Sample> samples = new ArrayList<>();
+
+ int spo2 = TestDeviceRand.randInt(timestampFrom, 85, 99);
+
+ for (long ts = timestampFrom; ts < timestampTo; ts += 15 * 60 * 1000L) {
+ if (TestDeviceRand.randBool(ts, 0.3f)) {
+ samples.add(new TestSpo2Sample(ts, spo2));
+ }
+ spo2 += TestDeviceRand.randInt(ts, 85 - spo2, 99 - spo2);
+ }
+
+ return samples;
+ }
+
+ @Nullable
+ @Override
+ public Spo2Sample getLatestSample() {
+ final long ts = System.currentTimeMillis();
+ return new TestSpo2Sample(
+ ts - TestDeviceRand.randLong(ts, 10 * 1000L, 2 * 60 * 60 * 1000L),
+ TestDeviceRand.randInt(ts, 85, 99)
+ );
+ }
+
+ @Nullable
+ @Override
+ public Spo2Sample getFirstSample() {
+ return new TestSpo2Sample(
+ TestDeviceRand.BASE_TIMESTAMP,
+ TestDeviceRand.randInt(TestDeviceRand.BASE_TIMESTAMP, 85, 99)
+ );
+ }
+
+ protected static class TestSpo2Sample implements Spo2Sample {
+ private final long timestamp;
+ private final int spo2;
+
+ public TestSpo2Sample(final long timestamp, final int spo2) {
+ this.timestamp = timestamp;
+ this.spo2 = spo2;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public Type getType() {
+ return Type.UNKNOWN;
+ }
+
+ @Override
+ public int getSpo2() {
+ return spo2;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestStressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestStressSampleProvider.java
new file mode 100644
index 0000000..5a890c3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestStressSampleProvider.java
@@ -0,0 +1,89 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceRand;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+
+public class TestStressSampleProvider extends AbstractTestSampleProvider<StressSample> {
+ @NonNull
+ @Override
+ public List<StressSample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<StressSample> samples = new ArrayList<>();
+
+ int stress = TestDeviceRand.randInt(timestampFrom, 10, 90);
+
+ for (long ts = timestampFrom; ts < timestampTo; ts += 15 * 60 * 1000L) {
+ if (TestDeviceRand.randBool(ts, 0.3f)) {
+ samples.add(new TestStressSample(ts, stress));
+ }
+ stress += TestDeviceRand.randInt(ts, (10 - stress) / 10, (90 - stress) / 10);
+ }
+
+ return samples;
+ }
+
+ @Nullable
+ @Override
+ public StressSample getLatestSample() {
+ final long ts = System.currentTimeMillis();
+ return new TestStressSample(
+ ts - TestDeviceRand.randLong(ts, 10 * 1000L, 2 * 60 * 60 * 1000L),
+ TestDeviceRand.randInt(ts, 10, 90)
+ );
+ }
+
+ @Nullable
+ @Override
+ public StressSample getFirstSample() {
+ return new TestStressSample(
+ TestDeviceRand.BASE_TIMESTAMP,
+ TestDeviceRand.randInt(TestDeviceRand.BASE_TIMESTAMP, 10, 90)
+ );
+ }
+
+ protected static class TestStressSample implements StressSample {
+ private final long timestamp;
+ private final int stress;
+
+ public TestStressSample(final long timestamp, final int stress) {
+ this.timestamp = timestamp;
+ this.stress = stress;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public Type getType() {
+ return Type.UNKNOWN;
+ }
+
+ @Override
+ public int getStress() {
+ return stress;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestTemperatureSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestTemperatureSampleProvider.java
new file mode 100644
index 0000000..6e7005f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/test/samples/TestTemperatureSampleProvider.java
@@ -0,0 +1,87 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.test.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceRand;
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+
+public class TestTemperatureSampleProvider extends AbstractTestSampleProvider<TemperatureSample> {
+ @NonNull
+ @Override
+ public List<TemperatureSample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<TemperatureSample> samples = new ArrayList<>();
+
+ int temp = TestDeviceRand.randInt(timestampFrom, 33, 40);
+
+ for (long ts = timestampFrom; ts < timestampTo; ts += 120 * 60 * 1000L) {
+ if (TestDeviceRand.randBool(ts, 0.3f)) {
+ samples.add(new TestTemperatureSample(ts, temp));
+ }
+ temp += TestDeviceRand.randInt(ts, 33 - temp, 40 - temp);
+ break;
+ }
+
+ return samples;
+ }
+
+ @Nullable
+ @Override
+ public TemperatureSample getLatestSample() {
+ // TODO
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public TemperatureSample getFirstSample() {
+ return new TestTemperatureSample(
+ TestDeviceRand.BASE_TIMESTAMP,
+ TestDeviceRand.randFloat(TestDeviceRand.BASE_TIMESTAMP, 36f, 38f)
+ );
+ }
+
+ protected static class TestTemperatureSample implements TemperatureSample {
+ private final long timestamp;
+ private final float temperature;
+
+ public TestTemperatureSample(final long timestamp, final float temperature) {
+ this.timestamp = timestamp;
+ this.temperature = temperature;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public float getTemperature() {
+ return temperature;
+ }
+
+ @Override
+ public int getTemperatureType() {
+ return 0; // ?
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64Constants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64Constants.java
new file mode 100644
index 0000000..cf0127d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64Constants.java
@@ -0,0 +1,62 @@
+/* Copyright (C) 2020-2024 115ek
+
+ 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.devices.tlw64;
+
+import java.util.UUID;
+
+public final class TLW64Constants {
+
+ public static final UUID UUID_SERVICE_NO1 = UUID.fromString("000055ff-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_CONTROL = UUID.fromString("000033f1-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_NOTIFY = UUID.fromString("000033f2-0000-1000-8000-00805f9b34fb");
+
+ // Command bytes
+ public static final byte CMD_DISPLAY_SETTINGS = (byte) 0xa0;
+ public static final byte CMD_FIRMWARE_VERSION = (byte) 0xa1;
+ public static final byte CMD_BATTERY = (byte) 0xa2;
+ public static final byte CMD_DATETIME = (byte) 0xa3;
+ public static final byte CMD_USER_DATA = (byte) 0xa9;
+ public static final byte CMD_ALARM = (byte) 0xab;
+ public static final byte CMD_FACTORY_RESET = (byte) 0xad;
+ public static final byte CMD_FETCH_STEPS = (byte) 0xb2;
+ public static final byte CMD_FETCH_SLEEP = (byte) 0xb3;
+ public static final byte CMD_NOTIFICATION = (byte) 0xc1;
+ public static final byte CMD_ICON = (byte) 0xc3;
+ public static final byte CMD_DEVICE_SETTINGS = (byte) 0xd3;
+
+ // Notifications
+ public static final byte NOTIFICATION_HEADER = (byte) 0x01;
+ public static final byte NOTIFICATION_CALL = (byte) 0x02; // displays "call" on screen
+ public static final byte NOTIFICATION_SMS = (byte) 0x03; // displays "mms" on screen
+ public static final byte NOTIFICATION_STOP = (byte) 0x04; // to stop showing incoming call
+
+ // Icons
+ public static final byte ICON_QQ = (byte) 0x01;
+ public static final byte ICON_WECHAT = (byte) 0x02;
+ public static final byte ICON_MAIL = (byte) 0x04;
+
+ // Alarm arguments
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_SUNDAY = (byte) 0x01;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_MONDAY = (byte) 0x02;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_TUESDAY = (byte) 0x04;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_WEDNESDAY = (byte) 0x08;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_THURSDAY = (byte) 0x10;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_FRIDAY = (byte) 0x20;
+ public static final byte ARG_SET_ALARM_REMINDER_REPEAT_SATURDAY = (byte) 0x40;
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64Coordinator.java
new file mode 100644
index 0000000..13d5292
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64Coordinator.java
@@ -0,0 +1,151 @@
+/* Copyright (C) 2020-2024 115ek, Damien Gaignon, Daniel Dakhno, Jos茅
+ Rebelo, Petr Van臎k
+
+ 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.devices.tlw64;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.tlw64.TLW64Support;
+
+public class TLW64Coordinator extends AbstractBLEDeviceCoordinator {
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Smart Bee.*");
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new TLW64SampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 3;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Toleda";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_liftwrist_display_noshed,
+ R.xml.devicesettings_inactivity_noshed,
+ R.xml.devicesettings_timeformat,
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return TLW64Support.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_tlw64;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64SampleProvider.java
new file mode 100644
index 0000000..8b3de42
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/tlw64/TLW64SampleProvider.java
@@ -0,0 +1,85 @@
+/* Copyright (C) 2020-2024 115ek
+
+ 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.devices.tlw64;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.TLW64ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.TLW64ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class TLW64SampleProvider extends AbstractSampleProvider<TLW64ActivitySample> {
+
+ private GBDevice mDevice;
+ private DaoSession mSession;
+
+ public TLW64SampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+
+ mSession = session;
+ mDevice = device;
+ }
+
+ @Override
+ public AbstractDao<TLW64ActivitySample, ?> getSampleDao() {
+ return getSession().getTLW64ActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return TLW64ActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return TLW64ActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return TLW64ActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / (float) 4000.0;
+ }
+
+ @Override
+ public TLW64ActivitySample createActivitySample() {
+ return new TLW64ActivitySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/um25/Activity/DataActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/um25/Activity/DataActivity.java
new file mode 100644
index 0000000..1fe9666
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/um25/Activity/DataActivity.java
@@ -0,0 +1,151 @@
+/* Copyright (C) 2020-2024 Daniel Dakhno
+
+ 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.devices.um25.Activity;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.um25.Data.MeasurementData;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.um25.Support.UM25Support;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class DataActivity extends AbstractGBActivity {
+ private HashMap<Integer, TextView> valueViews = new HashMap<>(ValueDisplay.values().length);
+
+ private TextView chargeDurationTextView;
+
+ private enum ValueDisplay{
+ VOLTAGE("voltage", "%.3fV", R.id.um25_text_voltage, 1000),
+ CURRENT("current", "%.4fA", R.id.um25_text_current, 10000),
+ CURRENT_SUM("chargedCurrent", "%.0fmAh", R.id.um25_text_current_sum, 1),
+ WATTAGE("wattage", "%.3fW", R.id.um25_text_wattage, 1000),
+ WATTAGE_SUM("chargedWattage", "%.3fWh", R.id.um25_text_wattage_sum, 1000),
+ TEMPERATURE_CELCIUS("temperatureCelcius", "%.0f掳", R.id.um25_text_temperature, 1),
+ CABLE_RESISTANCE("cableResistance", "%.1f惟", R.id.um25_cable_resistance, 10),
+ ;
+
+ private String variableName;
+ private String formatString;
+ private int textViewResource;
+ private float divisor;
+
+ ValueDisplay(String variableName, String formatString, int textViewResource, float divisor) {
+ this.variableName = variableName;
+ this.formatString = formatString;
+ this.textViewResource = textViewResource;
+ this.divisor = divisor;
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_um25_data);
+
+ chargeDurationTextView = findViewById(R.id.um25_text_charge_duration);
+ TextView wattHoursTextView = findViewById(R.id.um25_text_wattage_sum);
+ TextView currentAccumulatedTextView = findViewById(R.id.um25_text_current_sum);
+
+ View.OnLongClickListener longClickListener = new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ GB.toast("resetting", Toast.LENGTH_SHORT, GB.INFO);
+ LocalBroadcastManager.getInstance(DataActivity.this).sendBroadcast(
+ new Intent(UM25Support.ACTION_RESET_STATS)
+ );
+ return true;
+ }
+ };
+
+ chargeDurationTextView.setOnLongClickListener(longClickListener);
+ wattHoursTextView.setOnLongClickListener(longClickListener);
+ currentAccumulatedTextView.setOnLongClickListener(longClickListener);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ LocalBroadcastManager.getInstance(this)
+ .registerReceiver(
+ measurementReceiver,
+ new IntentFilter(UM25Support.ACTION_MEASUREMENT_TAKEN)
+ );
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ LocalBroadcastManager.getInstance(this)
+ .unregisterReceiver(measurementReceiver);
+ for(TextView view : valueViews.values()){
+ view.setText("-");
+ }
+ }
+
+ private void displayMeasurementData(MeasurementData data){
+ for(ValueDisplay display : ValueDisplay.values()){
+ try {
+ TextView textView = valueViews.get(display.textViewResource);
+ if(textView == null){
+ valueViews.put(display.textViewResource, textView = findViewById(display.textViewResource));
+ }
+ Field field = data.getClass().getDeclaredField(display.variableName);
+ field.setAccessible(true);
+ float value = ((int) field.get(data)) / display.divisor;
+ String result = String.format(display.formatString, value);
+ textView.setText(result);
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ e.printStackTrace();
+ }
+ }
+
+ int durationSeconds = data.getChargingSeconds();
+ int hours = durationSeconds / 3600;
+ int minutes = durationSeconds % 3600 / 60;
+ int seconds = durationSeconds % 60;
+
+ String timeString = String.format("%02d:%02d:%02d", hours, minutes, seconds);
+
+ int thresholdCurrent = data.getThresholdCurrent();
+ int current = data.getCurrent() / 10;
+
+ chargeDurationTextView.setTextColor(current > thresholdCurrent ? 0xff669900 : 0xffcc0000);
+ chargeDurationTextView.setText(timeString);
+ }
+
+ private BroadcastReceiver measurementReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ MeasurementData data = (MeasurementData) intent.getSerializableExtra(UM25Support.EXTRA_KEY_MEASUREMENT_DATA);
+ displayMeasurementData(data);
+ }
+ };
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/um25/Coordinator/UM25Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/um25/Coordinator/UM25Coordinator.java
new file mode 100644
index 0000000..122bd05
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/um25/Coordinator/UM25Coordinator.java
@@ -0,0 +1,120 @@
+/* Copyright (C) 2020-2024 Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.um25.Coordinator;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.um25.Activity.DataActivity;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.um25.Support.UM25Support;
+
+public class UM25Coordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ return Collections.singletonList(
+ new ScanFilter.Builder()
+ .setServiceUuid(ParcelUuid.fromString(UM25Support.UUID_SERVICE))
+ .build()
+ );
+ }
+
+ @NonNull
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ if(!"UM25C".equals(candidate.getName())) return false;
+ for(ParcelUuid service : candidate.getServiceUuids()){
+ if(service.getUuid().toString().equals(UM25Support.UUID_SERVICE)) return true;
+ }
+ return false;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_um25
+ };
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Ruideng";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return DataActivity.class;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 0;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return UM25Support.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_um25;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vesc/VescControlActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vesc/VescControlActivity.java
new file mode 100644
index 0000000..197d93c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vesc/VescControlActivity.java
@@ -0,0 +1,251 @@
+/* Copyright (C) 2021-2024 Daniel Dakhno
+
+ 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.devices.vesc;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.EditText;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vesc.VescDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class VescControlActivity extends AbstractGBActivity {
+ private static final String TAG = "VescControlActivity";
+ private boolean volumeKeyPressed = false;
+ private boolean volumeKeysControl = false;
+ private int currentRPM = 0;
+ private int currentBreakCurrentMa = 0;
+ LocalBroadcastManager localBroadcastManager;
+
+ private Logger logger = LoggerFactory.getLogger(getClass());
+
+ EditText rpmEditText, breakCurrentEditText;
+
+ private final int DELAY_SAVE = 1000;
+
+ Prefs preferences;
+
+ final String PREFS_KEY_LAST_RPM = "VESC_LAST_RPM";
+ final String PREFS_KEY_LAST_BREAK_CURRENT = "VESC_LAST_BREAK_CURRENT";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_vesc_control);
+
+ localBroadcastManager = LocalBroadcastManager.getInstance(this);
+
+ preferences = GBApplication.getPrefs();
+
+ initViews();
+
+ restoreValues();
+ }
+
+ private void restoreValues(){
+ rpmEditText.setText(String.valueOf(preferences.getInt(PREFS_KEY_LAST_RPM, 0)));
+ breakCurrentEditText.setText(String.valueOf(preferences.getInt(PREFS_KEY_LAST_BREAK_CURRENT, 0) / 1000));
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ setCurrent(0);
+ }
+
+ private boolean handleKeyPress(int keyCode, boolean isPressed) {
+ if (!volumeKeysControl) {
+ return false;
+ }
+
+ if (keyCode != 24 && keyCode != 25) {
+ return false;
+ }
+
+ if (volumeKeyPressed == isPressed) {
+ return true;
+ }
+ volumeKeyPressed = isPressed;
+
+ logger.debug("volume " + (keyCode == 25 ? "down" : "up") + (isPressed ? " pressed" : " released"));
+ if (!isPressed) {
+ setCurrent(0);
+ return true;
+ }
+ if (keyCode == 24) {
+ setRPM(currentRPM);
+ } else {
+ setBreakCurrent(VescControlActivity.this.currentBreakCurrentMa);
+ }
+
+ return true;
+ }
+
+ Runnable rpmSaveRunnable = new Runnable() {
+ @Override
+ public void run() {
+ preferences.getPreferences().edit().putInt(PREFS_KEY_LAST_RPM, currentRPM).apply();
+ }
+ };
+
+ Runnable breakCurrentSaveRunnable = new Runnable() {
+ @Override
+ public void run() {
+ preferences.getPreferences().edit().putInt(PREFS_KEY_LAST_BREAK_CURRENT, currentBreakCurrentMa).apply();
+ }
+ };
+
+ private void initViews() {
+ ((CheckBox) findViewById(R.id.vesc_control_checkbox_volume_keys))
+ .setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ VescControlActivity.this.volumeKeysControl = isChecked;
+ if (!isChecked) {
+ setRPM(0);
+ }
+ }
+ });
+
+ rpmEditText = ((EditText) findViewById(R.id.vesc_control_input_rpm));
+ rpmEditText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ rpmEditText.removeCallbacks(rpmSaveRunnable);
+ rpmEditText.postDelayed(rpmSaveRunnable, DELAY_SAVE);
+
+ String text = s.toString();
+ if (text.isEmpty()) {
+ currentRPM = 0;
+ return;
+ }
+ VescControlActivity.this.currentRPM = Integer.parseInt(text);
+ }
+ });
+
+ breakCurrentEditText = ((EditText) findViewById(R.id.vesc_control_input_break_current));
+ breakCurrentEditText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ breakCurrentEditText.removeCallbacks(breakCurrentSaveRunnable);
+ breakCurrentEditText.postDelayed(breakCurrentSaveRunnable, DELAY_SAVE);
+
+ String text = s.toString();
+ if (text.isEmpty()) {
+ currentBreakCurrentMa = 0;
+ return;
+ }
+ try {
+ VescControlActivity.this.currentBreakCurrentMa = Integer.parseInt(text) * 1000;
+ }catch (NumberFormatException e){
+ VescControlActivity.this.currentBreakCurrentMa = 0;
+ }
+ }
+ });
+
+ View.OnTouchListener controlTouchListener = new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ if (v.getId() == R.id.vesc_control_button_fwd) {
+ setRPM(VescControlActivity.this.currentRPM);
+ } else {
+ setBreakCurrent(VescControlActivity.this.currentBreakCurrentMa);
+ }
+ } else if (event.getAction() == MotionEvent.ACTION_UP) {
+ setCurrent(0);
+ } else {
+ return false;
+ }
+ return true;
+ }
+ };
+
+ findViewById(R.id.vesc_control_button_fwd).setOnTouchListener(controlTouchListener);
+ findViewById(R.id.vesc_control_button_break).setOnTouchListener(controlTouchListener);
+ }
+
+ private void setBreakCurrent(int breakCurrentMa) {
+ logger.debug("setting break current to {}", breakCurrentMa);
+ Intent intent = new Intent(VescDeviceSupport.COMMAND_SET_BREAK_CURRENT);
+ intent.putExtra(VescDeviceSupport.EXTRA_CURRENT, breakCurrentMa);
+ sendLocalBroadcast(intent);
+ }
+
+ private void setCurrent(int currentMa) {
+ logger.debug("setting current to {}", currentMa);
+ Intent intent = new Intent(VescDeviceSupport.COMMAND_SET_CURRENT);
+ intent.putExtra(VescDeviceSupport.EXTRA_CURRENT, currentMa);
+ sendLocalBroadcast(intent);
+ }
+
+ private void setRPM(int rpm) {
+ logger.debug("setting rpm to {}", rpm);
+ Intent intent = new Intent(VescDeviceSupport.COMMAND_SET_RPM);
+ intent.putExtra(VescDeviceSupport.EXTRA_RPM, rpm);
+ sendLocalBroadcast(intent);
+ }
+
+ private void sendLocalBroadcast(Intent intent) {
+ localBroadcastManager.sendBroadcast(intent);
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
+ return handleKeyPress(keyCode, false);
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ return handleKeyPress(keyCode, true);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vesc/VescCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vesc/VescCoordinator.java
new file mode 100644
index 0000000..12d0ba1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vesc/VescCoordinator.java
@@ -0,0 +1,184 @@
+/* Copyright (C) 2021-2024 Damien Gaignon, Daniel Dakhno, Jos茅 Rebelo,
+ Petr Van臎k
+
+ 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.devices.vesc;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vesc.VescDeviceSupport;
+
+public class VescCoordinator extends AbstractBLEDeviceCoordinator {
+ public final static String UUID_SERVICE_SERIAL_HM10 = "0000ffe0-0000-1000-8000-00805f9b34fb";
+ public final static String UUID_CHARACTERISTIC_SERIAL_TX_HM10 = "0000ffe1-0000-1000-8000-00805f9b34fb";
+ public final static String UUID_CHARACTERISTIC_SERIAL_RX_HM10 = "0000ffe1-0000-1000-8000-00805f9b34fb";
+
+ public final static String UUID_SERVICE_SERIAL_NRF = "0000ffe0-0000-1000-8000-00805f9b34fb";
+ public final static String UUID_CHARACTERISTIC_SERIAL_TX_NRF = "0000ffe0-0000-1000-8000-00805f9b34fb";
+ public final static String UUID_CHARACTERISTIC_SERIAL_RX_NRF = "0000ffe1-0000-1000-8000-00805f9b34fb";
+
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_vesc
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return VescDeviceSupport.class;
+ }
+
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ if(!candidate.getName().toLowerCase().contains("vesc")){
+ return false;
+ }
+ ParcelUuid[] uuids = candidate.getServiceUuids();
+ for(ParcelUuid uuid : uuids){
+ if(uuid.getUuid().toString().equals(UUID_SERVICE_SERIAL_NRF)){
+ return true;
+ }else if(uuid.getUuid().toString().equals(UUID_SERVICE_SERIAL_HM10)){
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Benjamin Vedder";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return VescControlActivity.class;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_vesc;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_vesc;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_vesc_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vibratissimo/VibratissimoCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vibratissimo/VibratissimoCoordinator.java
new file mode 100644
index 0000000..08bf613
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vibratissimo/VibratissimoCoordinator.java
@@ -0,0 +1,156 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien
+ Gaignon, Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.vibratissimo;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.util.EnumSet;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.vibratissimo.VibratissimoSupport;
+
+public class VibratissimoCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Vibratissimo.*");
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Amor AG";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false; // hmmm well, it has a temperature sensor :D
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return VibratissimoSupport.class;
+ }
+
+ @Override
+ public EnumSet<ServiceDeviceSupport.Flags> getInitialFlags() {
+ return EnumSet.of(ServiceDeviceSupport.Flags.THROTTLING, ServiceDeviceSupport.Flags.BUSY_CHECKING);
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ // nothing to delete, yet
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_vibratissimo;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_lovetoy;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_lovetoy_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/GarminCapability.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/GarminCapability.java
new file mode 100644
index 0000000..25663c6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/GarminCapability.java
@@ -0,0 +1,183 @@
+/* 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.devices.vivomovehr;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+public enum GarminCapability {
+ CONNECT_MOBILE_FIT_LINK,
+ GOLF_FIT_LINK,
+ VIVOKID_JR_FIT_LINK,
+ SYNC,
+ DEVICE_INITIATES_SYNC,
+ HOST_INITIATED_SYNC_REQUESTS,
+ GNCS,
+ ADVANCED_MUSIC_CONTROLS,
+ FIND_MY_PHONE,
+ FIND_MY_WATCH,
+ CONNECTIQ_HTTP,
+ CONNECTIQ_SETTINGS,
+ CONNECTIQ_WATCH_APP_DOWNLOAD,
+ CONNECTIQ_WIDGET_DOWNLOAD,
+ CONNECTIQ_WATCH_FACE_DOWNLOAD,
+ CONNECTIQ_DATA_FIELD_DOWNLOAD,
+ CONNECTIQ_APP_MANAGEMENT,
+ COURSE_DOWNLOAD,
+ WORKOUT_DOWNLOAD,
+ GOLF_COURSE_DOWNLOAD,
+ DELTA_SOFTWARE_UPDATE_FILES,
+ FITPAY,
+ LIVETRACK,
+ LIVETRACK_AUTO_START,
+ LIVETRACK_MESSAGING,
+ GROUP_LIVETRACK,
+ WEATHER_CONDITIONS,
+ WEATHER_ALERTS,
+ GPS_EPHEMERIS_DOWNLOAD,
+ EXPLICIT_ARCHIVE,
+ SWING_SENSOR,
+ SWING_SENSOR_REMOTE,
+ INCIDENT_DETECTION,
+ TRUEUP,
+ INSTANT_INPUT,
+ SEGMENTS,
+ AUDIO_PROMPT_LAP,
+ AUDIO_PROMPT_PACE_SPEED,
+ AUDIO_PROMPT_HEART_RATE,
+ AUDIO_PROMPT_POWER,
+ AUDIO_PROMPT_NAVIGATION,
+ AUDIO_PROMPT_CADENCE,
+ SPORT_GENERIC,
+ SPORT_RUNNING,
+ SPORT_CYCLING,
+ SPORT_TRANSITION,
+ SPORT_FITNESS_EQUIPMENT,
+ SPORT_SWIMMING,
+ STOP_SYNC_AFTER_SOFTWARE_UPDATE,
+ CALENDAR,
+ WIFI_SETUP,
+ SMS_NOTIFICATIONS,
+ BASIC_MUSIC_CONTROLS,
+ AUDIO_PROMPTS_SPEECH,
+ DELTA_SOFTWARE_UPDATES,
+ GARMIN_DEVICE_INFO_FILE_TYPE,
+ SPORT_PROFILE_SETUP,
+ HSA_SUPPORT,
+ SPORT_STRENGTH,
+ SPORT_CARDIO,
+ UNION_PAY,
+ IPASS,
+ CIQ_AUDIO_CONTENT_PROVIDER,
+ UNION_PAY_INTERNATIONAL,
+ REQUEST_PAIR_FLOW,
+ LOCATION_UPDATE,
+ LTE_SUPPORT,
+ DEVICE_DRIVEN_LIVETRACK_SUPPORT,
+ CUSTOM_CANNED_TEXT_LIST_SUPPORT,
+ EXPLORE_SYNC,
+ INCIDENT_DETECT_AND_ASSISTANCE,
+ CURRENT_TIME_REQUEST_SUPPORT,
+ CONTACTS_SUPPORT,
+ LAUNCH_REMOTE_CIQ_APP_SUPPORT,
+ DEVICE_MESSAGES,
+ WAYPOINT_TRANSFER,
+ MULTI_LINK_SERVICE,
+ OAUTH_CREDENTIALS,
+ GOLF_9_PLUS_9,
+ ANTI_THEFT_ALARM,
+ INREACH,
+ EVENT_SHARING,
+ UNK_82,
+ UNK_83,
+ UNK_84,
+ UNK_85,
+ UNK_86,
+ UNK_87,
+ UNK_88,
+ UNK_89,
+ UNK_90,
+ UNK_91,
+ REALTIME_SETTINGS,
+ UNK_93,
+ UNK_94,
+ UNK_95,
+ UNK_96,
+ UNK_97,
+ UNK_98,
+ UNK_99,
+ UNK_100,
+ UNK_101,
+ UNK_102,
+ UNK_103,
+ ;
+
+ public static final Set<GarminCapability> ALL_CAPABILITIES = new HashSet<>(values().length);
+ private static final Map<Integer, GarminCapability> FROM_ORDINAL = new HashMap<>(values().length);
+
+ static {
+ for (final GarminCapability cap : values()) {
+ FROM_ORDINAL.put(cap.ordinal(), cap);
+ ALL_CAPABILITIES.add(cap);
+ }
+ }
+
+ public static Set<GarminCapability> setFromBinary(final byte[] bytes) {
+ final Set<GarminCapability> result = new HashSet<>(GarminCapability.values().length);
+ int current = 0;
+ for (int b : bytes) {
+ for (int i = 0; i < 8; i++) {
+ if ((b & (1 << i)) != 0) {
+ if (FROM_ORDINAL.containsKey(current)) {
+ result.add(FROM_ORDINAL.get(current));
+ }
+ }
+ ++current;
+ }
+ }
+ return result;
+ }
+
+ public static byte[] setToBinary(final Set<GarminCapability> capabilities) {
+ final GarminCapability[] values = values();
+ final byte[] result = new byte[(values.length + 7) / 8];
+ int bytePos = 0;
+ int bitPos = 0;
+ for (int i = 0; i < values.length; ++i) {
+ if (capabilities.contains(FROM_ORDINAL.get(i))) {
+ result[bytePos] |= (1 << bitPos);
+ }
+ ++bitPos;
+ if (bitPos >= 8) {
+ bitPos = 0;
+ ++bytePos;
+ }
+ }
+ return result;
+ }
+
+ public static String setToString(final Set<GarminCapability> capabilities) {
+ final StringBuilder result = new StringBuilder();
+ for (final GarminCapability cap : capabilities) {
+ if (result.length() > 0) result.append(", ");
+ result.append(cap.name());
+ }
+ return result.toString();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrSampleProvider.java
new file mode 100644
index 0000000..d6b29cf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrSampleProvider.java
@@ -0,0 +1,235 @@
+/* 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.devices.vivomovehr;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.VivomoveHrActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.VivomoveHrActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class VivomoveHrSampleProvider extends AbstractSampleProvider<VivomoveHrActivitySample> {
+ public static final int RAW_TYPE_KIND_MASK = 0x0F000000;
+ public static final int RAW_TYPE_KIND_ACTIVITY = 0x00000000;
+ public static final int RAW_TYPE_KIND_SLEEP = 0x01000000;
+ // public static final int RAW_TYPE_KIND_NOT_WORN = 0x0F000000;
+ public static final int RAW_NOT_WORN = 0x0F000000;
+
+ public VivomoveHrSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ if (rawType == RAW_NOT_WORN) {
+ return ActivityKind.NOT_WORN;
+ }
+
+ switch (rawType & RAW_TYPE_KIND_MASK) {
+ case RAW_TYPE_KIND_ACTIVITY:
+ return normalizeActivityType(rawType & ~RAW_TYPE_KIND_MASK);
+ case RAW_TYPE_KIND_SLEEP:
+ return normalizeSleepType(rawType & ~RAW_TYPE_KIND_MASK);
+ default:
+ // ???
+ return ActivityKind.UNKNOWN;
+ }
+ }
+
+ private static ActivityKind normalizeActivityType(int rawType) {
+ switch (rawType) {
+ case 0:
+ // generic
+ return ActivityKind.ACTIVITY;
+ case 1:
+ // running
+ return ActivityKind.RUNNING;
+ case 2:
+ // cycling
+ return ActivityKind.CYCLING;
+ case 3:
+ // transition
+ return ActivityKind.VIVOMOVE_HR_TRANSITION;
+ case 4:
+ // fitness_equipment
+ return ActivityKind.EXERCISE;
+ case 5:
+ // swimming
+ return ActivityKind.SWIMMING;
+ case 6:
+ // walking
+ return ActivityKind.WALKING;
+ case 8:
+ // sedentary
+ // TODO?
+ return ActivityKind.ACTIVITY;
+
+ default:
+ return ActivityKind.UNKNOWN;
+ }
+ }
+
+ private static ActivityKind normalizeSleepType(int rawType) {
+ switch (rawType) {
+ case 0:
+ // deep_sleep
+ return ActivityKind.DEEP_SLEEP;
+ case 1:
+ // light_sleep
+ return ActivityKind.LIGHT_SLEEP;
+ case 2:
+ // awake
+ case 3:
+ // more_awake
+ return ActivityKind.ACTIVITY;
+ default:
+ // ?
+ return ActivityKind.UNKNOWN;
+ }
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ switch (activityKind) {
+ case NOT_WORN:
+ return RAW_NOT_WORN;
+
+ case ACTIVITY:
+ // generic
+ //noinspection PointlessBitwiseExpression
+ return RAW_TYPE_KIND_ACTIVITY | 0;
+ case RUNNING:
+ // running
+ return RAW_TYPE_KIND_ACTIVITY | 1;
+ case CYCLING:
+ // cycling
+ return RAW_TYPE_KIND_ACTIVITY | 2;
+ case VIVOMOVE_HR_TRANSITION:
+ return RAW_TYPE_KIND_ACTIVITY | 3;
+ case EXERCISE:
+ // fitness_equipment
+ return RAW_TYPE_KIND_ACTIVITY | 4;
+ case SWIMMING:
+ // swimming
+ return RAW_TYPE_KIND_ACTIVITY | 5;
+ case WALKING:
+ // walking
+ return RAW_TYPE_KIND_ACTIVITY | 6;
+ case LIGHT_SLEEP:
+ return RAW_TYPE_KIND_SLEEP | 1;
+ case DEEP_SLEEP:
+ //noinspection PointlessBitwiseExpression
+ return RAW_TYPE_KIND_SLEEP | 0;
+ default:
+ return 0;
+ }
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / 255.0f;
+ }
+
+ @Override
+ public VivomoveHrActivitySample createActivitySample() {
+ return new VivomoveHrActivitySample();
+ }
+
+ @Override
+ public AbstractDao<VivomoveHrActivitySample, ?> getSampleDao() {
+ return getSession().getVivomoveHrActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return VivomoveHrActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return VivomoveHrActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return VivomoveHrActivitySampleDao.Properties.DeviceId;
+ }
+
+ public static String rawKindToString(int rawType) {
+ if (rawType == RAW_NOT_WORN) {
+ return "not worn";
+ }
+
+ switch (rawType & RAW_TYPE_KIND_MASK) {
+ case RAW_TYPE_KIND_ACTIVITY:
+ return activityTypeToString(rawType & ~RAW_TYPE_KIND_MASK);
+ case RAW_TYPE_KIND_SLEEP:
+ return sleepTypeToString(rawType & ~RAW_TYPE_KIND_MASK);
+ default:
+ // ???
+ return "unknown " + rawType;
+ }
+ }
+
+ private static String activityTypeToString(int rawType) {
+ switch (rawType) {
+ case 0:
+ return "generic";
+ case 1:
+ return "running";
+ case 2:
+ return "cycling";
+ case 3:
+ return "transition";
+ case 4:
+ return "fitness equipment";
+ case 5:
+ return "swimming";
+ case 6:
+ return "walking";
+ case 8:
+ return "sedentary";
+ default:
+ return "unknown activity " + rawType;
+ }
+ }
+
+ private static String sleepTypeToString(int rawType) {
+ switch (rawType) {
+ case 0:
+ return "deep sleep";
+ case 1:
+ return "light sleep";
+ case 2:
+ // awake
+ return "awake";
+ case 3:
+ // more_awake
+ return "more awake";
+ default:
+ return "unknown sleep " + rawType;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/waspos/WaspOSConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/waspos/WaspOSConstants.java
new file mode 100644
index 0000000..8ea8901
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/waspos/WaspOSConstants.java
@@ -0,0 +1,28 @@
+/* Copyright (C) 2020-2024 Daniel Thompson
+
+ 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.devices.waspos;
+
+import java.util.UUID;
+
+public final class WaspOSConstants {
+
+
+ public static final UUID UUID_SERVICE_NORDIC_UART = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID UUID_CHARACTERISTIC_NORDIC_UART_TX = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID UUID_CHARACTERISTIC_NORDIC_UART_RX = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/waspos/WaspOSCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/waspos/WaspOSCoordinator.java
new file mode 100644
index 0000000..eedff2a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/waspos/WaspOSCoordinator.java
@@ -0,0 +1,173 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Daniel Thompson, Jos茅 Rebelo, Petr Van臎k
+
+ 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.devices.waspos;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.waspos.WaspOSDeviceSupport;
+
+public class WaspOSCoordinator extends AbstractBLEDeviceCoordinator {
+
+ @Override
+ public String getManufacturer() {
+ return "MicroPython";
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ // TODO: filter on name beginning Bangle.js? Doesn't appear to be built-in :(
+ // https://developer.android.com/reference/android/bluetooth/le/ScanFilter.Builder.html#setDeviceName(java.lang.String)
+ ParcelUuid hpService = new ParcelUuid(WaspOSConstants.UUID_SERVICE_NORDIC_UART);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(hpService).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("DS-D6.*|K9.*|PineTime.*|P8.*");
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+ }
+
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;//new WaspOSSampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return WaspOSDeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_waspos;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_pebble;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_pebble_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9CalibrationActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9CalibrationActivity.java
new file mode 100644
index 0000000..c869486
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9CalibrationActivity.java
@@ -0,0 +1,123 @@
+/* Copyright (C) 2018-2024 Daniele Gobbetti, maxirnilian
+
+ 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.devices.watch9;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.View;
+import android.widget.Button;
+import android.widget.NumberPicker;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class Watch9CalibrationActivity extends AbstractGBActivity {
+
+ private static final String STATE_DEVICE = "stateDevice";
+ GBDevice device;
+
+ NumberPicker pickerHour, pickerMinute, pickerSecond;
+
+ Handler handler;
+ Runnable holdCalibration;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_watch9_calibration);
+
+ pickerHour = findViewById(R.id.np_hour);
+ pickerMinute = findViewById(R.id.np_minute);
+ pickerSecond = findViewById(R.id.np_second);
+
+ pickerHour.setMinValue(1);
+ pickerHour.setMaxValue(12);
+ pickerHour.setValue(12);
+ pickerMinute.setMinValue(0);
+ pickerMinute.setMaxValue(59);
+ pickerMinute.setValue(0);
+ pickerSecond.setMinValue(0);
+ pickerSecond.setMaxValue(59);
+ pickerSecond.setValue(0);
+
+ handler = new Handler();
+ holdCalibration = new Runnable() {
+ @Override
+ public void run() {
+ LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(new Intent(Watch9Constants.ACTION_CALIBRATION_HOLD));
+ handler.postDelayed(this, 10000);
+ }
+ };
+
+ Intent intent = getIntent();
+ device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (device == null && savedInstanceState != null) {
+ device = savedInstanceState.getParcelable(STATE_DEVICE);
+ }
+ if (device == null) {
+ finish();
+ }
+
+ final Button btCalibrate = findViewById(R.id.watch9_bt_calibrate);
+ btCalibrate.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ btCalibrate.setEnabled(false);
+ handler.removeCallbacks(holdCalibration);
+ Intent calibrationData = new Intent(Watch9Constants.ACTION_CALIBRATION_SEND);
+ calibrationData.putExtra(Watch9Constants.VALUE_CALIBRATION_HOUR, pickerHour.getValue());
+ calibrationData.putExtra(Watch9Constants.VALUE_CALIBRATION_MINUTE, pickerMinute.getValue());
+ calibrationData.putExtra(Watch9Constants.VALUE_CALIBRATION_SECOND, pickerSecond.getValue());
+ LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(calibrationData);
+ finish();
+ }
+ });
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelable(STATE_DEVICE, device);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ device = savedInstanceState.getParcelable(STATE_DEVICE);
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ Intent calibration = new Intent(Watch9Constants.ACTION_CALIBRATION);
+ calibration.putExtra(Watch9Constants.ACTION_ENABLE, true);
+ LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(calibration);
+ handler.postDelayed(holdCalibration, 1000);
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ Intent calibration = new Intent(Watch9Constants.ACTION_CALIBRATION);
+ calibration.putExtra(Watch9Constants.ACTION_ENABLE, false);
+ LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(calibration);
+ handler.removeCallbacks(holdCalibration);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9Constants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9Constants.java
new file mode 100644
index 0000000..27426f9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9Constants.java
@@ -0,0 +1,92 @@
+/* Copyright (C) 2018-2024 maxirnilian
+
+ 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.devices.watch9;
+
+import java.util.UUID;
+
+public final class Watch9Constants {
+ public static final UUID UUID_SERVICE_WATCH9 = UUID.fromString("0000a800-0000-1000-8000-00805f9b34fb");
+
+ public static final UUID UUID_UNKNOWN_DESCRIPTOR = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
+
+ public static final UUID UUID_CHARACTERISTIC_WRITE = UUID.fromString("0000a801-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_UNKNOWN_2 = UUID.fromString("0000a802-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_UNKNOWN_3 = UUID.fromString("0000a803-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_UNKNOWN_4 = UUID.fromString("0000a804-0000-1000-8000-00805f9b34fb");
+
+ public static final int NOTIFICATION_CHANNEL_DEFAULT = 128;
+ public static final int NOTIFICATION_CHANNEL_PHONE_CALL = 1024;
+
+ public static final byte RESPONSE = 0x13;
+ public static final byte REQUEST = 0x31;
+
+ public static final byte WRITE_VALUE = 0x01;
+ public static final byte READ_VALUE = 0x02;
+ public static final byte TASK = 0x04;
+ public static final byte KEEP_ALIVE = -0x80;
+
+ public static final byte[] CMD_HEADER = new byte[]{0x23, 0x01, 0x00, 0x00, 0x00};
+
+ // byte[] COMMAND = new byte[]{0x23, 0x01, 0x00, 0x31, 0x00, ... , 0x00}
+ // | | | | | | 鈹� Checksum
+ // | | | | | 鈹� Command + value
+ // | | | | 鈹� Sequence number
+ // | | | 鈹� Response/Request indicator
+ // | | 鈹� Value length
+ // | |
+ // 鈹�-----鈹� Header
+
+ public static final byte[] CMD_FIRMWARE_INFO = new byte[]{0x01, 0x02};
+ public static final byte[] CMD_AUTHORIZATION_TASK = new byte[]{0x01, 0x05};
+ public static final byte[] CMD_TIME_SETTINGS = new byte[]{0x01, 0x08};
+ public static final byte[] CMD_ALARM_SETTINGS = new byte[]{0x01, 0x0A};
+ public static final byte[] CMD_BATTERY_INFO = new byte[]{0x01, 0x14};
+
+ public static final byte[] CMD_NOTIFICATION_TASK = new byte[]{0x03, 0x01};
+ public static final byte[] CMD_NOTIFICATION_SETTINGS = new byte[]{0x03, 0x02};
+ public static final byte[] CMD_CALIBRATION_INIT_TASK = new byte[]{0x03, 0x31};
+ public static final byte[] CMD_CALIBRATION_TASK = new byte[]{0x03, 0x33, 0x01};
+ public static final byte[] CMD_CALIBRATION_KEEP_ALIVE = new byte[]{0x03, 0x34};
+ public static final byte[] CMD_DO_NOT_DISTURB_SETTINGS = new byte[]{0x03, 0x61};
+
+ public static final byte[] CMD_FITNESS_GOAL_SETTINGS = new byte[]{0x10, 0x02};
+
+ public static final byte[] RESP_AUTHORIZATION_TASK = new byte[]{0x01, 0x01, 0x05};
+ public static final byte[] RESP_BUTTON_INDICATOR = new byte[]{0x04, 0x03, 0x11};
+ public static final byte[] RESP_ALARM_INDICATOR = new byte[]{-0x80, 0x01, 0x0A};
+
+ public static final byte[] RESP_FIRMWARE_INFO = new byte[]{0x08, 0x01, 0x02};
+ public static final byte[] RESP_TIME_SETTINGS = new byte[]{0x08, 0x01, 0x08};
+ public static final byte[] RESP_BATTERY_INFO = new byte[]{0x08, 0x01, 0x14};
+ public static final byte[] RESP_NOTIFICATION_SETTINGS = new byte[]{0x08, 0x03, 0x02};
+
+ public static final String ACTION_ENABLE = "action.watch9.enable";
+
+ public static final String ACTION_CALIBRATION
+ = "nodomain.freeyourgadget.gadgetbridge.devices.action.watch9.start_calibration";
+ public static final String ACTION_CALIBRATION_SEND
+ = "nodomain.freeyourgadget.gadgetbridge.devices.action.watch9.send_calibration";
+ public static final String ACTION_CALIBRATION_HOLD
+ = "nodomain.freeyourgadget.gadgetbridge.devices.action.watch9.keep_calibrating";
+ public static final String VALUE_CALIBRATION_HOUR
+ = "value.watch9.calibration_hour";
+ public static final String VALUE_CALIBRATION_MINUTE
+ = "value.watch9.calibration_minute";
+ public static final String VALUE_CALIBRATION_SECOND
+ = "value.watch9.calibration_second";
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9DeviceCoordinator.java
new file mode 100644
index 0000000..eaa4202
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9DeviceCoordinator.java
@@ -0,0 +1,181 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Daniele Gobbetti, Jos茅 Rebelo, mamucho, maxirnilian, Petr Van臎k
+
+ 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.devices.watch9;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.watch9.Watch9DeviceSupport;
+
+public class Watch9DeviceCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ ParcelUuid watch9Service = new ParcelUuid(Watch9Constants.UUID_SERVICE_WATCH9);
+ ScanFilter filter = new ScanFilter.Builder().setServiceUuid(watch9Service).build();
+ return Collections.singletonList(filter);
+ }
+
+ @NonNull
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ String macAddress = candidate.getMacAddress().toUpperCase();
+ String deviceName = candidate.getName().toUpperCase();
+ if (candidate.supportsService(Watch9Constants.UUID_SERVICE_WATCH9)) {
+ return true;
+ // add support for Watch X non-plus (same MAC address)
+ // add support for Watch X Plus (same MAC address)
+ } else if ((macAddress.startsWith("1C:87:79")) && ((!deviceName.equalsIgnoreCase("WATCH X")) && (!deviceName.equalsIgnoreCase("WATCH XPLUS")))) {
+ return true;
+ } else if (deviceName.equals("WATCH 9")) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return Watch9PairingActivity.class;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return false;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return null;
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 3; // FIXME - check the real value
+ }
+
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Lenovo";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return Watch9DeviceSupport.class;
+ }
+
+ @Override
+ public EnumSet<ServiceDeviceSupport.Flags> getInitialFlags() {
+ return EnumSet.of(ServiceDeviceSupport.Flags.THROTTLING, ServiceDeviceSupport.Flags.BUSY_CHECKING);
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_watch9;
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getCalibrationActivity() {
+ return Watch9CalibrationActivity.class;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9PairingActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9PairingActivity.java
new file mode 100644
index 0000000..afa7e7d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/watch9/Watch9PairingActivity.java
@@ -0,0 +1,166 @@
+/* Copyright (C) 2018-2024 Andreas B枚hler, Daniele Gobbetti, Jos茅 Rebelo,
+ maxirnilian, Taavi Eom盲e
+
+ 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.devices.watch9;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.BondingUtil.STATE_DEVICE_CANDIDATE;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2;
+import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.BondingInterface;
+import nodomain.freeyourgadget.gadgetbridge.util.BondingUtil;
+
+public class Watch9PairingActivity extends AbstractGBActivity implements BondingInterface {
+ private static final Logger LOG = LoggerFactory.getLogger(Watch9PairingActivity.class);
+
+ private final BroadcastReceiver pairingReceiver = BondingUtil.getPairingReceiver(this);
+ private TextView message;
+ private GBDeviceCandidate deviceCandidate;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_watch9_pairing);
+
+ message = findViewById(R.id.watch9_pair_message);
+
+ deviceCandidate = getIntent().getParcelableExtra(DeviceCoordinator.EXTRA_DEVICE_CANDIDATE);
+ if (deviceCandidate == null && savedInstanceState != null) {
+ deviceCandidate = savedInstanceState.getParcelable(STATE_DEVICE_CANDIDATE);
+ }
+
+ if (deviceCandidate == null) {
+ Toast.makeText(this, getString(R.string.message_cannot_pair_no_mac), Toast.LENGTH_SHORT).show();
+ startActivity(new Intent(this, DiscoveryActivityV2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
+ finish();
+ return;
+ }
+
+ startConnecting(deviceCandidate);
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelable(STATE_DEVICE_CANDIDATE, deviceCandidate);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ deviceCandidate = savedInstanceState.getParcelable(STATE_DEVICE_CANDIDATE);
+ }
+
+ private void startConnecting(GBDeviceCandidate deviceCandidate) {
+ message.setText(getString(R.string.pairing, deviceCandidate));
+
+ registerBroadcastReceivers();
+ BondingUtil.connectThenComplete(this, deviceCandidate);
+ }
+
+ @Override
+ public void onBondingComplete(boolean success) {
+ startActivity(new Intent(this, ControlCenterv2.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
+ finish();
+ }
+
+ @Override
+ public GBDeviceCandidate getCurrentTarget() {
+ return this.deviceCandidate;
+ }
+
+ @Override
+ public String getMacAddress() {
+ return deviceCandidate.getDevice().getAddress();
+ }
+
+ @Override
+ public boolean getAttemptToConnect() {
+ return true;
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ BondingUtil.handleActivityResult(this, requestCode, resultCode, data);
+ }
+
+ @Override
+ public void unregisterBroadcastReceivers() {
+ AndroidUtils.safeUnregisterBroadcastReceiver(LocalBroadcastManager.getInstance(this), pairingReceiver);
+ }
+
+ @Override
+ public void registerBroadcastReceivers() {
+ LocalBroadcastManager.getInstance(this).registerReceiver(pairingReceiver, new IntentFilter(GBDevice.ACTION_DEVICE_CHANGED));
+ }
+
+ @Override
+ public Context getContext() {
+ return this;
+ }
+
+ @Override
+ protected void onResume() {
+ registerBroadcastReceivers();
+ super.onResume();
+ }
+
+ @Override
+ protected void onStart() {
+ registerBroadcastReceivers();
+ super.onStart();
+ }
+
+ @Override
+ protected void onDestroy() {
+ unregisterBroadcastReceivers();
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onStop() {
+ unregisterBroadcastReceivers();
+ super.onStop();
+ }
+
+ @Override
+ protected void onPause() {
+ // WARN: Do not remove listeners on pause
+ // Bonding process can pause the activity and you might miss broadcasts
+ super.onPause();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/RotaryControl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/RotaryControl.java
new file mode 100644
index 0000000..0a42d3a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/RotaryControl.java
@@ -0,0 +1,201 @@
+/* Copyright (C) 2023-2024 Frank Ertl
+
+ 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.devices.withingssteelhr;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.DashPathEffect;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class RotaryControl extends View {
+
+ public interface RotationListener {
+ void onRotation(short movementAmount);
+ }
+
+ private Path circlePath;
+
+ private int controlPointX;
+ private int controlPointY;
+
+ private int controlCenterX;
+ private int controlCenterY;
+ private int controlRadius;
+
+ private int padding;
+ private int controlPointSize;
+ private int controlPointColor;
+ private int lineColor;
+ private int lineThickness;
+ private double startAngle;
+ private double angle ;
+ private boolean isControlPointSelected = false;
+ private Paint paint = new Paint();
+ private Paint controlPointPaint = new Paint();
+ private RotationListener rotationListener;
+
+ public RotaryControl(Context context) {
+ this(context, null);
+ }
+
+ public RotaryControl(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public RotaryControl(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(context, attrs, defStyleAttr);
+ }
+
+ private void init(Context context, AttributeSet attrs, int defStyleAttr) {
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RotaryControl, defStyleAttr, 0);
+
+ startAngle = a.getFloat(R.styleable.RotaryControl_start_angle, (float) Math.PI / 2);
+ angle = startAngle;
+ controlPointSize = a.getDimensionPixelSize(R.styleable.RotaryControl_controlpoint_size, 50);
+ controlPointColor = a.getColor(R.styleable.RotaryControl_controlpoint_color, Color.GRAY);
+ lineThickness = a.getDimensionPixelSize(R.styleable.RotaryControl_line_thickness, 20);
+ lineColor = a.getColor(R.styleable.RotaryControl_line_color, Color.RED);
+ calculateAndSetPadding();
+ a.recycle();
+ }
+
+ private void calculateAndSetPadding() {
+ int totalPadding = getPaddingLeft() + getPaddingRight() + getPaddingBottom() + getPaddingTop() + getPaddingEnd() + getPaddingStart();
+ padding = totalPadding / 6;
+ }
+
+ @Override
+ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
+ int smallerDim = width > height ? height : width;
+ int largestCenteredSquareLeft = (width - smallerDim) / 2;
+ int largestCenteredSquareTop = (height - smallerDim) / 2;
+ int largestCenteredSquareRight = largestCenteredSquareLeft + smallerDim;
+ int largestCenteredSquareBottom = largestCenteredSquareTop + smallerDim;
+ controlCenterX = largestCenteredSquareRight / 2 + (width - largestCenteredSquareRight) / 2;
+ controlCenterY = largestCenteredSquareBottom / 2 + (height - largestCenteredSquareBottom) / 2;
+ controlRadius = smallerDim / 2 - lineThickness / 2 - padding;
+
+ super.onSizeChanged(width, height, oldWidth, oldHeight);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ drawRotationCircle(canvas);
+ drawControlPoint(canvas);
+
+ }
+
+ private void drawControlPoint(Canvas canvas) {
+ controlPointX = (int) (controlCenterX + controlRadius * Math.cos(angle));
+ controlPointY = (int) (controlCenterY - controlRadius * Math.sin(angle));
+ controlPointPaint.setColor(controlPointColor);
+ controlPointPaint.setStyle(Paint.Style.FILL);
+ controlPointPaint.setAlpha(128);
+ Path controlPointPath = new Path();
+ controlPointPath.addCircle(controlPointX, controlPointY, controlPointSize, Path.Direction.CW);
+ canvas.drawPath(controlPointPath, controlPointPaint);
+ }
+
+ private void drawRotationCircle(Canvas canvas) {
+ DashPathEffect dashPath = new DashPathEffect(new float[]{8,22}, (float)1.0);
+ paint.setPathEffect(dashPath);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeWidth(lineThickness);
+ paint.setAntiAlias(true);
+ paint.setColor(lineColor);
+ circlePath = new Path();
+ circlePath.addCircle(controlCenterX, controlCenterY, controlRadius, Path.Direction.CW);
+ canvas.drawPath(circlePath, paint);
+ }
+
+ private void updateRotationPosition(double touchX, double touchY) {
+ double distanceX = touchX - controlCenterX;
+ double distanceY = controlCenterY - touchY;
+ double c = Math.sqrt(Math.pow(distanceX, 2) + Math.pow(distanceY, 2));
+ double currentAngle = Math.acos(distanceX / c);
+ if (distanceY < 0) {
+ currentAngle = -currentAngle;
+ }
+
+ int movementAmount = (int) ((currentAngle - angle) * 100);
+
+ int i = (int) movementAmount;
+ if (movementAmount != 0) {
+ if (Math.abs(movementAmount) > 15) {
+ movementAmount /= movementAmount;
+ }
+
+ rotationListener.onRotation((short) -movementAmount);
+ }
+
+ angle = currentAngle;
+ }
+
+ public void setRotationListener(RotationListener listener) {
+ rotationListener = listener;
+ }
+
+ public void reset() {
+ angle = startAngle;
+ invalidate();
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent ev) {
+ switch (ev.getAction()) {
+ case MotionEvent.ACTION_DOWN: {
+ double x = ev.getX();
+ double y = ev.getY();
+ if (x < controlPointX + controlPointSize && x > controlPointX - controlPointSize && y < controlPointY + controlPointSize && y > controlPointY - controlPointSize) {
+ getParent().requestDisallowInterceptTouchEvent(true);
+ isControlPointSelected = true;
+ updateRotationPosition(x, y);
+ }
+ break;
+ }
+
+ case MotionEvent.ACTION_MOVE: {
+ if (isControlPointSelected) {
+ double x = ev.getX();
+ double y = ev.getY();
+ updateRotationPosition(x, y);
+ }
+ break;
+ }
+
+ case MotionEvent.ACTION_UP: {
+ getParent().requestDisallowInterceptTouchEvent(false);
+ isControlPointSelected = false;
+ break;
+ }
+ }
+
+ invalidate();
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsCalibrationActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsCalibrationActivity.java
new file mode 100644
index 0000000..49030e7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsCalibrationActivity.java
@@ -0,0 +1,159 @@
+/* Copyright (C) 2023-2024 Frank Ertl
+
+ 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.devices.withingssteelhr;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport;
+
+public class WithingsCalibrationActivity extends AbstractGBActivity {
+
+ enum Hands {
+ HOURS((short)1),
+ MINUTES((short)0),
+ ACTIVITY_TARGET((short)2);
+
+ private final short code;
+ Hands(short code) {
+ this.code = code;
+ }
+
+ }
+
+ private GBDevice device;
+ private LocalBroadcastManager localBroadcastManager;
+ private final String[] calibrationAdvices = new String[3];
+ private final Hands[] hands = new Hands[]{Hands.HOURS, Hands.MINUTES, Hands.ACTIVITY_TARGET};
+ private short handIndex = 0;
+ private Button previousButton;
+ private Button nextButton;
+ private Button okButton;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_withings_calibration);
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ for(GBDevice device : devices){
+ if(device.getType() == DeviceType.WITHINGS_STEEL_HR ){
+ this.device = device;
+ break;
+ }
+ }
+
+ if (device == null) {
+ Toast.makeText(this, R.string.watch_not_connected, Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+
+ initView();
+ localBroadcastManager = LocalBroadcastManager.getInstance(this);
+ localBroadcastManager.sendBroadcast(new Intent(WithingsSteelHRDeviceSupport.START_HANDS_CALIBRATION_CMD));
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (localBroadcastManager != null) {
+ localBroadcastManager.sendBroadcast(new Intent(WithingsSteelHRDeviceSupport.STOP_HANDS_CALIBRATION_CMD));
+ }
+ }
+
+ private void initView() {
+
+ calibrationAdvices[0] = getString(R.string.withings_calibration_text_hours);
+ calibrationAdvices[1] = getString(R.string.withings_calibration_text_minutes);
+ calibrationAdvices[2] = getString(R.string.withings_calibration_text_activity_target);
+
+ RotaryControl rotaryControl = findViewById(R.id.rotary_control);
+ rotaryControl.setRotationListener(new RotaryControl.RotationListener() {
+ @Override
+ public void onRotation(short movementAmount) {
+ Intent calibration = new Intent(WithingsSteelHRDeviceSupport.HANDS_CALIBRATION_CMD);
+ calibration.putExtra("hand", hands[handIndex].code);
+ calibration.putExtra("movementAmount", movementAmount);
+ localBroadcastManager.sendBroadcast(calibration);
+ }
+ });
+
+ TextView textView = findViewById(R.id.withings_calibration_textview);
+ textView.setText(calibrationAdvices[0]);
+ previousButton = findViewById(R.id.withings_calibration_button_previous);
+ previousButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ handIndex--;
+ enableButtons();
+ textView.setText(calibrationAdvices[handIndex]);
+ rotaryControl.reset();
+ }
+ });
+ nextButton = findViewById(R.id.withings_calibration_button_next);
+ nextButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ handIndex++;
+ enableButtons();
+ textView.setText(calibrationAdvices[handIndex]);
+ rotaryControl.reset();
+ }
+ });
+
+ okButton = findViewById(R.id.withings_calibration_button_ok);
+ okButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ finish();
+ }
+ });
+
+ enableButtons();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ finish();
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void enableButtons() {
+ nextButton.setEnabled(handIndex < 2);
+ previousButton.setEnabled(handIndex > 0);
+ okButton.setEnabled(handIndex == 2);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsSteelHRDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsSteelHRDeviceCoordinator.java
new file mode 100644
index 0000000..7466c5a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsSteelHRDeviceCoordinator.java
@@ -0,0 +1,212 @@
+/* Copyright (C) 2023-2024 Ascense, Daniel Dakhno, Frank Ertl, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.withingssteelhr;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Locale;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.WithingsSteelHRActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport;
+
+public class WithingsSteelHRDeviceCoordinator extends AbstractDeviceCoordinator {
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder<?> qb = session.getWithingsSteelHRActivitySampleDao().queryBuilder();
+ qb.where(WithingsSteelHRActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @NonNull
+ @Override
+ public boolean supports(GBDeviceCandidate candidate) {
+ String name = candidate.getName();
+ if (name != null && (name.toLowerCase(Locale.ROOT).startsWith("steel") || name.toLowerCase(Locale.ROOT).startsWith("activite"))) {
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_withingssteelhr
+ };
+ }
+
+ @Override
+ public int getBondingStyle(){
+ return BONDING_STYLE_BOND;
+ }
+
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsRemSleep() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new WithingsSteelHRSampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice gbDevice) {
+ return 3;
+ }
+
+ @Override
+ public boolean supportsAlarmTitle(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsAlarmDescription(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(GBDevice device, int position) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Withings";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(GBDevice gbDevice) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(GBDevice device) {
+ return new String[]{
+ "auto",
+ "de_DE",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "it_IT",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return WithingsSteelHRDeviceSupport.class;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.withings_steel_hr;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_watchxplus;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_watchxplus_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsSteelHRSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsSteelHRSampleProvider.java
new file mode 100644
index 0000000..8b79d04
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsSteelHRSampleProvider.java
@@ -0,0 +1,98 @@
+/* Copyright (C) 2023-2024 Frank Ertl
+
+ 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.devices.withingssteelhr;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.WithingsSteelHRActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.WithingsSteelHRActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class WithingsSteelHRSampleProvider extends AbstractSampleProvider<WithingsSteelHRActivitySample> {
+ public WithingsSteelHRSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<WithingsSteelHRActivitySample, ?> getSampleDao() {
+ return getSession().getWithingsSteelHRActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return WithingsSteelHRActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return WithingsSteelHRActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return WithingsSteelHRActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ switch (rawType) {
+ case 1:
+ return ActivityKind.LIGHT_SLEEP;
+ case 2:
+ return ActivityKind.DEEP_SLEEP;
+ default:
+ return ActivityKind.fromCode(rawType);
+ }
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ switch (activityKind) {
+ case UNKNOWN:
+ return 0;
+ case LIGHT_SLEEP:
+ return 1;
+ case DEEP_SLEEP:
+ return 2;
+ default:
+ return activityKind.getCode();
+ }
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ if (rawIntensity > 0) {
+ return (float) (Math.log(rawIntensity) / 8);
+ }
+
+ return 0;
+ }
+
+ @Override
+ public WithingsSteelHRActivitySample createActivitySample() {
+ return new WithingsSteelHRActivitySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java
new file mode 100644
index 0000000..3dd99b0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java
@@ -0,0 +1,608 @@
+/* Copyright (C) 2023-2024 Andreas Shimokawa, Jos茅 Rebelo, Yoran Vulker
+
+ 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.devices.xiaomi;
+
+import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences.*;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.ArrayUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
+import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiUuids;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.WorkoutSummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public abstract class XiaomiCoordinator extends AbstractBLEDeviceCoordinator {
+ // On plaintext devices, user id is used as auth key - numeric
+ private static final Pattern AUTH_KEY_PATTERN = Pattern.compile("^[0-9]+$");
+
+ @NonNull
+ @Override
+ public Collection<? extends ScanFilter> createBLEScanFilters() {
+ final List<ScanFilter> filters = new ArrayList<>();
+ for (final UUID uuid : XiaomiUuids.BLE_UUIDS.keySet()) {
+ final ParcelUuid service = new ParcelUuid(uuid);
+ final ScanFilter filter = new ScanFilter.Builder().setServiceUuid(service).build();
+ filters.add(filter);
+ }
+ return filters;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return XiaomiSupport.class;
+ }
+
+ @Override
+ public boolean validateAuthKey(final String authKey) {
+ final byte[] authKeyBytes = authKey.trim().getBytes();
+ // At this point we don't know if it's encrypted or not, so let's accept both:
+ return authKeyBytes.length == 32 || (authKey.startsWith("0x") && authKeyBytes.length == 34)
+ || AUTH_KEY_PATTERN.matcher(authKey.trim()).matches();
+ }
+
+ @Nullable
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final XiaomiInstallHandler handler = new XiaomiInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull final GBDevice gbDevice,
+ @NonNull final Device device,
+ @NonNull final DaoSession session) throws GBException {
+ final Long deviceId = device.getId();
+
+ session.getXiaomiActivitySampleDao().queryBuilder()
+ .where(XiaomiActivitySampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(final GBDevice device, DaoSession session) {
+ return new XiaomiSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends StressSample> getStressSampleProvider(final GBDevice device, final DaoSession session) {
+ return new XiaomiStressSampleProvider(device, session);
+ }
+
+ @Override
+ public int[] getStressRanges() {
+ // 1-25 = relaxed
+ // 26-50 = mild
+ // 51-80 = moderate
+ // 81-100 = high
+ return new int[]{1, 26, 51, 81};
+ }
+
+ @Override
+ public TimeSampleProvider<? extends TemperatureSample> getTemperatureSampleProvider(final GBDevice device, final DaoSession session) {
+ return new XiaomiTemperatureSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ return new XiaomiSpo2SampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HeartRateSample> getHeartRateMaxSampleProvider(final GBDevice device, final DaoSession session) {
+ // TODO XiaomiHeartRateMaxSampleProvider
+ return super.getHeartRateMaxSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HeartRateSample> getHeartRateRestingSampleProvider(GBDevice device, DaoSession session) {
+ return new XiaomiHeartRateRestingSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends HeartRateSample> getHeartRateManualSampleProvider(final GBDevice device, final DaoSession session) {
+ // TODO XiaomiHeartRateManualSampleProvider
+ return super.getHeartRateManualSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends PaiSample> getPaiSampleProvider(final GBDevice device, final DaoSession session) {
+ return new XiaomiPaiSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider<? extends RespiratoryRateSample> getRespiratoryRateSampleProvider(final GBDevice device, final DaoSession session) {
+ // TODO XiaomiSleepRespiratoryRateSampleProvider
+ return super.getRespiratoryRateSampleProvider(device, session);
+ }
+
+ @Nullable
+ @Override
+ public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
+ return new WorkoutSummaryParser();
+ }
+
+ @Override
+ public boolean supportsFlashing() {
+ return true;
+ }
+
+ @Override
+ public int getAlarmSlotCount(final GBDevice device) {
+ return getPrefs(device).getInt(XiaomiPreferences.PREF_ALARM_SLOTS, 0);
+ }
+
+ @Override
+ public boolean supportsSmartWakeup(final GBDevice device, int position) {
+ return true;
+ }
+
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsCachedAppManagement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsInstalledAppManagement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWatchfaceManagement(GBDevice device) {
+ return supportsAppsManagement(device);
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return AppManagerActivity.class;
+ }
+
+ @Override
+ public boolean supportsAppListFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsAppReordering() {
+ return false;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateStats() {
+ // TODO it does, and they're persisted - see DailySummaryParser
+ return false;
+ }
+
+ @Override
+ public boolean supportsHeartRateRestingMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsPai() {
+ // Vitality Score
+ return true;
+ }
+
+ @Override
+ public int getPaiName() {
+ return R.string.pref_vitality_score_title;
+ }
+
+ @Override
+ public boolean supportsPaiTime() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSleepRespiratoryRate() {
+ // TODO it does
+ return false;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public int getMaximumReminderMessageLength() {
+ // TODO does it?
+ return 20;
+ }
+
+ @Override
+ public int getReminderSlotCount(final GBDevice device) {
+ return getPrefs(device).getInt(XiaomiPreferences.PREF_REMINDER_SLOTS, 0);
+ }
+
+ @Override
+ public int getCannedRepliesSlotCount(final GBDevice device) {
+ return getPrefs(device).getInt(XiaomiPreferences.PREF_CANNED_MESSAGES_MAX, 0);
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ // TODO how many? also, map world clocks
+ return 0;
+ }
+
+ @Override
+ public int getWorldClocksLabelLength() {
+ // TODO no labels
+ // TODO list of supported timezones
+ return 5;
+ }
+
+ @Override
+ public boolean supportsDisabledWorldClocks() {
+ // TODO does it?
+ return false;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsManualHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Xiaomi";
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsRemSleep() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean addBatteryPollingSettings() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificConnectionSettings() {
+ final List<Integer> settings = new ArrayList<>();
+
+ if (getConnectionType().equals(ConnectionType.BOTH)) {
+ settings.add(R.xml.devicesettings_force_connection_type);
+ }
+
+ return ArrayUtils.addAll(
+ super.getSupportedDeviceSpecificConnectionSettings(),
+ ArrayUtils.toPrimitive(settings.toArray(new Integer[0]))
+ );
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ if (supports(device, FEAT_WEAR_MODE)) {
+ // TODO we should be able to get this from the band - right now it must be changed
+ // at least once from the band itself
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_wearmode);
+ }
+
+ //
+ // Time
+ //
+ final List<Integer> dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ dateTime.add(R.xml.devicesettings_timeformat);
+ if (getWorldClocksSlotCount() > 0) {
+ dateTime.add(R.xml.devicesettings_world_clocks);
+ }
+
+ //
+ // Display
+ //
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ if (supports(device, FEAT_DISPLAY_ITEMS)) {
+ display.add(R.xml.devicesettings_xiaomi_displayitems);
+ }
+ if (this.supportsWidgets(device)) {
+ display.add(R.xml.devicesettings_widgets);
+ }
+ if (supports(device, FEAT_PASSWORD)) {
+ display.add(R.xml.devicesettings_password);
+ }
+
+ //
+ // Health
+ //
+ if (supportsStressMeasurement() && supports(device, FEAT_STRESS) && supportsSpo2(device) && supports(device, FEAT_SPO2)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_heartrate_sleep_alert_activity_stress_spo2);
+ } else if (supportsStressMeasurement() && supports(device, FEAT_STRESS)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_heartrate_sleep_alert_activity_stress);
+ } else {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_heartrate_sleep_activity);
+ }
+ if (supports(device, FEAT_INACTIVITY)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_inactivity_dnd_no_threshold);
+ }
+ if (supports(device, FEAT_SLEEP_MODE_SCHEDULE)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_sleep_mode_schedule);
+ }
+ if (supports(device, FEAT_GOAL_NOTIFICATION)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_goal_notification);
+ }
+ if (supports(device, FEAT_GOAL_SECONDARY)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_goal_secondary);
+ }
+ if (supports(device, FEAT_VITALITY_SCORE)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_vitality_score);
+ }
+
+ //
+ // Workout
+ //
+ final List<Integer> workout = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.WORKOUT);
+ workout.add(R.xml.devicesettings_workout_start_on_phone);
+ workout.add(R.xml.devicesettings_workout_send_gps_to_band);
+
+ //
+ // Notifications
+ //
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.NOTIFICATIONS);
+ // TODO not implemented settings.add(R.xml.devicesettings_vibrationpatterns);
+ // TODO not implemented settings.add(R.xml.devicesettings_donotdisturb_withauto_and_always);
+ notifications.add(R.xml.devicesettings_send_app_notifications);
+ if (supports(device, FEAT_SCREEN_ON_ON_NOTIFICATIONS)) {
+ notifications.add(R.xml.devicesettings_screen_on_on_notifications);
+ }
+ notifications.add(R.xml.devicesettings_autoremove_notifications);
+ if (getCannedRepliesSlotCount(device) > 0) {
+ notifications.add(R.xml.devicesettings_canned_dismisscall_16);
+ }
+ notifications.add(R.xml.devicesettings_transliteration);
+
+ //
+ // Calendar
+ //
+ if (supportsCalendarEvents()) {
+ deviceSpecificSettings.addRootScreen(
+ DeviceSpecificSettingsScreen.CALENDAR,
+ R.xml.devicesettings_header_calendar,
+ R.xml.devicesettings_sync_calendar
+ );
+ }
+
+ //
+ // Other
+ //
+ if (getContactsSlotCount(device) > 0) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_contacts);
+ }
+ if (supports(device, FEAT_CAMERA_REMOTE)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_camera_remote);
+ }
+ if (supports(device, FEAT_DEVICE_ACTIONS)) {
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_device_actions);
+ }
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_phone_silent_mode);
+
+ //
+ // Developer
+ //
+ deviceSpecificSettings.addRootScreen(
+ DeviceSpecificSettingsScreen.DEVELOPER,
+ R.xml.devicesettings_keep_activity_data_on_device
+ );
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificAuthenticationSettings() {
+ return new int[]{R.xml.devicesettings_pairingkey};
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new XiaomiSettingsCustomizer();
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "ar_SA",
+ "cs_CZ",
+ "da_DK",
+ "de_DE",
+ "el_GR",
+ "en_US",
+ "es_ES",
+ "fr_FR",
+ "he_IL",
+ "id_ID",
+ "it_IT",
+ "ja_JP",
+ "ko_KO",
+ "nl_NL",
+ "nb_NO",
+ "pl_PL",
+ "pt_BR",
+ "pt_PT",
+ "ro_RO",
+ "ru_RU",
+ "sv_SE",
+ "th_TH",
+ "tr_TR",
+ "uk_UA",
+ "vi_VN",
+ "zh_CN",
+ "zh_TW",
+ };
+ }
+
+ @Override
+ public PasswordCapabilityImpl.Mode getPasswordCapability() {
+ return PasswordCapabilityImpl.Mode.NUMBERS_6;
+ }
+
+ @Override
+ public List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
+ return Arrays.asList(
+ HeartRateCapability.MeasurementInterval.OFF,
+ HeartRateCapability.MeasurementInterval.SMART,
+ HeartRateCapability.MeasurementInterval.MINUTES_1,
+ HeartRateCapability.MeasurementInterval.MINUTES_10,
+ HeartRateCapability.MeasurementInterval.MINUTES_30
+ );
+ }
+
+ @Override
+ public boolean supportsWidgets(final GBDevice device) {
+ return getPrefs(device).getBoolean(XiaomiPreferences.FEAT_WIDGETS, false);
+ }
+
+ @Override
+ public WidgetManager getWidgetManager(final GBDevice device) {
+ return new XiaomiWidgetManager(device);
+ }
+
+ protected static Prefs getPrefs(final GBDevice device) {
+ return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
+ }
+
+ public boolean supports(final GBDevice device, final String feature) {
+ return getPrefs(device).getBoolean(feature, false);
+ }
+
+ public boolean checkDecryptionMac() {
+ return true;
+ }
+
+ /**
+ * Whether the device supports alarms. This differs from {@link #getAlarmSlotCount} since that
+ * returns the number of alarms dynamically after requesting them, but some devices will crash
+ * if we even attempt to request alarms and they do not support them - see
+ * <a href="https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/3766">#3766</a>
+ */
+ public boolean supportsAlarms() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiDailySummarySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiDailySummarySampleProvider.java
new file mode 100644
index 0000000..5926994
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiDailySummarySampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiDailySummarySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiDailySummarySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class XiaomiDailySummarySampleProvider extends AbstractTimeSampleProvider<XiaomiDailySummarySample> {
+ public XiaomiDailySummarySampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<XiaomiDailySummarySample, ?> getSampleDao() {
+ return getSession().getXiaomiDailySummarySampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return XiaomiDailySummarySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return XiaomiDailySummarySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public XiaomiDailySummarySample createSample() {
+ return new XiaomiDailySummarySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiFWHelper.java
new file mode 100644
index 0000000..5e71aef
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiFWHelper.java
@@ -0,0 +1,368 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiBitmapUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+
+public class XiaomiFWHelper {
+ private static final Logger LOG = LoggerFactory.getLogger(XiaomiFWHelper.class);
+ // TODO use to determine translatable face name displayed in UI
+ private static final String[] FACE_LOCALE_BITMAP_LIST = new String[]{
+ "en_US",
+ "zh_CN",
+ "zh_TW",
+ "ja_JP",
+ "es_ES",
+ "fr_FR",
+ "de_DE",
+ "ru_RU",
+ "pt_BR",
+ "pt_PT",
+ "it_IT",
+ "ko_KR",
+ "tr_TR",
+ "nl_NL",
+ "th_TH",
+ "sv_SE",
+ "da_DK",
+ "vi_VN",
+ "nb_NO",
+ "pl_PL",
+ "fi_FI",
+ "in_ID", // old ISO-639
+ "el_GR",
+ "ro_RO",
+ "cs_CZ",
+ "uk_UA",
+ "hu_HU",
+ "sk_SK",
+ "ar_EG",
+ "iw_IL", // old ISO-639
+ "zh_HK",
+ };
+
+ private final Uri uri;
+ private byte[] fw;
+ private boolean valid;
+ private boolean typeFirmware;
+ private boolean typeWatchface;
+
+ private String id;
+ private String name;
+ private String version;
+
+ public XiaomiFWHelper(final Uri uri, final Context context) {
+ this.uri = uri;
+
+ final UriHelper uriHelper;
+ try {
+ uriHelper = UriHelper.get(uri, context);
+ } catch (final IOException e) {
+ LOG.error("Failed to get uri helper for {}", uri, e);
+ return;
+ }
+
+ final int maxExpectedFileSize = 1024 * 1024 * 128; // 64MB
+
+ if (uriHelper.getFileSize() > maxExpectedFileSize) {
+ LOG.warn("Firmware size is larger than the maximum expected file size of {}", maxExpectedFileSize);
+ return;
+ }
+
+ try (final InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ this.fw = FileUtils.readAll(in, maxExpectedFileSize);
+ } catch (final IOException e) {
+ LOG.error("Failed to read bytes from {}", uri, e);
+ return;
+ }
+
+ parseBytes();
+ }
+
+ public boolean isValid() {
+ return valid;
+ }
+
+ public boolean isWatchface() {
+ return typeWatchface;
+ }
+
+ public boolean isFirmware() {
+ return typeFirmware;
+ }
+
+ public String getDetails() {
+ return name != null ? name : (version != null ? version : "UNKNOWN");
+ }
+
+ public byte[] getBytes() {
+ return fw;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public void unsetFwBytes() {
+ this.fw = null;
+ }
+
+ private void parseBytes() {
+ if (parseAsWatchface()) {
+ assert id != null;
+ valid = true;
+ typeWatchface = true;
+ } else if (parseAsFirmware()) {
+ assert version != null;
+ valid = true;
+ typeFirmware = true;
+ } else {
+ valid = false;
+ }
+ }
+
+ private int findBestI18n(final long availableLocales) {
+ // try to find a locale matching the user's preferences amongst locales available
+ final Locale userLocale = GBApplication.getLanguage();
+ int found = -1, fallbackLocale = -1;
+ for (int i = 0; i < FACE_LOCALE_BITMAP_LIST.length; i++) {
+ if ((availableLocales & (1L << i)) != 0) {
+ final String[] localeParts = FACE_LOCALE_BITMAP_LIST[i].split("_");
+ final Locale locale = new Locale(localeParts[0], localeParts[1]);
+
+ if (locale.getLanguage().equals(userLocale.getLanguage())) {
+ // return for this entry if locale matches on language and country
+ if (locale.getCountry().equals(userLocale.getCountry())) {
+ LOG.debug("Found locale match: {}", FACE_LOCALE_BITMAP_LIST[i]);
+ return i;
+ }
+
+ // keep scanning as a better locale might exist (Portuguese)
+ LOG.debug("Found language match for {}_{}: {}",
+ userLocale.getLanguage(),
+ userLocale.getCountry(),
+ FACE_LOCALE_BITMAP_LIST[i]);
+ found = i;
+ }
+
+ // keep first found available locale as fallback
+ if (fallbackLocale == -1) {
+ fallbackLocale = i;
+ }
+ }
+ }
+
+ if (found != -1) {
+ return found;
+ }
+
+ if (fallbackLocale != -1) {
+ LOG.debug("Using fallback locale {}", FACE_LOCALE_BITMAP_LIST[fallbackLocale]);
+ return fallbackLocale;
+ }
+
+ LOG.debug("No translation available");
+ return -1;
+ }
+
+ private String extractTranslatableString(final int tableOffset, final int tableSize) {
+ if (tableOffset < 0 || tableSize < 0 || tableOffset + tableSize > fw.length) {
+ LOG.error("i18n table out-of-bounds");
+ return null;
+ }
+
+ final ByteBuffer bb = ByteBuffer.wrap(fw, tableOffset, tableSize).order(ByteOrder.LITTLE_ENDIAN);
+ long availableLocalizations = bb.getLong();
+ final int localizationsCount = Long.bitCount(availableLocalizations);
+
+ if (tableSize < localizationsCount * 4 + 8) {
+ LOG.error("cannot decode i18n table (at least {} bytes required for length table, but localization block is only {} bytes)",
+ localizationsCount * 4 + 8,
+ tableSize);
+ return null;
+ }
+
+ final int targetLocale = findBestI18n(availableLocalizations);
+ if (targetLocale == -1) {
+ return null;
+ }
+
+ int targetOffset = 0, targetSize = 0;
+ for (int i = 0; availableLocalizations != 0; i++) {
+ if ((availableLocalizations & 1L) != 0) {
+ final int size = bb.getInt();
+
+ if (i < targetLocale) {
+ targetOffset += size;
+ }
+
+ if (i == targetLocale) {
+ targetSize = size;
+ }
+ }
+ availableLocalizations >>= 1;
+ }
+
+ if (bb.remaining() < targetOffset + targetSize) {
+ LOG.error("cannot extract localization (at least {} bytes required, but only {} bytes remaining)",
+ targetOffset + targetSize,
+ bb.remaining());
+ return null;
+ }
+
+ bb.position(bb.position() + targetOffset);
+ final byte[] localizationBytes = new byte[targetSize];
+ bb.get(localizationBytes);
+ return new String(localizationBytes, StandardCharsets.UTF_8);
+ }
+
+ public Bitmap getWatchfacePreview() {
+ if (!isWatchface() || fw == null) {
+ return null;
+ }
+
+ final ByteBuffer bb = ByteBuffer.wrap(fw).order(ByteOrder.LITTLE_ENDIAN);
+ final int previewOffset = bb.getInt(0x20);
+ if (previewOffset == 0) {
+ LOG.debug("No preview available (at offset 0)");
+ return null;
+ }
+
+ if (previewOffset + 12 > fw.length) {
+ LOG.debug("No preview available (header out-of-bounds)");
+ return null;
+ }
+
+ bb.position(previewOffset);
+ final int bitmapType = bb.get() & 0xff;
+ final int compressionType = bb.get() & 0xff;
+ bb.getShort(); // ignore
+ final int width = bb.getShort() & 0xffff;
+ final int height = bb.getShort() & 0xffff;
+ final int bitmapSize = bb.getInt();
+
+ byte[] bitmapData = new byte[bitmapSize];
+ bb.get(bitmapData);
+
+ if (compressionType != 0) {
+ LOG.debug("Preview image compression type: {}", compressionType);
+ switch (compressionType) {
+ case 4:
+ bitmapData = XiaomiBitmapUtils.decompressLvglRleV2(bitmapData);
+ break;
+ case 8:
+ bitmapData = XiaomiBitmapUtils.decompressLvglRleV1(bitmapData);
+ break;
+ default:
+ LOG.error("unknown compression type {}", compressionType);
+ return null;
+ }
+
+ if (bitmapData == null) {
+ LOG.error("decompression returned null");
+ return null;
+ }
+ }
+
+ return XiaomiBitmapUtils.decodeWatchfaceImage(
+ bitmapData,
+ bitmapType,
+ compressionType == 8,
+ width,
+ height
+ );
+ }
+
+ private boolean parseAsWatchface() {
+ if (fw[0] != (byte) 0x5A || fw[1] != (byte) 0xA5) {
+ LOG.warn("File header not a watchface");
+ return false;
+ }
+
+ id = StringUtils.untilNullTerminator(fw, 0x28);
+
+ if (id == null) {
+ LOG.warn("id not found in {}", uri);
+ return false;
+ }
+
+ if (!Pattern.matches("^\\d+$", id)) {
+ LOG.warn("Id {} not a number", id);
+ return false;
+ }
+
+ if (ArrayUtils.equals(fw, new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff}, 0x68)) {
+ // name is localized
+ LOG.debug("detected translatable face name");
+ name = ""; // default value in case localization fails
+ final int tableOffset = BLETypeConversions.toUint32(fw, 0x74);
+ final int tableSize = BLETypeConversions.toUint32(fw, 0x78);
+
+ if (tableSize > 8) {
+ final String localized = extractTranslatableString(tableOffset, tableSize);
+
+ if (localized != null) {
+ name = localized;
+ }
+ }
+ } else {
+ name = StringUtils.untilNullTerminator(fw, 0x68);
+
+ if (name == null) {
+ LOG.warn("name not found in {}", uri);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private boolean parseAsFirmware() {
+ // TODO parse and set version
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiHeartRateRestingSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiHeartRateRestingSampleProvider.java
new file mode 100644
index 0000000..1a66b82
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiHeartRateRestingSampleProvider.java
@@ -0,0 +1,113 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiDailySummarySample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+
+public class XiaomiHeartRateRestingSampleProvider implements TimeSampleProvider<HeartRateSample> {
+ private final XiaomiDailySummarySampleProvider dailySummarySampleProvider;
+
+ public XiaomiHeartRateRestingSampleProvider(final GBDevice device, final DaoSession session) {
+ this.dailySummarySampleProvider = new XiaomiDailySummarySampleProvider(device, session);
+ }
+
+ @NonNull
+ @Override
+ public List<HeartRateSample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<XiaomiDailySummarySample> allSamples = dailySummarySampleProvider.getAllSamples(timestampFrom, timestampTo);
+ final List<HeartRateSample> ret = new ArrayList<>(allSamples.size());
+ for (final XiaomiDailySummarySample sample : allSamples) {
+ ret.add(new XiaomiHeartRateRestingSample(sample));
+ }
+ return ret;
+ }
+
+ @Override
+ public void addSample(final HeartRateSample timeSample) {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Override
+ public void addSamples(final List<HeartRateSample> timeSamples) {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Override
+ public HeartRateSample createSample() {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Nullable
+ @Override
+ public HeartRateSample getLatestSample() {
+ final XiaomiDailySummarySample sample = dailySummarySampleProvider.getLatestSample();
+ if (sample != null) {
+ return new XiaomiHeartRateRestingSample(sample);
+ }
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public HeartRateSample getLatestSample(final long until) {
+ final XiaomiDailySummarySample sample = dailySummarySampleProvider.getLatestSample(until);
+ if (sample != null) {
+ return new XiaomiHeartRateRestingSample(sample);
+ }
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public HeartRateSample getFirstSample() {
+ final XiaomiDailySummarySample sample = dailySummarySampleProvider.getFirstSample();
+ if (sample != null) {
+ return new XiaomiHeartRateRestingSample(sample);
+ }
+ return null;
+ }
+
+ public static class XiaomiHeartRateRestingSample implements HeartRateSample {
+ private final long timestamp;
+ private final int heartRate;
+
+ public XiaomiHeartRateRestingSample(final XiaomiDailySummarySample sample) {
+ this.timestamp = sample.getTimestamp();
+ this.heartRate = sample.getHrResting();
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public int getHeartRate() {
+ return heartRate;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiInstallHandler.java
new file mode 100644
index 0000000..86f1e25
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiInstallHandler.java
@@ -0,0 +1,93 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+
+public class XiaomiInstallHandler implements InstallHandler {
+ protected final Uri mUri;
+ protected final Context mContext;
+ protected final XiaomiFWHelper helper;
+
+ public XiaomiInstallHandler(final Uri uri, final Context context) {
+ this.mUri = uri;
+ this.mContext = context;
+ this.helper = new XiaomiFWHelper(uri, context);
+ }
+
+ @Override
+ public boolean isValid() {
+ return helper.isValid();
+ }
+
+ @Override
+ public void validateInstallation(final InstallActivity installActivity, final GBDevice device) {
+ if (device.isBusy()) {
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!device.isInitialized()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!helper.isValid()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final GenericItem installItem = new GenericItem();
+ if (helper.isWatchface()) {
+ installItem.setIcon(R.drawable.ic_watchface);
+ installItem.setName(mContext.getString(R.string.kind_watchface));
+
+ final Bitmap preview = helper.getWatchfacePreview();
+ if (preview != null) {
+ installItem.setPreview(preview);
+ }
+ } else if (helper.isFirmware()) {
+ installItem.setIcon(R.drawable.ic_firmware);
+ installItem.setName(mContext.getString(R.string.kind_firmware));
+ } else {
+ installItem.setIcon(R.drawable.ic_device_unknown);
+ installItem.setName(mContext.getString(R.string.kind_invalid));
+ }
+
+ installItem.setDetails(helper.getDetails());
+
+ installActivity.setInfoText(mContext.getString(R.string.firmware_install_warning, "(unknown)"));
+ installActivity.setInstallItem(installItem);
+ installActivity.setInstallEnabled(true);
+ }
+
+ @Override
+ public void onStartInstall(final GBDevice device) {
+ helper.unsetFwBytes(); // free up memory
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiManualSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiManualSampleProvider.java
new file mode 100644
index 0000000..55c0d76
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiManualSampleProvider.java
@@ -0,0 +1,125 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiManualSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiManualSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class XiaomiManualSampleProvider extends AbstractTimeSampleProvider<XiaomiManualSample> {
+ public static final int TYPE_HR = 0x11;
+ public static final int TYPE_SPO2 = 0x12;
+ public static final int TYPE_STRESS = 0x13;
+ public static final int TYPE_TEMPERATURE = 0x44;
+
+ public XiaomiManualSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<XiaomiManualSample, ?> getSampleDao() {
+ return getSession().getXiaomiManualSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return XiaomiManualSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return XiaomiManualSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public XiaomiManualSample createSample() {
+ return new XiaomiManualSample();
+ }
+
+ @Nullable
+ public XiaomiManualSample getLatestSample(final int type) {
+ final QueryBuilder<XiaomiManualSample> qb = getSampleDao().queryBuilder();
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+ final Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(deviceProperty.eq(dbDevice.getId()))
+ .where(XiaomiManualSampleDao.Properties.Type.eq(type))
+ .orderDesc(getTimestampSampleProperty()).limit(1);
+ final List<XiaomiManualSample> samples = qb.build().list();
+ if (samples.isEmpty()) {
+ return null;
+ }
+ return samples.get(0);
+ }
+
+ @Nullable
+ public XiaomiManualSample getLatestSample(final int type, final long until) {
+ final QueryBuilder<XiaomiManualSample> qb = getSampleDao().queryBuilder();
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+ final Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(deviceProperty.eq(dbDevice.getId()))
+ .where(XiaomiManualSampleDao.Properties.Timestamp.le(until))
+ .where(XiaomiManualSampleDao.Properties.Type.eq(type))
+ .orderDesc(getTimestampSampleProperty()).limit(1);
+ final List<XiaomiManualSample> samples = qb.build().list();
+ if (samples.isEmpty()) {
+ return null;
+ }
+ return samples.get(0);
+ }
+
+ @Nullable
+ public XiaomiManualSample getFirstSample(final int type) {
+ final QueryBuilder<XiaomiManualSample> qb = getSampleDao().queryBuilder();
+ final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
+ if (dbDevice == null) {
+ // no device, no sample
+ return null;
+ }
+ final Property deviceProperty = getDeviceIdentifierSampleProperty();
+ qb.where(deviceProperty.eq(dbDevice.getId()))
+ .where(XiaomiManualSampleDao.Properties.Type.eq(type))
+ .orderAsc(getTimestampSampleProperty()).limit(1);
+ final List<XiaomiManualSample> samples = qb.build().list();
+ if (samples.isEmpty()) {
+ return null;
+ }
+ return samples.get(0);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiPaiSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiPaiSampleProvider.java
new file mode 100644
index 0000000..1908046
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiPaiSampleProvider.java
@@ -0,0 +1,154 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiDailySummarySample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
+
+public class XiaomiPaiSampleProvider implements TimeSampleProvider<PaiSample> {
+ private final XiaomiDailySummarySampleProvider dailySummarySampleProvider;
+
+ public XiaomiPaiSampleProvider(final GBDevice device, final DaoSession session) {
+ this.dailySummarySampleProvider = new XiaomiDailySummarySampleProvider(device, session);
+ }
+
+ @NonNull
+ @Override
+ public List<PaiSample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<XiaomiDailySummarySample> allSamples = dailySummarySampleProvider.getAllSamples(timestampFrom, timestampTo);
+ final List<PaiSample> ret = new ArrayList<>(allSamples.size());
+ for (final XiaomiDailySummarySample sample : allSamples) {
+ ret.add(new XiaomiPaiSample(sample));
+ }
+ return ret;
+ }
+
+ @Override
+ public void addSample(final PaiSample timeSample) {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Override
+ public void addSamples(final List<PaiSample> timeSamples) {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Override
+ public PaiSample createSample() {
+ throw new UnsupportedOperationException("This sample provider is read-only!");
+ }
+
+ @Nullable
+ @Override
+ public PaiSample getLatestSample() {
+ final XiaomiDailySummarySample sample = dailySummarySampleProvider.getLatestSample();
+ if (sample != null) {
+ return new XiaomiPaiSample(sample);
+ }
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public PaiSample getLatestSample(final long until) {
+ final XiaomiDailySummarySample sample = dailySummarySampleProvider.getLatestSample(until);
+ if (sample != null) {
+ return new XiaomiPaiSample(sample);
+ }
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public PaiSample getFirstSample() {
+ final XiaomiDailySummarySample sample = dailySummarySampleProvider.getFirstSample();
+ if (sample != null) {
+ return new XiaomiPaiSample(sample);
+ }
+ return null;
+ }
+
+ public static class XiaomiPaiSample implements PaiSample {
+ private final long timestamp;
+ private final int paiLow;
+ private final int paiModerate;
+ private final int paiHigh;
+ private final int paiTotal;
+
+ public XiaomiPaiSample(final XiaomiDailySummarySample sample) {
+ this.timestamp = sample.getTimestamp();
+ this.paiLow = sample.getVitalityIncreaseLight();
+ this.paiModerate = sample.getVitalityIncreaseModerate();
+ this.paiHigh = sample.getVitalityIncreaseHigh();
+ this.paiTotal = sample.getVitalityCurrent();
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public float getPaiLow() {
+ return paiLow;
+ }
+
+ @Override
+ public float getPaiModerate() {
+ return paiModerate;
+ }
+
+ @Override
+ public float getPaiHigh() {
+ return paiHigh;
+ }
+
+ @Override
+ public int getTimeLow() {
+ return 0; // not supported
+ }
+
+ @Override
+ public int getTimeModerate() {
+ return 0; // not supported
+ }
+
+ @Override
+ public int getTimeHigh() {
+ return 0; // not supported
+ }
+
+ @Override
+ public float getPaiToday() {
+ return paiLow + paiModerate + paiHigh;
+ }
+
+ @Override
+ public float getPaiTotal() {
+ return paiTotal;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSampleProvider.java
new file mode 100644
index 0000000..c760688
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSampleProvider.java
@@ -0,0 +1,211 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiSleepTimeSample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.util.RangeMap;
+
+public class XiaomiSampleProvider extends AbstractSampleProvider<XiaomiActivitySample> {
+ private static final Logger LOG = LoggerFactory.getLogger(XiaomiSampleProvider.class);
+
+ public XiaomiSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao<XiaomiActivitySample, ?> getSampleDao() {
+ return getSession().getXiaomiActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return XiaomiActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return XiaomiActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return XiaomiActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(final int rawType) {
+ // TODO
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(final ActivityKind activityKind) {
+ // TODO
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(final int rawIntensity) {
+ return rawIntensity / 100f;
+ }
+
+ @Override
+ public XiaomiActivitySample createActivitySample() {
+ return new XiaomiActivitySample();
+ }
+
+ @Override
+ protected List<XiaomiActivitySample> getGBActivitySamples(final int timestamp_from, final int timestamp_to) {
+ final List<XiaomiActivitySample> samples = super.getGBActivitySamples(timestamp_from, timestamp_to);
+
+ overlaySleep(samples, timestamp_from, timestamp_to);
+
+ return samples;
+ }
+
+ /**
+ * See {@link nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.SleepDetailsParser}
+ */
+ private static ActivityKind getActivityKindForSample(final XiaomiSleepStageSample sample) {
+ switch (sample.getStage()) {
+ case 2:
+ return ActivityKind.DEEP_SLEEP;
+ case 3:
+ return ActivityKind.LIGHT_SLEEP;
+ case 4:
+ return ActivityKind.REM_SLEEP;
+ default: // default to awake
+ return ActivityKind.UNKNOWN;
+ }
+ }
+
+ /**
+ * Overlay sleep states on activity samples, since they are stored on a separate table.
+ *
+ * @implNote In order to determine whether a sleep session was ongoing at the start of the
+ * given range and what the detected sleep stage was at that time, the last sleep stage and
+ * sleep time sample before the given range will be queried and included in the results if
+ * found.
+ */
+ public void overlaySleep(final List<XiaomiActivitySample> samples, final int timestamp_from, final int timestamp_to) {
+ final RangeMap<Long, ActivityKind> stagesMap = new RangeMap<>(RangeMap.Mode.LOWER_BOUND);
+
+ final XiaomiSleepTimeSampleProvider sleepTimeSampleProvider = new XiaomiSleepTimeSampleProvider(getDevice(), getSession());
+ final XiaomiSleepStageSampleProvider sleepStagesSampleProvider = new XiaomiSleepStageSampleProvider(getDevice(), getSession());
+
+ // First populate all samples within this range
+ final List<XiaomiSleepTimeSample> sleepTimesWithinRange = sleepTimeSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L);
+ LOG.trace("Found {} sleep samples between {} and {}", sleepTimesWithinRange.size(), timestamp_from, timestamp_to);
+
+ for (final XiaomiSleepTimeSample sleepTimeSample : sleepTimesWithinRange) {
+ stagesMap.put(sleepTimeSample.getWakeupTime(), ActivityKind.UNKNOWN);
+ stagesMap.put(sleepTimeSample.getTimestamp(), ActivityKind.LIGHT_SLEEP);
+ }
+
+ // Retrieve the last stage before this time range, as the user could have been asleep during
+ // the range transition
+ final XiaomiSleepStageSample lastSleepStageBeforeRange = sleepStagesSampleProvider.getLastSampleBefore(timestamp_from * 1000L);
+
+ if (lastSleepStageBeforeRange != null) {
+ LOG.debug("Last sleep stage before range: ts={}, stage={}", lastSleepStageBeforeRange.getTimestamp(), lastSleepStageBeforeRange.getStage());
+ stagesMap.put(lastSleepStageBeforeRange.getTimestamp(), getActivityKindForSample(lastSleepStageBeforeRange));
+ }
+
+ // Retrieve all sleep stage samples during the range
+ final List<XiaomiSleepStageSample> sleepStagesInRange = sleepStagesSampleProvider.getAllSamples(
+ timestamp_from * 1000L,
+ timestamp_to * 1000L
+ );
+
+ if (!sleepStagesInRange.isEmpty()) {
+ // We got actual sleep stages
+ LOG.debug("Found {} sleep stage samples between {} and {}", sleepStagesInRange.size(), timestamp_from, timestamp_to);
+
+ for (final XiaomiSleepStageSample stageSample : sleepStagesInRange) {
+ stagesMap.put(stageSample.getTimestamp(), getActivityKindForSample(stageSample));
+ }
+ }
+
+
+ // Find last sleep sample before the requested range, as the recorded wake up time may be
+ // in the current range
+ final XiaomiSleepTimeSample lastSleepTimesBeforeRange = sleepTimeSampleProvider.getLastSampleBefore(timestamp_from * 1000L);
+
+ if (lastSleepTimesBeforeRange != null) {
+ LOG.debug("Last sleep time before range: ts={}, stage={}", lastSleepTimesBeforeRange.getTimestamp(), lastSleepTimesBeforeRange);
+
+ stagesMap.put(lastSleepTimesBeforeRange.getWakeupTime(), ActivityKind.UNKNOWN);
+ stagesMap.put(lastSleepTimesBeforeRange.getTimestamp(), ActivityKind.LIGHT_SLEEP);
+ }
+
+ // Find all wake up and sleep samples in the current time range
+ final List<XiaomiSleepTimeSample> sleepTimesInRange = sleepTimeSampleProvider.getAllSamples(
+ timestamp_from * 1000L,
+ timestamp_to * 1000L
+ );
+
+ if (!sleepTimesInRange.isEmpty()) {
+ LOG.debug("Found {} sleep samples between {} and {}", sleepTimesInRange.size(), timestamp_from, timestamp_to);
+ for (final XiaomiSleepTimeSample stageSample : sleepTimesInRange) {
+ if (sleepStagesInRange.isEmpty()) {
+ // Only overlay them as light sleep if we don't have actual sleep stages
+ stagesMap.put(stageSample.getTimestamp(), ActivityKind.LIGHT_SLEEP);
+ }
+
+ // We need to set the wakeup times, because some bands don't report them in the stage samples (see #3502)
+ stagesMap.put(stageSample.getWakeupTime(), ActivityKind.UNKNOWN);
+ }
+ }
+
+ if (!stagesMap.isEmpty()) {
+ LOG.debug("Found {} sleep stage samples between {} and {}", stagesMap.size(), timestamp_from, timestamp_to);
+ // FIXME if no samples were retrieved from the database that were generated by other
+ // activity files, the stages will not get overlayed/inserted and the sleep charts
+ // will stay empty.
+
+ for (final XiaomiActivitySample sample : samples) {
+ final long ts = sample.getTimestamp() * 1000L;
+ final ActivityKind sleepType = stagesMap.get(ts);
+ if (sleepType != null && !sleepType.equals(ActivityKind.UNKNOWN)) {
+ sample.setRawKind(sleepType.getCode());
+ sample.setRawIntensity(ActivitySample.NOT_MEASURED);
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSettingsCustomizer.java
new file mode 100644
index 0000000..8ba3acc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSettingsCustomizer.java
@@ -0,0 +1,92 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsUtils.hidePrefIfNoneVisible;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsUtils.populateOrHideListPreference;
+
+import android.os.Parcel;
+
+import androidx.preference.Preference;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class XiaomiSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ final Preference activityMonitoringPref = handler.findPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ACTIVITY_MONITORING);
+ if (activityMonitoringPref != null) {
+ activityMonitoringPref.setVisible(false);
+ }
+
+ final Preference hrAlertActivePref = handler.findPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD);
+ if (hrAlertActivePref != null) {
+ hrAlertActivePref.setVisible(false);
+ }
+
+ populateOrHideListPreference(HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE, handler, prefs);
+
+ hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_DISPLAY, Arrays.asList(
+ HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE,
+ DeviceSettingsPreferenceConst.PREF_SCREEN_PASSWORD
+ ));
+ hidePrefIfNoneVisible(handler, "pref_header_other", Arrays.asList(
+ "pref_contacts",
+ "camera_remote",
+ "screen_events_forwarding",
+ "phone_silent_mode"
+ ));
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ public static final Creator<XiaomiSettingsCustomizer> CREATOR = new Creator<XiaomiSettingsCustomizer>() {
+ @Override
+ public XiaomiSettingsCustomizer createFromParcel(final Parcel in) {
+ return new XiaomiSettingsCustomizer();
+ }
+
+ @Override
+ public XiaomiSettingsCustomizer[] newArray(final int size) {
+ return new XiaomiSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSleepStageSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSleepStageSampleProvider.java
new file mode 100644
index 0000000..f26bf3e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSleepStageSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiSleepStageSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class XiaomiSleepStageSampleProvider extends AbstractTimeSampleProvider<XiaomiSleepStageSample> {
+ public XiaomiSleepStageSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<XiaomiSleepStageSample, ?> getSampleDao() {
+ return getSession().getXiaomiSleepStageSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return XiaomiSleepStageSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return XiaomiSleepStageSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public XiaomiSleepStageSample createSample() {
+ return new XiaomiSleepStageSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSleepTimeSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSleepTimeSampleProvider.java
new file mode 100644
index 0000000..2254886
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSleepTimeSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiSleepTimeSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiSleepTimeSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class XiaomiSleepTimeSampleProvider extends AbstractTimeSampleProvider<XiaomiSleepTimeSample> {
+ public XiaomiSleepTimeSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao<XiaomiSleepTimeSample, ?> getSampleDao() {
+ return getSession().getXiaomiSleepTimeSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return XiaomiSleepTimeSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return XiaomiSleepTimeSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public XiaomiSleepTimeSample createSample() {
+ return new XiaomiSleepTimeSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSpo2SampleProvider.java
new file mode 100644
index 0000000..93dc821
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiSpo2SampleProvider.java
@@ -0,0 +1,66 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleToTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+
+public class XiaomiSpo2SampleProvider extends AbstractSampleToTimeSampleProvider<Spo2Sample, XiaomiActivitySample> {
+ public XiaomiSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ super(new XiaomiSampleProvider(device, session), device, session);
+ }
+
+ @Override
+ protected Spo2Sample convertSample(final XiaomiActivitySample sample) {
+ if (sample.getSpo2() == null || sample.getSpo2() == 0) {
+ return null;
+ }
+
+ return new XiaomiSpo2Sample(
+ sample.getTimestamp() * 1000L,
+ sample.getSpo2()
+ );
+ }
+
+ protected static class XiaomiSpo2Sample implements Spo2Sample {
+ private final long timestamp;
+ private final int spo2;
+
+ public XiaomiSpo2Sample(final long timestamp, final int spo2) {
+ this.timestamp = timestamp;
+ this.spo2 = spo2;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public Type getType() {
+ return Type.UNKNOWN;
+ }
+
+ @Override
+ public int getSpo2() {
+ return spo2;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiStressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiStressSampleProvider.java
new file mode 100644
index 0000000..2695ae9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiStressSampleProvider.java
@@ -0,0 +1,66 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleToTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+
+public class XiaomiStressSampleProvider extends AbstractSampleToTimeSampleProvider<StressSample, XiaomiActivitySample> {
+ public XiaomiStressSampleProvider(final GBDevice device, final DaoSession session) {
+ super(new XiaomiSampleProvider(device, session), device, session);
+ }
+
+ @Override
+ protected StressSample convertSample(final XiaomiActivitySample sample) {
+ if (sample.getStress() == null || sample.getStress() == 0) {
+ return null;
+ }
+
+ return new XiaomiStressSample(
+ sample.getTimestamp() * 1000L,
+ sample.getStress()
+ );
+ }
+
+ protected static class XiaomiStressSample implements StressSample {
+ private final long timestamp;
+ private final int stress;
+
+ public XiaomiStressSample(final long timestamp, final int stress) {
+ this.timestamp = timestamp;
+ this.stress = stress;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public Type getType() {
+ return Type.UNKNOWN;
+ }
+
+ @Override
+ public int getStress() {
+ return stress;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiTemperatureSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiTemperatureSampleProvider.java
new file mode 100644
index 0000000..78ce1cc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiTemperatureSampleProvider.java
@@ -0,0 +1,126 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiManualSample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+
+public class XiaomiTemperatureSampleProvider implements TimeSampleProvider<TemperatureSample> {
+ private final XiaomiManualSampleProvider manualSampleProvider;
+
+ public XiaomiTemperatureSampleProvider(final GBDevice device, final DaoSession session) {
+ manualSampleProvider = new XiaomiManualSampleProvider(device, session);
+ }
+
+ @NonNull
+ @Override
+ public List<TemperatureSample> getAllSamples(final long timestampFrom, final long timestampTo) {
+ final List<XiaomiManualSample> manualSamples = manualSampleProvider.getAllSamples(timestampFrom, timestampTo);
+
+ final List<TemperatureSample> temperatureSamples = new ArrayList<>();
+
+ for (final XiaomiManualSample manualSample : manualSamples) {
+ if (XiaomiManualSampleProvider.TYPE_TEMPERATURE == manualSample.getType()) {
+ temperatureSamples.add(new XiaomiTemperatureSample(manualSample));
+ }
+ }
+
+ return temperatureSamples;
+ }
+
+ @Override
+ public void addSample(final TemperatureSample timeSample) {
+ throw new UnsupportedOperationException("read-only sample provider");
+ }
+
+ @Override
+ public void addSamples(final List<TemperatureSample> timeSamples) {
+ throw new UnsupportedOperationException("read-only sample provider");
+ }
+
+ @Override
+ public TemperatureSample createSample() {
+ throw new UnsupportedOperationException("read-only sample provider");
+ }
+
+ @Nullable
+ @Override
+ public TemperatureSample getLatestSample() {
+ final XiaomiManualSample sample = manualSampleProvider.getLatestSample(XiaomiManualSampleProvider.TYPE_TEMPERATURE);
+ if (sample != null) {
+ return new XiaomiTemperatureSample(sample);
+ }
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public TemperatureSample getLatestSample(final long until) {
+ final XiaomiManualSample sample = manualSampleProvider.getLatestSample(XiaomiManualSampleProvider.TYPE_TEMPERATURE, until);
+ if (sample != null) {
+ return new XiaomiTemperatureSample(sample);
+ }
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public TemperatureSample getFirstSample() {
+ final XiaomiManualSample sample = manualSampleProvider.getFirstSample(XiaomiManualSampleProvider.TYPE_TEMPERATURE);
+ if (sample != null) {
+ return new XiaomiTemperatureSample(sample);
+ }
+ return null;
+ }
+
+ protected static class XiaomiTemperatureSample implements TemperatureSample {
+ private final long timestamp;
+ private final float temperature;
+
+ public XiaomiTemperatureSample(final XiaomiManualSample sample) {
+ this.timestamp = sample.getTimestamp();
+ // first 2 bytes are body temperature
+ // last 2 bytes are skin temperature
+ // since the body temperature seems to always be 0, we only display skin temperature
+ this.temperature = (sample.getValue() & 0xffff) / 100f;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public float getTemperature() {
+ return temperature;
+ }
+
+ @Override
+ public int getTemperatureType() {
+ return 0; // ?
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWeatherConditions.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWeatherConditions.java
new file mode 100644
index 0000000..d32cc12
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWeatherConditions.java
@@ -0,0 +1,186 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Yoran Vulker
+
+ 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.devices.xiaomi;
+
+public class XiaomiWeatherConditions {
+ // Most of these conditions are the same on Huami and ZeppOS (see HuamiWeatherConditions), but
+ // some have been renamed and a few were added. Nonetheless, some more detailed conditions,
+ // such as drizzle, tornado, and tropical storm, do not have an equivalent
+ // These conditions were mapped by sending the current condition and iterating over the condition
+ // code.
+ // At least the Xiaomi Watch S1 Active is known to crash if the condition cannot be mapped
+ // to an icon while shown (above 33), so take that into account when adding new conditions below.
+ public static final byte CLEAR_SKY = 0;
+ public static final byte CLOUDY = 1; // may appear with a moon on some models
+ public static final byte OVERCAST = 2;
+ public static final byte SHOWER = 3; // 'light rain' (two drops) on MB8P
+ public static final byte THUNDERSTORM = 4;
+ public static final byte HAIL = 5;
+ public static final byte SLEET = 6;
+ public static final byte LIGHT_RAIN = 7; // 'light rain' (two drops) on MB8P
+ public static final byte MODERATE_RAIN = 8; // 'moderate rain' (three drops) on MB8P
+ public static final byte HEAVY_RAINFALL = 9; // 'heavy rain' (four drops) on MB8P
+ public static final byte RAINSTORM = 10; // 'heavy rain' (four drops) on MB8P
+ public static final byte DOWNPOUR = 11; // 'heavy rain' (four drops) on MB8P
+ public static final byte HEAVY_RAINSTORM = 12; // 'heavy rain' (four drops) on MB8P
+ public static final byte SNOW_SHOWERS = 13; // 'light snow' (1 flake) on MB8P
+ public static final byte LIGHT_SNOW = 14; // 'light snow' (1 flake) on MB8P
+ public static final byte MODERATE_SNOW = 15; // 'moderate snow' (1 large and 1 small flake) on MB8P
+ public static final byte HEAVY_SNOW = 16; // 'heavy snow' (multiple flakes}) on MB8P
+ public static final byte BLIZZARD = 17; // 'heavy snow' (multiple flakes}) on MB8P
+ public static final byte MIST = 18;
+ public static final byte FREEZING_RAIN = 19; // 'sleet' on MB8P
+ public static final byte SANDSTORM = 20; // sandstorm
+ public static final byte LIGHT_TO_MODERATE_RAIN = 21; // 'moderate rain' on MB8P
+ public static final byte MODERATE_TO_HEAVY_RAIN = 22; // 'heavy rain' on MB8P
+ public static final byte HEAVY_RAIN_TO_RAINSTORM = 23; // 'heavy rain' on MB8P
+ public static final byte RAINSTORM_TO_DOWNPOUR = 24; // 'heavy rain' on MB8P
+ public static final byte DOWNPOUR_TO_HEAVY_RAINSTORM = 25; // 'heavy rain' on MB8P
+ public static final byte LIGHT_TO_MODERATE_SNOW = 26; // 'moderate snow' on MB8P
+ public static final byte MODERATE_TO_HEAVY_SNOW = 27; // 'heavy snow' on MB8P
+ public static final byte HEAVY_SNOW_TO_BLIZZARD = 28; // 'heavy snow' on MB8P
+ public static final byte DUST = 29; // 'sandstorm' on MB8P
+ public static final byte WINDY = 30; // 'sandstorm' on MB8P
+ public static final byte STRONG_SANDSTORM = 31; // 'sandstorm' on MB8P
+ public static final byte MODERATE_FOG = 32; // 'mist' on MB8P
+ public static final byte SNOW = 33; // 'light snow' on MB8P
+ // Please read the comment above before adding new condition codes here
+
+ public static byte convertOwmConditionToXiaomi(int openWeatherMapCondition) {
+ // openweathermap.org conditions:
+ // http://openweathermap.org/weather-conditions
+ switch (openWeatherMapCondition) {
+//Group 2xx: Thunderstorm
+ case 200: // thunderstorm with light rain
+ case 201: // thunderstorm with rain
+ case 202: // thunderstorm with heavy rain
+ case 210: // light thunderstorm
+ case 211: // thunderstorm
+ case 212: // heavy thunderstorm
+ case 221: // ragged thunderstorm
+ case 230: // thunderstorm with light drizzle
+ case 231: // thunderstorm with drizzle
+ case 232: // thunderstorm with heavy drizzle
+ return THUNDERSTORM;
+//Group 3xx: Drizzle
+ case 300: // light intensity drizzle
+ case 301: // drizzle
+ case 302: // heavy intensity drizzle
+ case 310: // light intensity drizzle rain
+ case 311: // drizzle rain
+ case 312: // heavy intensity drizzle rain
+ case 313: // shower rain and drizzle
+ case 314: // heavy shower rain and drizzle
+ case 321: // shower drizzle
+ // for lack of a better transcription, drizzle -> light rain
+//Group 5xx: Rain
+ case 500: // light rain
+ return LIGHT_RAIN;
+ case 501: // moderate rain
+ return MODERATE_RAIN;
+ case 502: // heavy intensity rain
+ return RAINSTORM;
+ case 503: // very heavy rain
+ return DOWNPOUR;
+ case 504: // extreme rain
+ return HEAVY_RAINSTORM;
+ case 511: // freezing rain
+ return FREEZING_RAIN;
+ case 520: // light intensity shower rain
+ case 521: // shower rain
+ return SHOWER;
+ case 522: // heavy intensity shower rain
+ case 531: // ragged shower rain
+ return HEAVY_RAINFALL;
+
+//Group 6xx: Snow
+ case 600: // light snow
+ return LIGHT_SNOW;
+ case 601: // snow
+ return MODERATE_SNOW;
+ case 602: // heavy snow
+ return HEAVY_SNOW;
+ case 611: // sleet
+ case 612: // shower sleet
+ return SLEET;
+ case 615: // light rain and snow
+ case 616: // rain and snow
+ return SNOW_SHOWERS;
+ case 620: // light shower snow
+ case 621: // shower snow
+ return HEAVY_SNOW;
+ case 622: // heavy shower snow
+ return BLIZZARD;
+
+//Group 7xx: Atmosphere
+ case 701: // mist
+ case 711: // smoke
+ case 721: // haze
+ case 731: // sand/dust whirls
+ case 741: // fog
+ return MIST;
+ case 751: // sand
+ return SANDSTORM;
+ case 761: // dust
+ case 762: // volcanic ash
+ case 771: // squalls
+ return DUST;
+ case 781: // tornado
+ case 900: // tornado
+ return WINDY;
+//Group 800: Clear
+ case 800: // clear sky
+ return CLEAR_SKY;
+//Group 80x: Clouds
+ case 801: // few clouds
+ case 802: // scattered clouds
+ case 803: // broken clouds
+ case 804: // overcast clouds
+ return OVERCAST;
+//Group 90x: Extreme
+ case 901: // tropical storm
+ return WINDY;
+ case 903: // cold
+ case 904: // hot
+ return CLEAR_SKY;
+ case 905: // windy
+ return WINDY;
+ case 906: // hail
+ return HAIL;
+//Group 9xx: Additional
+ case 951: // calm
+ case 952: // light breeze
+ case 953: // gentle breeze
+ case 954: // moderate breeze
+ case 955: // fresh breeze
+ return CLEAR_SKY;
+
+ case 956: // strong breeze
+ case 957: // high wind/near gale
+ case 958: // gale
+ case 959: // severe gale
+ case 960: // storm
+ case 961: // violent storm
+ case 902: // hurricane
+ case 962: // hurricane
+ return WINDY;
+
+ default:
+ return CLEAR_SKY;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWidgetManager.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWidgetManager.java
new file mode 100644
index 0000000..ccc46bf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWidgetManager.java
@@ -0,0 +1,525 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import android.text.TextUtils;
+
+import androidx.annotation.Nullable;
+
+import com.google.protobuf.InvalidProtocolBufferException;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+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.capabilities.widgets.WidgetLayout;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPart;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPartSubtype;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetScreen;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetType;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class XiaomiWidgetManager implements WidgetManager {
+ private static final Logger LOG = LoggerFactory.getLogger(XiaomiWidgetManager.class);
+
+ private final GBDevice device;
+
+ public XiaomiWidgetManager(final GBDevice device) {
+ this.device = device;
+ }
+
+ @Override
+ public List<WidgetLayout> getSupportedWidgetLayouts() {
+ final List<WidgetLayout> layouts = new ArrayList<>();
+
+ final XiaomiProto.WidgetScreens widgetScreens = getRawWidgetScreens();
+ if (!widgetScreens.hasWidgetsCapabilities() || !widgetScreens.getWidgetsCapabilities().hasSupportedLayoutStyles()) {
+ return Collections.emptyList();
+ }
+
+ final int supportedBitmap = getRawWidgetScreens().getWidgetsCapabilities().getSupportedLayoutStyles();
+
+ // highest known layout style is 0x4000 (1 << 14)
+ for (int i = 0; i < 15; i++) {
+ final int layoutStyleId = 1 << i;
+ if ((supportedBitmap & layoutStyleId) != 0) {
+ layouts.add(fromRawLayout(layoutStyleId));
+ }
+ }
+
+ return layouts;
+ }
+
+ private static Collection<WidgetPartSubtype> convertWorkoutTypesToPartSubtypes(final Collection<XiaomiWorkoutType> workoutTypes) {
+ final List<WidgetPartSubtype> subtypes = new ArrayList<>(workoutTypes.size());
+
+ // convert workout types to subtypes
+ for (final XiaomiWorkoutType workoutType : workoutTypes) {
+ subtypes.add(new WidgetPartSubtype(
+ String.valueOf(workoutType.getCode()),
+ workoutType.getName()
+ ));
+ }
+
+ // sort by name before returning
+ Collections.sort(subtypes, (it, other) -> it.getName().compareToIgnoreCase(other.getName()));
+
+ return subtypes;
+ }
+
+ @Override
+ public List<WidgetPart> getSupportedWidgetParts(final WidgetType targetWidgetType) {
+ final List<WidgetPart> parts = new LinkedList<>();
+
+ final XiaomiProto.WidgetParts rawWidgetParts = getRawWidgetParts();
+
+ final Set<String> seenNames = new HashSet<>();
+ final Set<String> duplicatedNames = new HashSet<>();
+
+ // get supported workout types and convert to subtypes for workout widgets
+ final Collection<WidgetPartSubtype> subtypes = convertWorkoutTypesToPartSubtypes(XiaomiWorkoutType.getWorkoutTypesSupportedByDevice(getDevice()));
+
+ for (final XiaomiProto.WidgetPart widgetPart : rawWidgetParts.getWidgetPartList()) {
+ final WidgetPart convertedPart = fromRawWidgetPart(widgetPart, subtypes);
+
+ if (convertedPart == null) {
+ continue;
+ }
+
+ if (!convertedPart.getType().equals(targetWidgetType)) {
+ continue;
+ }
+
+ final String convertedPartName = convertedPart.getName();
+ if (seenNames.contains(convertedPartName)) {
+ duplicatedNames.add(convertedPartName);
+ } else {
+ seenNames.add(convertedPartName);
+ }
+
+ parts.add(convertedPart);
+ seenNames.add(convertedPart.getName());
+ }
+
+ // Ensure that all names are unique
+ for (final WidgetPart part : parts) {
+ if (duplicatedNames.contains(part.getFullName())) {
+ part.setName(String.format(Locale.ROOT, "%s (%s)", part.getName(), part.getId()));
+ }
+ }
+
+ Collections.sort(parts, (it, other) -> it.getName().compareToIgnoreCase(other.getName()));
+ return parts;
+ }
+
+ private WidgetPart fromRawWidgetPart(final XiaomiProto.WidgetPart widgetPart, final Collection<WidgetPartSubtype> subtypes) {
+ final WidgetType type = fromRawWidgetType(widgetPart.getType());
+
+ if (type == null) {
+ LOG.warn("Unknown widget type {}", widgetPart.getType());
+ return null;
+ }
+
+ final String stringifiedId = String.valueOf(widgetPart.getId());
+ final WidgetPart convertedPart = new WidgetPart(
+ stringifiedId,
+ GBApplication.getContext().getString(R.string.widget_name_untitled, stringifiedId),
+ type
+ );
+
+ if (!TextUtils.isEmpty(widgetPart.getTitle())) {
+ convertedPart.setName(widgetPart.getTitle());
+ } else {
+ // some models do not provide the name of the widget in the screens list, resolve it here
+ final XiaomiProto.WidgetPart resolvedPart = findRawPart(widgetPart.getType(), widgetPart.getId());
+ if (resolvedPart != null) {
+ convertedPart.setName(resolvedPart.getTitle());
+ }
+ }
+
+ if (widgetPart.getFunction() == 16) {
+ if (StringUtils.isBlank(convertedPart.getName())) {
+ convertedPart.setName(GBApplication.getContext().getString(R.string.menuitem_workout));
+ }
+
+ if (subtypes != null) {
+ convertedPart.getSupportedSubtypes().addAll(subtypes);
+
+ if (widgetPart.getSubType() != 0) {
+ final String widgetSubtype = String.valueOf(widgetPart.getSubType());
+
+ for (final WidgetPartSubtype availableSubtype : subtypes) {
+ if (availableSubtype.getId().equals(widgetSubtype)) {
+ convertedPart.setSubtype(availableSubtype);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if ((widgetPart.getId() & 256) != 0) {
+ convertedPart.setName(GBApplication.getContext().getString(R.string.widget_name_colored_tile, convertedPart.getName()));
+ }
+
+ return convertedPart;
+ }
+
+ @Override
+ public List<WidgetScreen> getWidgetScreens() {
+ final XiaomiProto.WidgetScreens rawWidgetScreens = getRawWidgetScreens();
+
+ final List<WidgetScreen> convertedScreens = new ArrayList<>(rawWidgetScreens.getWidgetScreenCount());
+ final Collection<WidgetPartSubtype> workoutTypes = convertWorkoutTypesToPartSubtypes(XiaomiWorkoutType.getWorkoutTypesSupportedByDevice(getDevice()));
+
+ for (final XiaomiProto.WidgetScreen rawScreen : rawWidgetScreens.getWidgetScreenList()) {
+ final WidgetLayout layout = fromRawLayout(rawScreen.getLayout());
+
+ final List<WidgetPart> convertedParts = new ArrayList<>(rawScreen.getWidgetPartCount());
+
+ for (final XiaomiProto.WidgetPart rawPart : rawScreen.getWidgetPartList()) {
+ final WidgetPart convertedPart = fromRawWidgetPart(rawPart, workoutTypes);
+
+ if (convertedPart == null) {
+ LOG.warn("Widget cannot be converted, result was null for following raw widget: {}", rawPart);
+ continue;
+ }
+
+ convertedParts.add(convertedPart);
+ }
+
+ convertedScreens.add(new WidgetScreen(
+ String.valueOf(rawScreen.getId()),
+ layout,
+ convertedParts
+ ));
+ }
+
+ return convertedScreens;
+ }
+
+ @Override
+ public GBDevice getDevice() {
+ return device;
+ }
+
+ @Override
+ public int getMinScreens() {
+ return getRawWidgetScreens().getWidgetsCapabilities().getMinWidgets();
+ }
+
+ @Override
+ public int getMaxScreens() {
+ return getRawWidgetScreens().getWidgetsCapabilities().getMaxWidgets();
+ }
+
+ @Override
+ public void saveScreen(final WidgetScreen widgetScreen) {
+ final XiaomiProto.WidgetScreens rawWidgetScreens = getRawWidgetScreens();
+
+ final int layoutNum = toRawLayout(widgetScreen.getLayout());
+ if (layoutNum == -1) {
+ return;
+ }
+
+ XiaomiProto.WidgetScreen.Builder rawScreen = null;
+ if (widgetScreen.getId() == null) {
+ // new screen
+ rawScreen = XiaomiProto.WidgetScreen.newBuilder()
+ .setId(rawWidgetScreens.getWidgetScreenCount() + 1); // ids start at 1
+ } else {
+ for (final XiaomiProto.WidgetScreen screen : rawWidgetScreens.getWidgetScreenList()) {
+ if (String.valueOf(screen.getId()).equals(widgetScreen.getId())) {
+ rawScreen = XiaomiProto.WidgetScreen.newBuilder(screen);
+ break;
+ }
+
+ LOG.warn("Failed to find original screen for {}", widgetScreen.getId());
+ }
+
+ if (rawScreen == null) {
+ rawScreen = XiaomiProto.WidgetScreen.newBuilder()
+ .setId(rawWidgetScreens.getWidgetScreenCount() + 1);
+ }
+ }
+
+ rawScreen.setLayout(layoutNum);
+ rawScreen.clearWidgetPart();
+
+ final Collection<XiaomiWorkoutType> workoutTypes = XiaomiWorkoutType.getWorkoutTypesSupportedByDevice(getDevice());
+
+ for (final WidgetPart newPart : widgetScreen.getParts()) {
+ // Find the existing raw part
+ final XiaomiProto.WidgetPart knownRawPart = findRawPart(
+ toRawWidgetType(newPart.getType()),
+ Integer.parseInt(Objects.requireNonNull(newPart.getId()))
+ );
+
+ final XiaomiProto.WidgetPart.Builder newRawPartBuilder = XiaomiProto.WidgetPart.newBuilder(knownRawPart);
+
+ // TODO only support subtypes on widget with type 16
+ if (newPart.getSubtype() != null) {
+ try {
+ final int rawSubtype = Integer.parseInt(newPart.getSubtype().getId());
+
+ // Get the workout type as subtype
+ for (final XiaomiWorkoutType workoutType : workoutTypes) {
+ if (rawSubtype == workoutType.getCode()) {
+ newRawPartBuilder.setSubType(workoutType.getCode());
+ break;
+ }
+ }
+ } catch (final NumberFormatException ex) {
+ LOG.error("Failed to convert workout type {} to a number, defaulting to 1", newPart.getSubtype());
+ newRawPartBuilder.setSubType(1);
+ }
+ }
+
+ rawScreen.addWidgetPart(newRawPartBuilder);
+ }
+
+ final XiaomiProto.WidgetScreens.Builder builder = XiaomiProto.WidgetScreens.newBuilder(rawWidgetScreens);
+ if (rawScreen.getId() == rawWidgetScreens.getWidgetScreenCount() + 1) {
+ // Append at the end
+ builder.addWidgetScreen(rawScreen);
+ } else {
+ // Replace existing
+ builder.clearWidgetScreen();
+
+ for (final XiaomiProto.WidgetScreen screen : rawWidgetScreens.getWidgetScreenList()) {
+ if (screen.getId() == rawScreen.getId()) {
+ builder.addWidgetScreen(rawScreen);
+ } else {
+ builder.addWidgetScreen(screen);
+ }
+ }
+ }
+
+ builder.setIsFullList(1);
+
+ getPrefs().getPreferences().edit()
+ .putString(XiaomiPreferences.PREF_WIDGET_SCREENS, GB.hexdump(builder.build().toByteArray()))
+ .apply();
+ }
+
+ @Override
+ public void deleteScreen(final WidgetScreen widgetScreen) {
+ if (widgetScreen.getId() == null) {
+ LOG.warn("Can't delete screen without id");
+ return;
+ }
+
+ final XiaomiProto.WidgetScreens rawWidgetScreens = getRawWidgetScreens();
+
+ final XiaomiProto.WidgetScreens.Builder builder = XiaomiProto.WidgetScreens.newBuilder(rawWidgetScreens)
+ .clearWidgetScreen();
+
+ int i = 1;
+ for (final XiaomiProto.WidgetScreen screen : rawWidgetScreens.getWidgetScreenList()) {
+ if (String.valueOf(screen.getId()).equals(widgetScreen.getId())) {
+ continue;
+ }
+
+ // Ensure the IDs stay sequential and start at 1
+ builder.addWidgetScreen(
+ XiaomiProto.WidgetScreen.newBuilder()
+ .mergeFrom(screen)
+ .setId(i++)
+ .build()
+ );
+ }
+
+ getPrefs().getPreferences().edit()
+ .putString(XiaomiPreferences.PREF_WIDGET_SCREENS, GB.hexdump(builder.build().toByteArray()))
+ .apply();
+ }
+
+ @Override
+ public void sendToDevice() {
+ GBApplication.deviceService(getDevice()).onSendConfiguration(DeviceSettingsPreferenceConst.PREF_WIDGETS);
+ }
+
+ private Prefs getPrefs() {
+ return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()));
+ }
+
+ @Nullable
+ private WidgetType fromRawWidgetType(final int rawType) {
+ switch (rawType) {
+ case 1:
+ return WidgetType.SMALL;
+ case 2:
+ return WidgetType.WIDE;
+ case 3:
+ return WidgetType.TALL;
+ case 4:
+ return WidgetType.LARGE;
+ case 5:
+ return WidgetType.PORTRAIT_LARGE;
+ default:
+ LOG.warn("Unknown widget type {}", rawType);
+ return null;
+ }
+ }
+
+ private int toRawWidgetType(final WidgetType widgetType) {
+ switch (widgetType) {
+ case SMALL:
+ return 1;
+ case WIDE:
+ return 2;
+ case TALL:
+ return 3;
+ case LARGE:
+ return 4;
+ case PORTRAIT_LARGE:
+ return 5;
+ default:
+ throw new IllegalArgumentException("Unknown widget type " + widgetType);
+ }
+ }
+
+ @Nullable
+ private WidgetLayout fromRawLayout(final int rawLayout) {
+ switch (rawLayout) {
+ case 1: // 2x2, top 2x small, bottom 2x small
+ return WidgetLayout.TOP_2_BOT_2;
+ case 2: // 2x2, top wide, bottom 2x small
+ return WidgetLayout.TOP_1_BOT_2;
+ case 4: // 2x2, top 2x small, bottom wide
+ return WidgetLayout.TOP_2_BOT_1;
+ case 128: // 2x2, full screen
+ return WidgetLayout.TWO_BY_TWO_SINGLE;
+ case 256: // 1x2, top small, bottom small
+ return WidgetLayout.TWO;
+ case 512: // 1x2, full screen
+ return WidgetLayout.ONE_BY_TWO_SINGLE;
+ case 1024: // 2x3, top 2x small, bottom 2x2 square
+ return WidgetLayout.TOP_2_BOT_2X2;
+ case 2048: // 2x3, top 2x2 square, bottom 2x small
+ return WidgetLayout.TOP_2X2_BOT_2;
+ case 4096: // 2x3, top wide, bottom 2x2 small
+ return WidgetLayout.TOP_1_BOT_2X2;
+ case 8192: // 2x3, top 2x2 small, bottom wide
+ return WidgetLayout.TOP_2X2_BOT_1;
+ case 16384: // 2x3, full screen
+ return WidgetLayout.TWO_BY_THREE_SINGLE;
+ case 8: // 2x2, left tall, right 2x small
+ case 16: // 2x2, left 2x small, right tall
+ case 32: // 2x2, top wide, bottom wide
+ case 64: // 2x2, left tall, right tall
+ default:
+ LOG.warn("Unknown widget screens layout {}", rawLayout);
+ return null;
+ }
+ }
+
+ private int toRawLayout(final WidgetLayout layout) {
+ if (layout == null) {
+ return -1;
+ }
+
+ switch (layout) {
+ case TOP_2_BOT_2:
+ return 1;
+ case TOP_1_BOT_2:
+ return 2;
+ case TOP_2_BOT_1:
+ return 4;
+ case TWO_BY_TWO_SINGLE:
+ return 128;
+ case TWO:
+ return 256;
+ case ONE_BY_TWO_SINGLE:
+ return 512;
+ case TOP_2_BOT_2X2:
+ return 1024;
+ case TOP_2X2_BOT_2:
+ return 2048;
+ case TOP_1_BOT_2X2:
+ return 4096;
+ case TOP_2X2_BOT_1:
+ return 8192;
+ case TWO_BY_THREE_SINGLE:
+ return 16384;
+ default:
+ LOG.warn("Widget layout {} cannot be converted to raw variant", layout);
+ return -1;
+ }
+ }
+
+ @Nullable
+ private XiaomiProto.WidgetPart findRawPart(final int type, final int id) {
+ final XiaomiProto.WidgetParts rawWidgetParts = getRawWidgetParts();
+
+ for (final XiaomiProto.WidgetPart rawPart : rawWidgetParts.getWidgetPartList()) {
+ if (rawPart.getType() == type && rawPart.getId() == id) {
+ return rawPart;
+ }
+ }
+
+ return null;
+ }
+
+ private XiaomiProto.WidgetScreens getRawWidgetScreens() {
+ final String hex = getPrefs().getString(XiaomiPreferences.PREF_WIDGET_SCREENS, null);
+ if (hex == null) {
+ LOG.warn("raw widget screens hex is null");
+ return XiaomiProto.WidgetScreens.newBuilder().build();
+ }
+
+ try {
+ return XiaomiProto.WidgetScreens.parseFrom(GB.hexStringToByteArray(hex));
+ } catch (final InvalidProtocolBufferException e) {
+ LOG.warn("failed to parse raw widget screns hex");
+ return XiaomiProto.WidgetScreens.newBuilder().build();
+ }
+ }
+
+ private XiaomiProto.WidgetParts getRawWidgetParts() {
+ final String hex = getPrefs().getString(XiaomiPreferences.PREF_WIDGET_PARTS, null);
+ if (hex == null) {
+ LOG.warn("raw widget parts hex is null");
+ return XiaomiProto.WidgetParts.newBuilder().build();
+ }
+
+ try {
+ return XiaomiProto.WidgetParts.parseFrom(GB.hexStringToByteArray(hex));
+ } catch (final InvalidProtocolBufferException e) {
+ LOG.warn("failed to parse raw widget parts hex");
+ return XiaomiProto.WidgetParts.newBuilder().build();
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWorkoutType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWorkoutType.java
new file mode 100644
index 0000000..0141710
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWorkoutType.java
@@ -0,0 +1,89 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
+
+import androidx.annotation.StringRes;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class XiaomiWorkoutType {
+ private final int code;
+ private final String name;
+
+ public XiaomiWorkoutType(final int code, final String name) {
+ this.code = code;
+ this.name = name;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ @StringRes
+ public static int mapWorkoutName(final int code) {
+ switch (code) {
+ case 1:
+ return R.string.activity_type_outdoor_running;
+ case 2:
+ return R.string.activity_type_walking;
+ case 3:
+ return R.string.activity_type_hiking;
+ case 4:
+ return R.string.activity_type_trekking;
+ case 5:
+ return R.string.activity_type_trail_run;
+ case 6:
+ return R.string.activity_type_outdoor_cycling;
+ case 501:
+ return R.string.activity_type_wrestling;
+ }
+
+ return -1;
+ }
+
+ public static Collection<XiaomiWorkoutType> getWorkoutTypesSupportedByDevice(final GBDevice device) {
+ final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
+ final List<String> codes = prefs.getList(XiaomiPreferences.PREF_WORKOUT_TYPES, Collections.emptyList());
+ final List<XiaomiWorkoutType> ret = new ArrayList<>(codes.size());
+
+ for (final String code : codes) {
+ final int codeInt = Integer.parseInt(code);
+ final int codeNameStringRes = XiaomiWorkoutType.mapWorkoutName(codeInt);
+ ret.add(new XiaomiWorkoutType(
+ codeInt,
+ codeNameStringRes != -1 ?
+ GBApplication.getContext().getString(codeNameStringRes) :
+ GBApplication.getContext().getString(R.string.widget_unknown_workout, code)
+ ));
+ }
+
+ return ret;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband7pro/MiBand7ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband7pro/MiBand7ProCoordinator.java
new file mode 100644
index 0000000..c96c803
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband7pro/MiBand7ProCoordinator.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband7pro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class MiBand7ProCoordinator extends XiaomiCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile(HuamiConst.XIAOMI_SMART_BAND7_PRO_NAME + ".*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband7pro;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_default;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_default_disabled;
+ }
+
+ @Override
+ public boolean supportsPai() {
+ // no PAI nor vitality score
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8/MiBand8Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8/MiBand8Coordinator.java
new file mode 100644
index 0000000..db608a3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8/MiBand8Coordinator.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class MiBand8Coordinator extends XiaomiCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Xiaomi Smart Band 8 [A-Z0-9]{4}$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband8;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband6;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband6_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8active/MiBand8ActiveCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8active/MiBand8ActiveCoordinator.java
new file mode 100644
index 0000000..a0ce84e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8active/MiBand8ActiveCoordinator.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8active;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class MiBand8ActiveCoordinator extends XiaomiCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Xiaomi( Smart)? Band 8 Active [A-Z0-9]{4}$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband8active;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_default;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_default_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8pro/MiBand8ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8pro/MiBand8ProCoordinator.java
new file mode 100644
index 0000000..567def8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband8pro/MiBand8ProCoordinator.java
@@ -0,0 +1,55 @@
+/* Copyright (C) 2023 Yoran Vulker
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8pro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class MiBand8ProCoordinator extends XiaomiCoordinator {
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband8pro;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Xiaomi Smart Band 8 Pro [0-9A-F]{4}$");
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BT_CLASSIC;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_default;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_default_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband9/MiBand9Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband9/MiBand9Coordinator.java
new file mode 100644
index 0000000..e096baf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband9/MiBand9Coordinator.java
@@ -0,0 +1,54 @@
+/* Copyright (C) 2024 Yoran Vulker
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband9;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class MiBand9Coordinator extends XiaomiCoordinator {
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband9;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Xiaomi Smart Band 9 [0-9A-F]{4}$");
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miband6;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miband6_disabled;
+ }
+
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BT_CLASSIC;
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband9pro/MiBand9ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband9pro/MiBand9ProCoordinator.java
new file mode 100644
index 0000000..f4e9b99
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miband9pro/MiBand9ProCoordinator.java
@@ -0,0 +1,55 @@
+/* Copyright (C) 2023 Yoran Vulker
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband9pro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class MiBand9ProCoordinator extends XiaomiCoordinator {
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_miband9pro;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Xiaomi Smart Band 9 Pro [0-9A-F]{4}$");
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BT_CLASSIC;
+ }
+
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_default;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_default_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miwatch/MiWatchLiteCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miwatch/MiWatchLiteCoordinator.java
new file mode 100644
index 0000000..6730592
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miwatch/MiWatchLiteCoordinator.java
@@ -0,0 +1,70 @@
+/* Copyright (C) 2023-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatch;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class MiWatchLiteCoordinator extends XiaomiCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Mi Watch Lite_[A-Z0-9]{4}$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_xiaomi_watch_lite;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miwatchcolorsport/MiWatchColorSportCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miwatchcolorsport/MiWatchColorSportCoordinator.java
new file mode 100644
index 0000000..d5f7e02
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/miwatchcolorsport/MiWatchColorSportCoordinator.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatchcolorsport;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class MiWatchColorSportCoordinator extends XiaomiCoordinator {
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_mi_watch_color_sport;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Mi ColorS [0-9A-Z]{4}$");
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miwatch;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miwatch_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/RedmiBuds5ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/RedmiBuds5ProCoordinator.java
new file mode 100644
index 0000000..e9b5fdf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/RedmiBuds5ProCoordinator.java
@@ -0,0 +1,103 @@
+/* Copyright (C) 2024 Jonathan Gobbo
+
+ 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.devices.xiaomi.redmibuds5pro;
+
+import androidx.annotation.NonNull;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.redmibuds5pro.RedmiBuds5ProDeviceSupport;
+
+public class RedmiBuds5ProCoordinator extends AbstractDeviceCoordinator {
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Xiaomi";
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return RedmiBuds5ProDeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_redmi_buds_5_pro;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("Redmi Buds 5 Pro");
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public int getBatteryCount() {
+ return 3;
+ }
+
+ @Override
+ public BatteryConfig[] getBatteryConfig(GBDevice device) {
+ BatteryConfig battery1 = new BatteryConfig(0, R.drawable.ic_tws_case, R.string.battery_case);
+ BatteryConfig battery2 = new BatteryConfig(1, R.drawable.ic_nothing_ear_l, R.string.left_earbud);
+ BatteryConfig battery3 = new BatteryConfig(2, R.drawable.ic_nothing_ear_r, R.string.right_earbud);
+ return new BatteryConfig[]{battery1, battery2, battery3};
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_redmibuds5pro_headphones);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_redmibuds5pro_gestures);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_redmibuds5pro_sound);
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new RedmiBuds5ProSettingsCustomizer(device);
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_nothingear;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_nothingear_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/RedmiBuds5ProSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/RedmiBuds5ProSettingsCustomizer.java
new file mode 100644
index 0000000..9d0e5c9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/RedmiBuds5ProSettingsCustomizer.java
@@ -0,0 +1,161 @@
+/* Copyright (C) 2024 Jonathan Gobbo
+
+ 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.devices.xiaomi.redmibuds5pro;
+
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class RedmiBuds5ProSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+
+ final GBDevice device;
+
+ public RedmiBuds5ProSettingsCustomizer(final GBDevice device) {
+ this.device = device;
+ }
+
+
+ @Override
+ public void onPreferenceChange(Preference preference, DeviceSpecificSettingsHandler handler) {
+
+ // Hide or Show ANC/Transparency settings according to the current ambient sound control mode
+ if (preference.getKey().equals(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_AMBIENT_SOUND_CONTROL)) {
+ String mode = ((ListPreference) preference).getValue();
+ final Preference ancLevel = handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_NOISE_CANCELLING_STRENGTH);
+ final Preference transparencyLevel = handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_TRANSPARENCY_STRENGTH);
+ final Preference adaptiveAnc = handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_ADAPTIVE_NOISE_CANCELLING);
+// final Preference customizedAnc = handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_PERSONALIZED_NOISE_CANCELLING);
+ if (ancLevel != null) {
+ ancLevel.setVisible(mode.equals("1"));
+ }
+ if (transparencyLevel != null) {
+ transparencyLevel.setVisible(mode.equals("2"));
+ }
+ if (adaptiveAnc != null) {
+ adaptiveAnc.setVisible(mode.equals("1"));
+ }
+// if (customizedAnc != null) {
+// customizedAnc.setVisible(mode.equals("1"));
+// }
+ }
+ }
+
+ @Override
+ public void customizeSettings(DeviceSpecificSettingsHandler handler, Prefs prefs, String rootKey) {
+
+ final ListPreference longPressLeft = handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_MODE_LEFT);
+ final ListPreference longPressRight = handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_MODE_RIGHT);
+
+ final Preference longPressLeftSettings = handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_SETTINGS_LEFT);
+ final Preference longPressRightSettings = handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_SETTINGS_RIGHT);
+
+ final ListPreference equalizerPreset = handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_PRESET);
+
+ if (longPressLeft != null) {
+ final Preference.OnPreferenceChangeListener longPressLeftButtonListener = (preference, newVal) -> {
+ String mode = newVal.toString();
+ if (longPressLeftSettings != null) {
+ longPressLeftSettings.setVisible(mode.equals("6"));
+ }
+ return true;
+ };
+ longPressLeftButtonListener.onPreferenceChange(longPressLeft, prefs.getString(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_MODE_LEFT, "6"));
+ handler.addPreferenceHandlerFor(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_MODE_LEFT, longPressLeftButtonListener);
+ }
+ if (longPressRight != null) {
+ final Preference.OnPreferenceChangeListener longPressRightButtonListener = (preference, newVal) -> {
+ String mode = newVal.toString();
+ if (longPressRightSettings != null) {
+ longPressRightSettings.setVisible(mode.equals("6"));
+ }
+ return true;
+ };
+ longPressRightButtonListener.onPreferenceChange(longPressRight, prefs.getString(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_MODE_RIGHT, "6"));
+ handler.addPreferenceHandlerFor(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_CONTROL_LONG_TAP_MODE_RIGHT, longPressRightButtonListener);
+ }
+
+ if (equalizerPreset != null) {
+
+ final Preference.OnPreferenceChangeListener equalizerPresetListener = (preference, newVal) -> {
+
+ final List<Preference> prefsToDisable = Arrays.asList(
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_62),
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_125),
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_250),
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_500),
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_1k),
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_2k),
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_4k),
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_8k),
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_12k),
+ handler.findPreference(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_BAND_16k)
+ );
+
+ String mode = newVal.toString();
+ for (Preference pref : prefsToDisable) {
+ if (pref != null) {
+ pref.setEnabled(mode.equals("10"));
+ }
+ }
+ return true;
+ };
+ equalizerPresetListener.onPreferenceChange(equalizerPreset, prefs.getString(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_PRESET, "0"));
+ handler.addPreferenceHandlerFor(DeviceSettingsPreferenceConst.PREF_REDMI_BUDS_5_PRO_EQUALIZER_PRESET, equalizerPresetListener);
+ }
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel parcel, int i) {
+ parcel.writeParcelable(device, 0);
+ }
+
+ public static final Creator<RedmiBuds5ProSettingsCustomizer> CREATOR = new Creator<RedmiBuds5ProSettingsCustomizer>() {
+ @Override
+ public RedmiBuds5ProSettingsCustomizer createFromParcel(final Parcel in) {
+ final GBDevice device = in.readParcelable(RedmiBuds5ProSettingsCustomizer.class.getClassLoader());
+ return new RedmiBuds5ProSettingsCustomizer(device);
+ }
+
+ @Override
+ public RedmiBuds5ProSettingsCustomizer[] newArray(final int size) {
+ return new RedmiBuds5ProSettingsCustomizer[size];
+ }
+ };
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/prefs/Configuration.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/prefs/Configuration.java
new file mode 100644
index 0000000..e42ada1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/prefs/Configuration.java
@@ -0,0 +1,47 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmibuds5pro.prefs;
+
+public class Configuration {
+
+ public enum Config {
+ GESTURES((byte) 0x02),
+ AUTO_ANSWER((byte) 0x03),
+ DOUBLE_CONNECTION((byte) 0x04),
+ EAR_DETECTION((byte) 0x06),
+ EQ_PRESET((byte) 0x07),
+ LONG_GESTURES((byte) 0x0a),
+ EFFECT_STRENGTH((byte) 0x0b),
+ ADAPTIVE_ANC((byte) 0x25),
+ ADAPTIVE_SOUND((byte) 0x29),
+ EQ_CURVE((byte) 0x37),
+ CUSTOMIZED_ANC((byte) 0x3b),
+ UNKNOWN((byte) 0xff);
+
+ public final byte value;
+
+ Config(byte value) {
+ this.value = value;
+ }
+
+ public static Config fromCode(final byte code) {
+ for (final Config config : values()) {
+ if (config.value == code) {
+ return config;
+ }
+ }
+ return Config.UNKNOWN;
+ }
+
+ }
+
+ public enum StrengthTarget {
+ ANC((byte) 0x01),
+ TRANSPARENCY((byte) 0x02);
+
+ public final byte value;
+
+ StrengthTarget(byte value) {
+ this.value = value;
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/prefs/Gestures.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/prefs/Gestures.java
new file mode 100644
index 0000000..e7b8971
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmibuds5pro/prefs/Gestures.java
@@ -0,0 +1,22 @@
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmibuds5pro.prefs;
+
+public class Gestures {
+
+ public enum InteractionType {
+ SINGLE((byte) 0x04),
+ DOUBLE((byte) 0x01),
+ TRIPLE((byte) 0x02),
+ LONG((byte) 0x03);
+
+ public final byte value;
+
+ InteractionType(byte value) {
+ this.value = value;
+ }
+ }
+
+ public enum Position {
+ LEFT,
+ RIGHT
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmismartband2/RedmiSmartBand2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmismartband2/RedmiSmartBand2Coordinator.java
new file mode 100644
index 0000000..1ed86dd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmismartband2/RedmiSmartBand2Coordinator.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmismartband2;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class RedmiSmartBand2Coordinator extends XiaomiCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Redmi Smart Band 2 [A-Z0-9]{4}$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_redmi_smart_band_2;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_default;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_default_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmismartbandpro/RedmiSmartBandProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmismartbandpro/RedmiSmartBandProCoordinator.java
new file mode 100644
index 0000000..cc6fd55
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmismartbandpro/RedmiSmartBandProCoordinator.java
@@ -0,0 +1,68 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmismartbandpro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class RedmiSmartBandProCoordinator extends XiaomiCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Redmi Band Pro [A-Z0-9]{4}$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_redmi_smart_band_pro;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_default;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_default_disabled;
+ }
+
+ @Override
+ public PasswordCapabilityImpl.Mode getPasswordCapability() {
+ return PasswordCapabilityImpl.Mode.NUMBERS_4_DIGITS_0_TO_9;
+ }
+
+ @Override
+ public boolean checkDecryptionMac() {
+ // Sometimes it fails to decrypt with an invalid mac
+ // https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/3766
+ return false;
+ }
+
+ @Override
+ public boolean supportsAlarms() {
+ // Crashes when requesting alarms
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch2/RedmiWatch2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch2/RedmiWatch2Coordinator.java
new file mode 100644
index 0000000..e85485b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch2/RedmiWatch2Coordinator.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch2;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class RedmiWatch2Coordinator extends XiaomiCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Redmi Watch 2 [A-Z0-9]{4}$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_redmi_watch_2;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch2lite/RedmiWatch2LiteCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch2lite/RedmiWatch2LiteCoordinator.java
new file mode 100644
index 0000000..baccce9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch2lite/RedmiWatch2LiteCoordinator.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch2lite;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class RedmiWatch2LiteCoordinator extends XiaomiCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Redmi Watch 2 Lite [A-Z0-9]{4}$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_redmi_watch_2_lite;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch3/RedmiWatch3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch3/RedmiWatch3Coordinator.java
new file mode 100644
index 0000000..87c8b06
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch3/RedmiWatch3Coordinator.java
@@ -0,0 +1,60 @@
+/* Copyright (C) 2023-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch3;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class RedmiWatch3Coordinator extends XiaomiCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BOTH;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Redmi Watch 3 [A-Z0-9]{4}$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_redmiwatch3;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+
+ @Override
+ public int getContactsSlotCount(final GBDevice device) {
+ return 10; // TODO:verify
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch3active/RedmiWatch3ActiveCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch3active/RedmiWatch3ActiveCoordinator.java
new file mode 100644
index 0000000..4df03f9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch3active/RedmiWatch3ActiveCoordinator.java
@@ -0,0 +1,60 @@
+/* Copyright (C) 2023-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch3active;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class RedmiWatch3ActiveCoordinator extends XiaomiCoordinator {
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BOTH;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Redmi Watch 3 Active [A-Z0-9]{4}$");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_redmiwatch3active;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+
+ @Override
+ public int getContactsSlotCount(final GBDevice device) {
+ return 10; // TODO:verify
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch4/RedmiWatch4Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch4/RedmiWatch4Coordinator.java
new file mode 100644
index 0000000..4a01980
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch4/RedmiWatch4Coordinator.java
@@ -0,0 +1,61 @@
+/* Copyright (C) 2024 Yoran Vulker
+
+ 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.devices.xiaomi.redmiwatch4;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class RedmiWatch4Coordinator extends XiaomiCoordinator {
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_redmi_watch_4;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Redmi Watch 4 [0-9A-F]{4}$");
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public ConnectionType getConnectionType() {
+ // this device likely supports both connection types if we indicate
+ // that we are an iOS device in the final auth step
+ return ConnectionType.BT_CLASSIC;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+
+ @Override
+ public int getWorldClocksSlotCount() {
+ return 20;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch5active/RedmiWatch5ActiveCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch5active/RedmiWatch5ActiveCoordinator.java
new file mode 100644
index 0000000..2268f5a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch5active/RedmiWatch5ActiveCoordinator.java
@@ -0,0 +1,55 @@
+/* Copyright (C) 2024 Yoran Vulker
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch5active;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class RedmiWatch5ActiveCoordinator extends XiaomiCoordinator {
+
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BT_CLASSIC;
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_redmi_watch_5_active;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Redmi Watch 5 Active [0-9A-F]{4}$");
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch5lite/RedmiWatch5LiteCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch5lite/RedmiWatch5LiteCoordinator.java
new file mode 100644
index 0000000..0d520b0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/redmiwatch5lite/RedmiWatch5LiteCoordinator.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch5lite;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class RedmiWatch5LiteCoordinator extends XiaomiCoordinator {
+
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BT_CLASSIC;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_redmi_watch_5_lite;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Redmi Watch 5 Lite [0-9A-F]{4}$");
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1/XiaomiWatchS1Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1/XiaomiWatchS1Coordinator.java
new file mode 100644
index 0000000..a557e8c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1/XiaomiWatchS1Coordinator.java
@@ -0,0 +1,61 @@
+/* Copyright (C) 2023 Yoran Vulker
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs1;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class XiaomiWatchS1Coordinator extends XiaomiCoordinator {
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_xiaomi_watch_s1;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miwatch;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miwatch_disabled;
+ }
+
+ @Override
+ public ConnectionType getConnectionType() {
+ // TODO make sure that the device can actually communicate in BLE mode
+ return ConnectionType.BOTH;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Xiaomi Watch S1 [0-9A-F]{4}$");
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1active/XiaomiWatchS1ActiveCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1active/XiaomiWatchS1ActiveCoordinator.java
new file mode 100644
index 0000000..602d5b9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1active/XiaomiWatchS1ActiveCoordinator.java
@@ -0,0 +1,66 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo, Yoran Vulker
+
+ 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.devices.xiaomi.watchs1active;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.Nullable;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class XiaomiWatchS1ActiveCoordinator extends XiaomiCoordinator {
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_xiaomi_watch_s1_active;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^XiaomiWatchS1Active [0-9A-Z]{4}$");
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BOTH;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miwatch;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miwatch_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1pro/XiaomiWatchS1ProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1pro/XiaomiWatchS1ProCoordinator.java
new file mode 100644
index 0000000..e06e057
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs1pro/XiaomiWatchS1ProCoordinator.java
@@ -0,0 +1,66 @@
+/* Copyright (C) 2023 Yoran Vulker
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs1pro;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class XiaomiWatchS1ProCoordinator extends XiaomiCoordinator {
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ // TODO confirm that the secondary name is actually used in prod somewhere
+ return Pattern.compile("^(Xiaomi Watch S1 Pro [0-9A-F]{4}|[Ll]61.*[0-9A-F]{4})$");
+ }
+
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BT_CLASSIC;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_xiaomi_watch_s1_pro;
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miwatch;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miwatch_disabled;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsTemperatureMeasurement() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs3/XiaomiWatchS3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs3/XiaomiWatchS3Coordinator.java
new file mode 100644
index 0000000..d13a40c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/watchs3/XiaomiWatchS3Coordinator.java
@@ -0,0 +1,55 @@
+/* Copyright (C) 2023 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs3;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
+
+public class XiaomiWatchS3Coordinator extends XiaomiCoordinator {
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_xiaomi_watch_s3;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miwatch;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_miwatch_disabled;
+ }
+
+ @Override
+ public ConnectionType getConnectionType() {
+ return ConnectionType.BT_CLASSIC;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ // only saw it with eSIM in #3506, but allowing both just in case
+ return Pattern.compile("^Xiaomi Watch S3( eSIM)? [0-9A-F]{4}$");
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchCoordinator.java
new file mode 100644
index 0000000..af293b3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchCoordinator.java
@@ -0,0 +1,141 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Daniele Gobbetti, Jos茅 Rebelo, ladbsoft, Petr Van臎k
+
+ 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.devices.xwatch;
+
+import android.app.Activity;
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.xwatch.XWatchSupport;
+
+public class XWatchCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("XWatch.*");
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+
+ }
+
+ @Nullable
+ @Override
+ public Class<? extends Activity> getPairingActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new XWatchSampleProvider(device, session);
+ }
+
+ @Override
+ public InstallHandler findInstallHandler(Uri uri, Context context) {
+ return null;
+ }
+
+ @Override
+ public boolean supportsScreenshots(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmSlotCount(GBDevice device) {
+ return 0;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Generic";
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public Class<? extends Activity> getAppsManagementActivity() {
+ return null;
+ }
+
+ @Override
+ public boolean supportsCalendarEvents() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return XWatchSupport.class;
+ }
+
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_xwatch;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchSampleProvider.java
new file mode 100644
index 0000000..164ca23
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchSampleProvider.java
@@ -0,0 +1,80 @@
+/* Copyright (C) 2018-2024 Daniele Gobbetti, ladbsoft
+
+ 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.devices.xwatch;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.XWatchActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.XWatchActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+//TODO: extend own class
+public class XWatchSampleProvider extends AbstractSampleProvider<XWatchActivitySample> {
+ public static final int TYPE_ACTIVITY = -1;
+
+ public XWatchSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.ACTIVITY;
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return TYPE_ACTIVITY;
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / 180.0f;
+ }
+
+ @Override
+ public XWatchActivitySample createActivitySample() {
+ return new XWatchActivitySample();
+ }
+
+ @Override
+ public AbstractDao<XWatchActivitySample, ?> getSampleDao() {
+ return getSession().getXWatchActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return XWatchActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return XWatchActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return XWatchActivitySampleDao.Properties.DeviceId;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchService.java
new file mode 100644
index 0000000..8d764fd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xwatch/XWatchService.java
@@ -0,0 +1,53 @@
+/* Copyright (C) 2018-2024 ladbsoft, SalavatR
+
+ 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.devices.xwatch;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+public class XWatchService {
+ public static final UUID UUID_NOTIFY = UUID.fromString("0000fff7-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_SERVICE = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_WRITE = UUID.fromString("0000fff6-0000-1000-8000-00805f9b34fb");
+
+ public static final byte COMMAND_CONNECTED = 0x01;
+ public static final byte COMMAND_ACTION_BUTTON = 0x4c;
+ public static final byte COMMAND_ACTIVITY_DATA = 0x43;
+ public static final byte COMMAND_ACTIVITY_TOTALS = 0x46;
+ public static final byte COMMAND_NOTIFICATION = 0x4d;
+ public static final byte COMMAND_NOTIFICATION_PHONE = 0x00;
+ public static final byte COMMAND_NOTIFICATION_MESSAGE = 0x01;
+
+ private static final Map<UUID, String> XWATCH_DEBUG;
+
+ static {
+ XWATCH_DEBUG = new HashMap<>();
+
+ XWATCH_DEBUG.put(UUID_NOTIFY, "Read data");
+ XWATCH_DEBUG.put(UUID_WRITE, "Write data");
+ XWATCH_DEBUG.put(UUID_SERVICE, "Get service");
+ }
+
+ public static String lookup(UUID uuid, String fallback) {
+ String name = XWATCH_DEBUG.get(uuid);
+ if (name == null) {
+ name = fallback;
+ }
+ return name;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeConstants.java
new file mode 100644
index 0000000..af97b08
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeConstants.java
@@ -0,0 +1,156 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Damien Gaignon, Sebastian Kranz
+
+ 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.devices.zetime;
+
+import java.util.UUID;
+
+public class ZeTimeConstants {
+ public static final UUID UUID_WRITE_CHARACTERISTIC = UUID.fromString("00008001-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_ACK_CHARACTERISTIC = UUID.fromString("00008002-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_REPLY_CHARACTERISTIC = UUID.fromString("00008003-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_NOTIFY_CHARACTERISTIC = UUID.fromString("00008004-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_8005 = UUID.fromString("00008005-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_HEART_RATE = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CONFIG_DESCRIPTOR = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_SERVICE_BASE = UUID.fromString("00006006-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_SERVICE_EXTEND = UUID.fromString("00007006-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_SERVICE_HEART_RATE = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb");
+
+ public static final byte CMD_PREAMBLE = (byte) 0x6f;
+ // list all available commands
+ public static final byte CMD_RESPOND = (byte) 0x01;
+ public static final byte CMD_WATCH_ID = (byte) 0x02;
+ public static final byte CMD_DEVICE_VERSION = (byte) 0x03;
+ public static final byte CMD_DATE_TIME = (byte) 0x04;
+ public static final byte CMD_TIME_SURFACE_SETTINGS = (byte) 0x05;
+ public static final byte CMD_SURFACE_DISPLAY_SETTIGNS = (byte) 0x06;
+ public static final byte CMD_SCREEN_BRIGHTNESS = (byte) 0x07;
+ public static final byte CMD_BATTERY_POWER = (byte) 0x08;
+ public static final byte CMD_VOLUME_SETTINGS = (byte) 0x09;
+ public static final byte CMD_SHOCK_MODE = (byte) 0x0A;
+ public static final byte CMD_LANGUAGE_SETTINGS = (byte) 0x0B;
+ public static final byte CMD_UNIT_SETTINGS = (byte) 0x0C;
+ public static final byte CMD_FACTORY_RESTORE = (byte) 0x0D;
+ public static final byte CMD_ENTER_UPGRADE_MODE = (byte) 0x0E;
+ public static final byte CMD_SHOCK_STRENGTH = (byte) 0x10;
+ public static final byte CMD_WORK_MODE = (byte) 0x12;
+ public static final byte CMD_SCREEN_ON_TIME = (byte) 0x13;
+ public static final byte CMD_SNOOZE = (byte) 0x14;
+ public static final byte CMD_DO_NOT_DISTURB = (byte) 0x15;
+ public static final byte CMD_ANALOG_MODE = (byte) 0x17;
+ public static final byte CMD_CONTROL_DEVICE = (byte) 0x1A;
+ public static final byte CMD_DISPLAY_TIMEOUT = (byte) 0x25;
+ public static final byte CMD_USER_INFO = (byte) 0x30;
+ public static final byte CMD_USAGE_HABITS = (byte) 0x31;
+ public static final byte CMD_USER_NAME = (byte) 0x32;
+ public static final byte CMD_GOALS = (byte) 0x50;
+ public static final byte CMD_AVAIABLE_DATA = (byte) 0x52;
+ public static final byte CMD_DELETE_STEP_COUNT = (byte) 0x53;
+ public static final byte CMD_GET_STEP_COUNT = (byte) 0x54;
+ public static final byte CMD_DELETE_SLEEP_DATA = (byte) 0x55;
+ public static final byte CMD_GET_SLEEP_DATA = (byte) 0x56;
+ public static final byte CMD_DELETE_HEARTRATE_DATA = (byte) 0x5A;
+ public static final byte CMD_GET_HEARTRATE_DATA = (byte) 0x5B;
+ public static final byte CMD_AUTO_HEARTRATE = (byte) 0x5C;
+ public static final byte CMD_HEARTRATE_ALARM_LIMITS = (byte) 0x5D;
+ public static final byte CMD_INACTIVITY_ALERT = (byte) 0x5E;
+ public static final byte CMD_CALORIES_TYPE = (byte) 0x60;
+ public static final byte CMD_GET_HEARTRATE_EXDATA = (byte) 0x61;
+ public static final byte CMD_PUSH_EX_MSG = (byte) 0x76;
+ public static final byte CMD_PUSH_WEATHER_DATA = (byte) 0x77;
+ public static final byte CMD_SWITCH_SETTINGS = (byte) 0x90;
+ public static final byte CMD_REMINDERS = (byte) 0x97;
+ public static final byte CMD_PUSH_CALENDAR_DAY = (byte) 0x99;
+ public static final byte CMD_MUSIC_CONTROL = (byte) 0xD0;
+ public static final byte CMD_CALL_CONTROL = (byte) 0xDC;
+ public static final byte CMD_TEST_SIGNALING = (byte) 0xFA;
+ // here are the action commands
+ public static final byte CMD_REQUEST = (byte) 0x70;
+ public static final byte CMD_SEND = (byte) 0x71;
+ public static final byte CMD_REQUEST_RESPOND = (byte) 0x80;
+ // further commands
+ public static final byte CMD_END = (byte) 0x8f;
+ public static final byte CMD_ACK_WRITE = (byte) 0x03;
+ // notification types and icons
+ public static final byte NOTIFICATION_MISSED_CALL = (byte) 0x00;
+ public static final byte NOTIFICATION_SMS = (byte) 0x01;
+ public static final byte NOTIFICATION_SOCIAL = (byte) 0x02;
+ public static final byte NOTIFICATION_EMAIL = (byte) 0x03;
+ public static final byte NOTIFICATION_CALENDAR = (byte) 0x04;
+ public static final byte NOTIFICATION_INCOME_CALL = (byte) 0x05;
+ public static final byte NOTIFICATION_CALL_OFF = (byte) 0x06;
+ public static final byte NOTIFICATION_WECHAT = (byte) 0x07;
+ public static final byte NOTIFICATION_VIBER = (byte) 0x08;
+ public static final byte NOTIFICATION_SNAPCHAT = (byte) 0x09;
+ public static final byte NOTIFICATION_WHATSAPP = (byte) 0x0A;
+ public static final byte NOTIFICATION_QQ = (byte) 0x0B;
+ public static final byte NOTIFICATION_FACEBOOK = (byte) 0x0C;
+ public static final byte NOTIFICATION_HANGOUTS = (byte) 0x0D;
+ public static final byte NOTIFICATION_GMAIL = (byte) 0x0E;
+ public static final byte NOTIFICATION_MESSENGER = (byte) 0x0F;
+ public static final byte NOTIFICATION_INSTAGRAM = (byte) 0x10;
+ public static final byte NOTIFICATION_TWITTER = (byte) 0x11;
+ public static final byte NOTIFICATION_LINKEDIN = (byte) 0x12;
+ public static final byte NOTIFICATION_UBER = (byte) 0x13;
+ public static final byte NOTIFICATION_LINE = (byte) 0x14;
+ public static final byte NOTIFICATION_SKYPE = (byte) 0x15;
+ // reminders types
+ public static final byte REMINDER_ALARM = (byte) 0x04;
+ // signaling types
+ public static final byte ANTI_LOSS_TYPE = (byte) 0x00;
+ public static final byte CALL_TYPE = (byte) 0x02;
+ public static final byte MISSED_CALL_TYPE = (byte) 0x03;
+ public static final byte SMS_TYPE = (byte) 0x04;
+ public static final byte SOCIAL_TYPE = (byte) 0x05;
+ public static final byte EMAIL_TYPE = (byte) 0x06;
+ public static final byte CALENDAR_TYPE = (byte) 0x07;
+ public static final byte INACTIVITY_TYPE = (byte) 0x08;
+ public static final byte LOW_POWER_TYPE = (byte) 0x09;
+ // watch settings
+ public static final byte WEAR_ON_LEFT_WRIST = (byte) 0x00;
+ public static final byte WEAR_ON_RIGHT_WRIST = (byte) 0x01;
+
+ public static final String PREF_SCREENTIME = "zetime_screentime";
+ public static final int MAX_SCREEN_ON_TIME = 0xffff;
+ public static final int MIN_SCREEN_ON_TIME = 0x000a;
+
+ public static final String PREF_ZETIME_DONT_DEL_ACTDATA = "zetime_dont_del_actdata";
+
+ public static final String PREF_ZETIME_HEARTRATE_ALARM = "zetime_heartrate_alarm_enable";
+ public static final String PREF_ZETIME_MAX_HEARTRATE = "alarm_max_heart_rate";
+ public static final String PREF_ZETIME_MIN_HEARTRATE = "alarm_min_heart_rate";
+ public static final String PREF_ZETIME_HEARTRATE_INTERVAL = "heartrate_measurement_interval";
+
+ public static final String PREF_ANALOG_MODE = "zetime_analog_mode";
+ public static final String PREF_ACTIVITY_TRACKING = "zetime_activity_tracking";
+ public static final String PREF_HANDMOVE_DISPLAY = "zetime_handmove_display";
+ public static final String PREF_CALORIES_TYPE = "zetime_calories_type";
+ public static final String PREF_DATE_FORMAT = "zetime_date_format";
+
+ public static final String PREF_ALARM_SIGNALING = "zetime_alarm_signaling";
+ public static final String PREF_SMS_SIGNALING = "zetime_vibration_profile_sms";
+ public static final String PREF_CALL_SIGNALING = "zetime_vibration_profile_incoming_call";
+ public static final String PREF_MISSED_CALL_SIGNALING = "zetime_vibration_profile_missed_call";
+ public static final String PREF_EMAIL_SIGNALING = "zetime_vibration_profile_generic_email";
+ public static final String PREF_SOCIAL_SIGNALING = "zetime_vibration_profile_generic_social";
+ public static final String PREF_CALENDAR_SIGNALING = "zetime_vibration_profile_calendar";
+ public static final String PREF_INACTIVITY_SIGNALING = "zetime_vibration_profile_inactivity";
+ public static final String PREF_LOW_POWER_SIGNALING = "zetime_vibration_profile_lowpower";
+ public static final String PREF_ANTI_LOSS_SIGNALING = "zetime_vibration_profile_antiloss";
+
+ public static final String PREF_SHOCK_STRENGTH = "zetime_shock_strength";
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeCoordinator.java
new file mode 100644
index 0000000..32c5ab1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeCoordinator.java
@@ -0,0 +1,209 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Damien Gaignon, Daniel Dakhno,
+ Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k, Sebastian Kranz
+
+ 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.devices.zetime;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.zetime.ZeTimeDeviceSupport;
+
+public class ZeTimeCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("ZeTime.*");
+ }
+
+ @Override
+ public int getAlarmSlotCount(final GBDevice device) {
+ return 3; // FIXME - check the real value
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "MyKronoz";
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public SampleProvider<? extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new ZeTimeSampleProvider(device, session);
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) {
+
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public boolean supportsUnicodeEmojis() {
+ return true;
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+
+ final List<Integer> generic = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.GENERIC);
+ generic.add(R.xml.devicesettings_wearlocation);
+
+ final List<Integer> datetime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME);
+ datetime.add(R.xml.devicesettings_timeformat);
+ datetime.add(R.xml.devicesettings_zetime_date_format);
+
+ final List<Integer> display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY);
+ display.add(R.xml.devicesettings_zetime_display);
+
+ final List<Integer> health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_zetime_health);
+
+ final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
+ notifications.add(R.xml.devicesettings_donotdisturb_no_auto);
+ notifications.add(R.xml.devicesettings_zetime_vibrationpatterns);
+ notifications.add(R.xml.devicesettings_transliteration);
+
+ final List<Integer> calendar = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALENDAR);
+ calendar.add(R.xml.devicesettings_sync_calendar);
+
+ final List<Integer> activityInfo = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.ACTIVITY_INFO);
+ activityInfo.add(R.xml.devicesettings_zetime_activity_info);
+
+ final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
+ developer.add(R.xml.devicesettings_keep_activity_data_on_device);
+
+ return deviceSpecificSettings;
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new ZeTimeSettingsCustomizer(device);
+ }
+
+ @Override
+ public List<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
+ return Arrays.asList(
+ HeartRateCapability.MeasurementInterval.OFF,
+ HeartRateCapability.MeasurementInterval.MINUTES_5,
+ HeartRateCapability.MeasurementInterval.MINUTES_10,
+ HeartRateCapability.MeasurementInterval.MINUTES_15,
+ HeartRateCapability.MeasurementInterval.MINUTES_30,
+ HeartRateCapability.MeasurementInterval.MINUTES_45,
+ HeartRateCapability.MeasurementInterval.HOUR_1
+ );
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return new String[]{
+ "auto",
+ "zh_CN",
+ "zh_TW",
+ "en_US",
+ "es_ES",
+ "de_DE",
+ "it_IT",
+ "fr_FR",
+ "pt_PT",
+ "nl_NL",
+ "sv_SE",
+ "pl_PL",
+ "cs_CZ",
+ "el_GR",
+ "ru_RU",
+ "he_IL",
+ "ar_SA",
+ "ja_JP",
+ "ko_KO",
+ "th_TH",
+ };
+ }
+
+ @NonNull
+ @Override
+ public Class<? extends DeviceSupport> getDeviceSupportClass() {
+ return ZeTimeDeviceSupport.class;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_mykronoz_zetime;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_zetime;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_zetime_disabled;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeSampleProvider.java
new file mode 100644
index 0000000..b75f5ab
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeSampleProvider.java
@@ -0,0 +1,85 @@
+/* Copyright (C) 2018-2024 Daniele Gobbetti, Sebastian Kranz
+
+ 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.devices.zetime;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.ZeTimeActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ZeTimeActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class ZeTimeSampleProvider extends AbstractSampleProvider<ZeTimeActivitySample> {
+
+ private final float movementDivisor = 6000.0f;
+ private GBDevice mDevice;
+ private DaoSession mSession;
+
+ public ZeTimeSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+
+ mSession = session;
+ mDevice = device;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity/movementDivisor;
+ }
+
+ @Override
+ public ZeTimeActivitySample createActivitySample() {
+ return new ZeTimeActivitySample();
+ }
+
+ @Override
+ public AbstractDao<ZeTimeActivitySample, ?> getSampleDao() {
+ return getSession().getZeTimeActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return ZeTimeActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ZeTimeActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ZeTimeActivitySampleDao.Properties.DeviceId;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeSettingsCustomizer.java
new file mode 100644
index 0000000..1d4973f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/zetime/ZeTimeSettingsCustomizer.java
@@ -0,0 +1,111 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.devices.zetime;
+
+import android.os.Parcel;
+import android.text.InputType;
+
+import androidx.preference.Preference;
+
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class ZeTimeSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ private final GBDevice device;
+
+ public ZeTimeSettingsCustomizer(final GBDevice device) {
+ this.device = device;
+ }
+
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
+ if (rootKey == null) {
+ // Main screen - read the settings
+ GBApplication.deviceService(device).onReadConfiguration("do_it");
+ }
+
+ // Date time
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_DATE_FORMAT);
+
+ // Display
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_SCREENTIME);
+ handler.setInputTypeFor(ZeTimeConstants.PREF_SCREENTIME, InputType.TYPE_CLASS_NUMBER);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_ANALOG_MODE);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_HANDMOVE_DISPLAY);
+
+ // Vibration patterns
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_SMS_SIGNALING);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_ANTI_LOSS_SIGNALING);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_CALENDAR_SIGNALING);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_CALL_SIGNALING);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_MISSED_CALL_SIGNALING);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_EMAIL_SIGNALING);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_INACTIVITY_SIGNALING);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_LOW_POWER_SIGNALING);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_SOCIAL_SIGNALING);
+
+ // Heart rate
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_ZETIME_HEARTRATE_ALARM);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_ZETIME_MAX_HEARTRATE);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_ZETIME_MIN_HEARTRATE);
+ handler.setInputTypeFor(ZeTimeConstants.PREF_ZETIME_MAX_HEARTRATE, InputType.TYPE_CLASS_NUMBER);
+ handler.setInputTypeFor(ZeTimeConstants.PREF_ZETIME_MIN_HEARTRATE, InputType.TYPE_CLASS_NUMBER);
+
+ // Activity info
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_ACTIVITY_TRACKING);
+ handler.addPreferenceHandlerFor(ZeTimeConstants.PREF_CALORIES_TYPE);
+ }
+
+ @Override
+ public Set<String> getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ public static final Creator<ZeTimeSettingsCustomizer> CREATOR = new Creator<ZeTimeSettingsCustomizer>() {
+ @Override
+ public ZeTimeSettingsCustomizer createFromParcel(final Parcel in) {
+ final GBDevice device = in.readParcelable(ZeTimeSettingsCustomizer.class.getClassLoader());
+ return new ZeTimeSettingsCustomizer(device);
+ }
+
+ @Override
+ public ZeTimeSettingsCustomizer[] newArray(final int size) {
+ return new ZeTimeSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeParcelable(device, 0);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/.gitignore b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/.gitignore
new file mode 100644
index 0000000..b2099c8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/.gitignore
@@ -0,0 +1,2 @@
+*.java
+!Abstract*.java
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractActivitySample.java
new file mode 100644
index 0000000..60be8c8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractActivitySample.java
@@ -0,0 +1,127 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractActivitySample implements ActivitySample {
+ private SampleProvider<?> mProvider;
+
+ @Override
+ public SampleProvider<?> getProvider() {
+ return mProvider;
+ }
+
+ public void setProvider(SampleProvider<?> provider) {
+ mProvider = provider;
+ }
+
+ @Override
+ public ActivityKind getKind() {
+ return getProvider().normalizeType(getRawKind());
+ }
+
+ @Override
+ public int getRawKind() {
+ return NOT_MEASURED;
+ }
+
+ @Override
+ public float getIntensity() {
+ return getProvider().normalizeIntensity(getRawIntensity());
+ }
+
+ public void setRawKind(int kind) {
+ }
+
+ public void setRawIntensity(int intensity) {
+ }
+
+ public void setSteps(int steps) {
+ }
+
+ public void setDistanceCm(int distance) {
+ }
+
+ public void setActiveCalories(int activeCalories) {
+ }
+
+ /**
+ * Unix timestamp of the sample, i.e. the number of seconds since 1970-01-01 00:00:00 UTC.
+ */
+ public abstract void setTimestamp(int timestamp);
+
+ public abstract void setUserId(long userId);
+
+ @Override
+ public void setHeartRate(int heartRate) {
+ }
+
+ @Override
+ public int getHeartRate() {
+ return NOT_MEASURED;
+ }
+
+ public abstract void setDeviceId(long deviceId);
+
+ public abstract long getDeviceId();
+
+ public abstract long getUserId();
+
+ @Override
+ public int getRawIntensity() {
+ return NOT_MEASURED;
+ }
+
+ @Override
+ public int getSteps() {
+ return NOT_MEASURED;
+ }
+
+ @Override
+ public int getDistanceCm() {
+ return NOT_MEASURED;
+ }
+
+ @Override
+ public int getActiveCalories() {
+ return NOT_MEASURED;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ ActivityKind kind = getProvider() != null ? getKind() : ActivityKind.NOT_MEASURED;
+ float intensity = getProvider() != null ? getIntensity() : ActivitySample.NOT_MEASURED;
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimeStamp(getTimestamp())) +
+ ", intensity=" + intensity +
+ ", steps=" + getSteps() +
+ ", distanceCm=" + getDistanceCm() +
+ ", activeCalories=" + getActiveCalories() +
+ ", heartRate=" + getHeartRate() +
+ ", type=" + kind +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ '}';
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractBodyEnergySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractBodyEnergySample.java
new file mode 100644
index 0000000..6c0bcee
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractBodyEnergySample.java
@@ -0,0 +1,36 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.model.BodyEnergySample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractBodyEnergySample extends AbstractTimeSample implements BodyEnergySample {
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", energy=" + getEnergy() +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractColmiActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractColmiActivitySample.java
new file mode 100644
index 0000000..97d88c7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractColmiActivitySample.java
@@ -0,0 +1,37 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.entities;
+
+public abstract class AbstractColmiActivitySample extends AbstractActivitySample {
+ private int rawIntensity = 0;
+
+ abstract public int getCalories();
+
+ @Override
+ public void setRawIntensity(int rawIntensity) {
+ this.rawIntensity = rawIntensity;
+ }
+
+ @Override
+ public int getRawIntensity() {
+ if (rawIntensity != 0) {
+ return rawIntensity;
+ } else {
+ return getCalories();
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractFitProActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractFitProActivitySample.java
new file mode 100644
index 0000000..f72fc4a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractFitProActivitySample.java
@@ -0,0 +1,60 @@
+/* Copyright (C) 2021-2024 Petr Van臎k
+
+ 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.entities;
+
+public abstract class AbstractFitProActivitySample extends AbstractActivitySample {
+
+ abstract public int getSteps();
+
+ @Override
+ public int getRawIntensity() {
+ return getSteps();
+ }
+
+ @Override
+ public void setTimestamp(int timestamp) {
+
+ }
+
+ @Override
+ public void setUserId(long userId) {
+
+ }
+
+ @Override
+ public void setDeviceId(long deviceId) {
+
+ }
+
+ @Override
+ public long getDeviceId() {
+ return 0;
+ }
+
+ @Override
+ public long getUserId() {
+ return 0;
+ }
+
+ @Override
+ public int getTimestamp() {
+ return 0;
+ }
+}
+
+
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractGBX100ActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractGBX100ActivitySample.java
new file mode 100644
index 0000000..bd32330
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractGBX100ActivitySample.java
@@ -0,0 +1,27 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.entities;
+
+public abstract class AbstractGBX100ActivitySample extends AbstractActivitySample {
+ abstract public int getCalories();
+ abstract public int getSteps();
+
+ @Override
+ public int getRawIntensity() {
+ return getCalories();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHeartRateSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHeartRateSample.java
new file mode 100644
index 0000000..7c2327c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHeartRateSample.java
@@ -0,0 +1,35 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractHeartRateSample extends AbstractTimeSample implements HeartRateSample {
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", hr=" + getHeartRate() +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHrvSummarySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHrvSummarySample.java
new file mode 100644
index 0000000..1c4ba56
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHrvSummarySample.java
@@ -0,0 +1,50 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractHrvSummarySample extends AbstractTimeSample implements HrvSummarySample {
+ public Integer getStatusNum() {
+ return Status.NONE.getNum();
+ }
+
+ @Override
+ public Status getStatus() {
+ return Status.fromNum(getStatusNum());
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", weeklyAverage=" + getWeeklyAverage() +
+ ", lastNightAverage=" + getLastNightAverage() +
+ ", lastNight5MinHigh=" + getLastNight5MinHigh() +
+ ", baselineLowUpper=" + getBaselineLowUpper() +
+ ", baselineBalancedLower=" + getBaselineBalancedLower() +
+ ", baselineBalancedUpper=" + getBaselineBalancedUpper() +
+ ", status=" + getStatus() +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHrvValueSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHrvValueSample.java
new file mode 100644
index 0000000..8e80e94
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHrvValueSample.java
@@ -0,0 +1,35 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractHrvValueSample extends AbstractTimeSample implements HrvValueSample {
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", value=" + getValue() +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHybridHRActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHybridHRActivitySample.java
new file mode 100644
index 0000000..d4dd3f2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractHybridHRActivitySample.java
@@ -0,0 +1,40 @@
+/* Copyright (C) 2020-2024 Daniel Dakhno
+
+ 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.entities;
+
+public abstract class AbstractHybridHRActivitySample extends AbstractActivitySample {
+ abstract public int getCalories();
+ abstract public byte getWear_type();
+
+ @Override
+ public int getRawKind() {
+ return getWear_type();
+ }
+
+ @Override
+ public int getRawIntensity() {
+ return getSteps();
+ }
+
+ @Override
+ public void setUserId(long userId) {}
+
+ @Override
+ public long getUserId() {
+ return 0;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPaiSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPaiSample.java
new file mode 100644
index 0000000..e8ff17b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPaiSample.java
@@ -0,0 +1,42 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractPaiSample extends AbstractTimeSample implements PaiSample {
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", paiLow=" + getPaiLow() +
+ ", paiModerate=" + getPaiModerate() +
+ ", paiHigh=" + getPaiHigh() +
+ ", timeLow=" + getTimeLow() +
+ ", timeModerate=" + getTimeModerate() +
+ ", timeHigh=" + getTimeHigh() +
+ ", paiToday=" + getPaiToday() +
+ ", paiTotal=" + getPaiTotal() +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleHealthActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleHealthActivitySample.java
new file mode 100644
index 0000000..d18015b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleHealthActivitySample.java
@@ -0,0 +1,35 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa
+
+ 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.entities;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public abstract class AbstractPebbleHealthActivitySample extends AbstractActivitySample {
+ abstract public byte[] getRawPebbleHealthData();
+
+ private transient int rawActivityKind = ActivityKind.UNKNOWN.getCode();
+
+ @Override
+ public int getRawKind() {
+ return rawActivityKind;
+ }
+
+ @Override
+ public void setRawKind(int kind) {
+ this.rawActivityKind = kind;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleMisfitActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleMisfitActivitySample.java
new file mode 100644
index 0000000..a1b482e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleMisfitActivitySample.java
@@ -0,0 +1,70 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa
+
+ 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.entities;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public abstract class AbstractPebbleMisfitActivitySample extends AbstractActivitySample {
+ abstract public int getRawPebbleMisfitSample();
+
+ private transient int intensity = 0;
+ private transient int steps = 0;
+ private transient ActivityKind activityKind = ActivityKind.UNKNOWN;
+
+ private void calculate() {
+ int sample = getRawPebbleMisfitSample();
+
+ if (((sample & 0x83ff) == 0x0001) && ((sample & 0xff00) <= 0x4800)) {
+ // sleep seems to be from 0x2401 to 0x4801 (0b0IIIII0000000001) where I = intensity ?
+ intensity = (sample & 0x7c00) >>> 10;
+ // 9-18 decimal after shift
+ if (intensity <= 13) {
+ activityKind = ActivityKind.DEEP_SLEEP;
+ } else {
+ // FIXME: this leads to too much false positives, ignore for now
+ //activityKind = ActivityKind.LIGHT_SLEEP;
+ //intensity *= 2; // better visual distinction
+ }
+ } else {
+ if ((sample & 0x0001) == 0) { // 16-??? steps encoded in bits 1-7
+ steps = (sample & 0x00fe);
+ } else { // 0-14 steps encoded in bits 1-3, most of the time fc71 bits are set in that case
+ steps = (sample & 0x000e);
+ }
+ intensity = steps;
+ activityKind = ActivityKind.ACTIVITY;
+ }
+ }
+
+ @Override
+ public int getSteps() {
+ calculate();
+ return steps;
+ }
+
+ @Override
+ public ActivityKind getKind() {
+ calculate();
+ return activityKind;
+ }
+
+ @Override
+ public int getRawIntensity() {
+ calculate();
+ return intensity;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleMorpheuzActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleMorpheuzActivitySample.java
new file mode 100644
index 0000000..7e3a28a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractPebbleMorpheuzActivitySample.java
@@ -0,0 +1,33 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa
+
+ 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.entities;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public abstract class AbstractPebbleMorpheuzActivitySample extends AbstractActivitySample {
+
+ @Override
+ public ActivityKind getKind() {
+ int rawIntensity = getRawIntensity();
+ if (rawIntensity <= 120) {
+ return ActivityKind.DEEP_SLEEP;
+ } else if (rawIntensity <= 1000) {
+ return ActivityKind.LIGHT_SLEEP;
+ }
+ return ActivityKind.ACTIVITY;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractRespiratoryRateSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractRespiratoryRateSample.java
new file mode 100644
index 0000000..fb51f89
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractRespiratoryRateSample.java
@@ -0,0 +1,35 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractRespiratoryRateSample extends AbstractTimeSample implements RespiratoryRateSample {
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", respiratoryRate=" + getRespiratoryRate() +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractSpo2Sample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractSpo2Sample.java
new file mode 100644
index 0000000..9bae4eb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractSpo2Sample.java
@@ -0,0 +1,45 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractSpo2Sample extends AbstractTimeSample implements Spo2Sample {
+ public int getTypeNum() {
+ return Spo2Sample.Type.UNKNOWN.getNum();
+ }
+
+ @Override
+ public Type getType() {
+ return Type.fromNum(getTypeNum());
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", spo2=" + getSpo2() +
+ ", type=" + getType() +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractStressSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractStressSample.java
new file mode 100644
index 0000000..e2df694
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractStressSample.java
@@ -0,0 +1,45 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractStressSample extends AbstractTimeSample implements StressSample {
+ public int getTypeNum() {
+ return Type.UNKNOWN.getNum();
+ }
+
+ @Override
+ public Type getType() {
+ return Type.fromNum(getTypeNum());
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", stress=" + getStress() +
+ ", type=" + getType() +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTemperatureSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTemperatureSample.java
new file mode 100644
index 0000000..d337660
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTemperatureSample.java
@@ -0,0 +1,36 @@
+/* Copyright (C) 2023-2024 Alicia Hormann
+
+ 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.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractTemperatureSample extends AbstractTimeSample implements TemperatureSample {
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", temperature=" + getTemperature() +
+ ", temperatureType=" + getTemperatureType() +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTimeSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTimeSample.java
new file mode 100644
index 0000000..fcd8de0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTimeSample.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.model.TimeSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractTimeSample implements TimeSample {
+ // Unix timestamp in milliseconds
+ public abstract void setTimestamp(long timestamp);
+
+ public abstract long getUserId();
+
+ public abstract void setUserId(long userId);
+
+ public abstract long getDeviceId();
+
+ public abstract void setDeviceId(long deviceId);
+
+ public abstract void setDevice(Device device);
+
+ public abstract void setUser(User user);
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractWeightSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractWeightSample.java
new file mode 100644
index 0000000..e4422c5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractWeightSample.java
@@ -0,0 +1,35 @@
+/* Copyright (C) 2024 Severin von Wnuck-Lipinski
+
+ 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.entities;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.model.WeightSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class AbstractWeightSample extends AbstractTimeSample implements WeightSample {
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", weightKg=" + getWeightKg() +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/ActivityTrackExporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/ActivityTrackExporter.java
new file mode 100644
index 0000000..9548cbe
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/ActivityTrackExporter.java
@@ -0,0 +1,29 @@
+/* Copyright (C) 2017-2024 Carsten Pfeiffer, Daniele Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.export;
+
+import java.io.File;
+import java.io.IOException;
+
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
+
+public interface ActivityTrackExporter {
+ void performExport(ActivityTrack track, File targetFile) throws IOException, GPXTrackEmptyException;
+
+ class GPXTrackEmptyException extends Exception {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java
new file mode 100644
index 0000000..806433d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java
@@ -0,0 +1,246 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, AndrewH, Carsten Pfeiffer,
+ Daniele Gobbetti, Dikay900, Jos茅 Rebelo, Nick Spacek, Petr Van臎k
+
+ 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.export;
+
+import android.util.Xml;
+
+import androidx.annotation.Nullable;
+
+import org.xmlpull.v1.XmlSerializer;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
+import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public class GPXExporter implements ActivityTrackExporter {
+ private static final String NS_GPX_URI = "http://www.topografix.com/GPX/1/1";
+ private static final String NS_GPX_PREFIX = "";
+ private static final String NS_TRACKPOINT_EXTENSION = "gpxtpx";
+ private static final String NS_TRACKPOINT_EXTENSION_URI = "https://www8.garmin.com/xmlschemas/TrackPointExtensionv2.xsd";
+ private static final String NS_XSI_URI = "http://www.w3.org/2001/XMLSchema-instance";
+ private static final String TOPOGRAFIX_NAMESPACE_XSD = "http://www.topografix.com/GPX/1/1/gpx.xsd";
+ private static final String OPENTRACKS_PREFIX = "opentracks";
+ private static final String OPENTRACKS_NAMESPACE_URI = "http://opentracksapp.com/xmlschemas/v1";
+
+ private String creator;
+ private boolean includeHeartRate = true;
+ private boolean includeHeartRateOfNearestSample = true;
+
+ @Override
+ public void performExport(ActivityTrack track, File targetFile) throws IOException, GPXTrackEmptyException {
+ String encoding = StandardCharsets.UTF_8.name();
+ XmlSerializer ser = Xml.newSerializer();
+ try (FileOutputStream outputStream = new FileOutputStream(targetFile)) {
+ ser.setOutput(outputStream, encoding);
+ ser.startDocument(encoding, Boolean.TRUE);
+ ser.setPrefix("xsi", NS_XSI_URI);
+ ser.setPrefix(NS_TRACKPOINT_EXTENSION, NS_TRACKPOINT_EXTENSION_URI);
+ ser.setPrefix(NS_GPX_PREFIX, NS_GPX_URI);
+ ser.setPrefix(OPENTRACKS_PREFIX, OPENTRACKS_NAMESPACE_URI);
+
+ ser.startTag(NS_GPX_URI, "gpx");
+ ser.attribute(null, "version", "1.1");
+ if (creator != null) {
+ ser.attribute(null, "creator", creator);
+ } else {
+ ser.attribute(null, "creator", GBApplication.app().getNameAndVersion());
+ }
+ ser.attribute(NS_XSI_URI, "schemaLocation",NS_GPX_URI + " " + TOPOGRAFIX_NAMESPACE_XSD);
+
+ exportMetadata(ser, track);
+ exportTrack(ser, track);
+
+ ser.endTag(NS_GPX_URI, "gpx");
+ ser.endDocument();
+ ser.flush();
+ }
+ }
+
+ private void exportMetadata(XmlSerializer ser, ActivityTrack track) throws IOException {
+ ser.startTag(NS_GPX_URI, "metadata");
+ if (track.getName() != null) {
+ ser.startTag(NS_GPX_URI, "name").text(track.getName()).endTag(NS_GPX_URI, "name");
+ }
+
+ final User user = track.getUser();
+ if (user != null) {
+ ser.startTag(NS_GPX_URI, "author");
+ ser.startTag(NS_GPX_URI, "name").text(user.getName()).endTag(NS_GPX_URI, "name");
+ ser.endTag(NS_GPX_URI, "author");
+ }
+
+ ser.startTag(NS_GPX_URI, "time").text(formatTime(new Date())).endTag(NS_GPX_URI, "time");
+
+ ser.endTag(NS_GPX_URI, "metadata");
+ }
+
+ private String formatTime(Date date) {
+ return DateTimeUtils.formatIso8601(date);
+ }
+
+ private void exportTrack(XmlSerializer ser, ActivityTrack track) throws IOException, GPXTrackEmptyException {
+ String uuid = UUID.randomUUID().toString();
+ ser.startTag(NS_GPX_URI, "trk");
+ ser.startTag(NS_GPX_URI, "extensions");
+ ser.startTag(NS_GPX_URI, OPENTRACKS_PREFIX + ":trackid").text(uuid).endTag(NS_GPX_URI, OPENTRACKS_PREFIX + ":trackid");
+ ser.endTag(NS_GPX_URI, "extensions");
+
+ List<List<ActivityPoint>> segments = track.getSegments();
+ boolean atLeastOnePointExported = false;
+ for (List<ActivityPoint> segment : segments) {
+ if (segment.isEmpty()) {
+ // Skip empty segments
+ continue;
+ }
+
+ ser.startTag(NS_GPX_URI, "trkseg");
+ for (ActivityPoint point : segment) {
+ atLeastOnePointExported |= exportTrackPoint(ser, point, segment);
+ }
+ ser.endTag(NS_GPX_URI, "trkseg");
+ }
+
+ if (!atLeastOnePointExported) {
+ throw new GPXTrackEmptyException();
+ }
+
+ ser.endTag(NS_GPX_URI, "trk");
+ }
+
+ private boolean exportTrackPoint(XmlSerializer ser, ActivityPoint point, List<ActivityPoint> trackPoints) throws IOException {
+ GPSCoordinate location = point.getLocation();
+ if (location == null) {
+ return false; // skip invalid points, that just contain hr data, for example
+ }
+ ser.startTag(NS_GPX_URI, "trkpt");
+ // lon and lat attributes do not have an explicit namespace
+ ser.attribute(null, "lon", formatDouble(location.getLongitude()));
+ ser.attribute(null, "lat", formatDouble(location.getLatitude()));
+ if (location.getAltitude() != GPSCoordinate.UNKNOWN_ALTITUDE) {
+ ser.startTag(NS_GPX_URI, "ele").text(formatDouble(location.getAltitude())).endTag(NS_GPX_URI, "ele");
+ }
+ ser.startTag(NS_GPX_URI, "time").text(DateTimeUtils.formatIso8601UTC(point.getTime())).endTag(NS_GPX_URI, "time");
+ String description = point.getDescription();
+ if (description != null) {
+ ser.startTag(NS_GPX_URI, "desc").text(description).endTag(NS_GPX_URI, "desc");
+ }
+ //ser.startTag(NS_GPX_URI, "src").text(source).endTag(NS_GPX_URI, "src");
+
+ exportTrackpointExtensions(ser, point, trackPoints);
+
+ ser.endTag(NS_GPX_URI, "trkpt");
+
+ return true;
+ }
+
+ private void exportTrackpointExtensions(XmlSerializer ser, ActivityPoint point, List<ActivityPoint> trackPoints) throws IOException {
+ if (!includeHeartRate) {
+ return;
+ }
+
+ float speed = point.getSpeed();
+ int cadence = point.getCadence();
+ int hr = point.getHeartRate();
+ if (!HeartRateUtils.getInstance().isValidHeartRateValue(hr) && includeHeartRateOfNearestSample) {
+
+ ActivityPoint closestPointItem = findClosestSensibleActivityPoint(point.getTime(), trackPoints);
+ if (closestPointItem != null) {
+ hr = closestPointItem.getHeartRate();
+ }
+
+ }
+
+ boolean exportHr = HeartRateUtils.getInstance().isValidHeartRateValue(hr) && includeHeartRate;
+
+ if (!exportHr && speed < 0 && cadence < 0) {
+ // No valid data to export in extensions
+ return;
+ }
+
+ ser.startTag(NS_GPX_URI, "extensions");
+ ser.setPrefix(NS_TRACKPOINT_EXTENSION, NS_TRACKPOINT_EXTENSION_URI);
+ ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "TrackPointExtension");
+ if (exportHr) {
+ ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "hr").text(String.valueOf(hr)).endTag(NS_TRACKPOINT_EXTENSION_URI, "hr");
+ }
+ if (cadence >= 0) {
+ ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "cad").text(String.valueOf(cadence)).endTag(NS_TRACKPOINT_EXTENSION_URI, "cad");
+ }
+ if (speed >= 0) {
+ ser.startTag(NS_TRACKPOINT_EXTENSION_URI, "speed").text(formatDouble(speed)).endTag(NS_TRACKPOINT_EXTENSION_URI, "speed");
+ }
+ ser.endTag(NS_TRACKPOINT_EXTENSION_URI, "TrackPointExtension");
+ ser.endTag(NS_GPX_URI, "extensions");
+ }
+
+ private @Nullable ActivityPoint findClosestSensibleActivityPoint(Date time, List<ActivityPoint> trackPoints) {
+ ActivityPoint closestPointItem = null;
+ HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance();
+
+ long lowestDifference = 60 * 2 * 1000; // minimum distance is 2min
+ for (ActivityPoint pointItem : trackPoints) {
+ int hrItem = pointItem.getHeartRate();
+ if (heartRateUtilsInstance.isValidHeartRateValue(hrItem)) {
+ Date timeItem = pointItem.getTime();
+ if (timeItem.after(time) || timeItem.equals(time)) {
+ break; // we assume that the given trackPoints are sorted in time ascending order (oldest first)
+ }
+ long difference = time.getTime() - timeItem.getTime();
+ if (difference < lowestDifference) {
+ lowestDifference = difference;
+ closestPointItem = pointItem;
+ }
+ }
+ }
+ return closestPointItem;
+ }
+
+ private String formatDouble(double value) {
+ return new BigDecimal(value).setScale(GPSCoordinate.GPS_DECIMAL_DEGREES_SCALE, RoundingMode.HALF_UP).toPlainString();
+ }
+
+ public String getCreator() {
+ return creator; // TODO: move to some kind of BrandingInfo class
+ }
+
+ public void setCreator(String creator) {
+ this.creator = creator;
+ }
+
+ public void setIncludeHeartRate(boolean includeHeartRate) {
+ this.includeHeartRate = includeHeartRate;
+ }
+
+ public boolean isIncludeHeartRate() {
+ return includeHeartRate;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AlarmClockReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AlarmClockReceiver.java
new file mode 100644
index 0000000..33fcb61
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AlarmClockReceiver.java
@@ -0,0 +1,94 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti
+
+ 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;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import java.util.ArrayList;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
+
+public class AlarmClockReceiver extends BroadcastReceiver {
+ /**
+ * AlarmActivity and AlarmService (when unbound) listen for this broadcast intent
+ * so that other applications can snooze the alarm (after ALARM_ALERT_ACTION and before
+ * ALARM_DONE_ACTION).
+ */
+ public static final String ALARM_SNOOZE_ACTION = "com.android.deskclock.ALARM_SNOOZE";
+
+ /**
+ * AlarmActivity and AlarmService listen for this broadcast intent so that other
+ * applications can dismiss the alarm (after ALARM_ALERT_ACTION and before ALARM_DONE_ACTION).
+ */
+ public static final String ALARM_DISMISS_ACTION = "com.android.deskclock.ALARM_DISMISS";
+
+ /** A public action sent by AlarmService when the alarm has started. */
+ public static final String ALARM_ALERT_ACTION = "com.android.deskclock.ALARM_ALERT";
+ public static final String GOOGLE_CLOCK_ALARM_ALERT_ACTION = "com.google.android.deskclock.action.ALARM_ALERT";
+
+ /** A public action sent by AlarmService when the alarm has stopped for any reason. */
+ public static final String ALARM_DONE_ACTION = "com.android.deskclock.ALARM_DONE";
+ public static final String GOOGLE_CLOCK_ALARM_DONE_ACTION = "com.google.android.deskclock.action.ALARM_DONE";
+ private int lastId;
+
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (ALARM_ALERT_ACTION.equals(action) || GOOGLE_CLOCK_ALARM_ALERT_ACTION.equals(action)) {
+ sendAlarm(true);
+ } else if (ALARM_DONE_ACTION.equals(action) || GOOGLE_CLOCK_ALARM_DONE_ACTION.equals(action)) {
+ sendAlarm(false);
+ }
+ }
+
+
+
+ private synchronized void sendAlarm(boolean on) {
+ dismissLastAlarm();
+ if (on) {
+ NotificationSpec notificationSpec = new NotificationSpec();
+ //TODO: can we attach a dismiss action to the notification and not use the notification ID explicitly?
+ lastId = notificationSpec.getId();
+ notificationSpec.type = NotificationType.GENERIC_ALARM_CLOCK;
+ notificationSpec.sourceName = "ALARMCLOCKRECEIVER";
+ notificationSpec.attachedActions = new ArrayList<>();
+
+ // DISMISS ALL action
+ NotificationSpec.Action dismissAllAction = new NotificationSpec.Action();
+ dismissAllAction.title = "Dismiss All";
+ dismissAllAction.type = NotificationSpec.Action.TYPE_SYNTECTIC_DISMISS_ALL;
+ notificationSpec.attachedActions.add(dismissAllAction);
+
+ // can we get the alarm title somehow?
+ GBApplication.deviceService().onNotification(notificationSpec);
+ }
+ }
+
+ private void dismissLastAlarm() {
+ if (lastId != 0) {
+ GBApplication.deviceService().onDeleteNotification(lastId);
+ lastId = 0;
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AlarmReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AlarmReceiver.java
new file mode 100644
index 0000000..985bc8a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AlarmReceiver.java
@@ -0,0 +1,113 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Daniele Gobbetti, Ganblejs
+
+ 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;
+
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import net.e175.klaus.solarpositioning.DeltaT;
+import net.e175.klaus.solarpositioning.SPA;
+import net.e175.klaus.solarpositioning.SunriseTransitSet;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.TimeZone;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
+
+public class AlarmReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(AlarmReceiver.class);
+
+ public AlarmReceiver() {
+ Context context = GBApplication.getContext();
+ Intent intent = new Intent("DAILY_ALARM");
+ intent.setPackage(BuildConfig.APPLICATION_ID);
+ PendingIntent pendingIntent = PendingIntentUtils.getBroadcast(context, 0, intent, 0, false);
+ AlarmManager am = (AlarmManager) (context.getSystemService(Context.ALARM_SERVICE));
+
+ if (am != null) {
+ am.setInexactRepeating(AlarmManager.RTC_WAKEUP, Calendar.getInstance().getTimeInMillis() + 10000, AlarmManager.INTERVAL_DAY, pendingIntent);
+ }
+ else {
+ LOG.warn("could not get alarm manager!");
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ LOG.info("will resend sunrise and sunset events");
+
+ final GregorianCalendar dateTimeTomorrow = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
+ dateTimeTomorrow.set(Calendar.HOUR, 0);
+ dateTimeTomorrow.set(Calendar.MINUTE, 0);
+ dateTimeTomorrow.set(Calendar.SECOND, 0);
+ dateTimeTomorrow.set(Calendar.MILLISECOND, 0);
+ dateTimeTomorrow.add(GregorianCalendar.DAY_OF_MONTH, 1);
+
+ /*
+ * rotate ids ud reuse the id from two days ago for tomorrow, this way we will have
+ * sunrise /sunset for 3 days while sending only sunrise/sunset per day
+ */
+ byte id_tomorrow = (byte) ((dateTimeTomorrow.getTimeInMillis() / (1000L * 60L * 60L * 24L)) % 3);
+
+ GBApplication.deviceService().onDeleteCalendarEvent(CalendarEventSpec.TYPE_SUNRISE, id_tomorrow);
+ GBApplication.deviceService().onDeleteCalendarEvent(CalendarEventSpec.TYPE_SUNSET, id_tomorrow);
+
+ GBPrefs gbPrefs = GBApplication.getPrefs();
+ float[] longlat = gbPrefs.getLongLat(context);
+ float longitude = longlat[0];
+ float latitude = longlat[1];
+
+ final SunriseTransitSet sunriseTransitSetTomorrow = SPA.calculateSunriseTransitSet(
+ dateTimeTomorrow.toZonedDateTime(),
+ latitude,
+ longitude,
+ DeltaT.estimate(dateTimeTomorrow.toZonedDateTime().toLocalDate())
+ );
+
+ CalendarEventSpec calendarEventSpec = new CalendarEventSpec();
+ calendarEventSpec.durationInSeconds = 0;
+ calendarEventSpec.description = null;
+
+ calendarEventSpec.type = CalendarEventSpec.TYPE_SUNRISE;
+ calendarEventSpec.title = "Sunrise";
+ if (sunriseTransitSetTomorrow.getSunrise() != null) {
+ calendarEventSpec.id = id_tomorrow;
+ calendarEventSpec.timestamp = (int) (sunriseTransitSetTomorrow.getSunrise().toInstant().getEpochSecond());
+ GBApplication.deviceService().onAddCalendarEvent(calendarEventSpec);
+ }
+
+ calendarEventSpec.type = CalendarEventSpec.TYPE_SUNSET;
+ calendarEventSpec.title = "Sunset";
+ if (sunriseTransitSetTomorrow.getSunset() != null) {
+ calendarEventSpec.id = id_tomorrow;
+ calendarEventSpec.timestamp = (int) (sunriseTransitSetTomorrow.getSunset().toInstant().getEpochSecond());
+ GBApplication.deviceService().onAddCalendarEvent(calendarEventSpec);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.java
new file mode 100644
index 0000000..5ce081a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/AutoStartReceiver.java
@@ -0,0 +1,47 @@
+/* Copyright (C) 2017-2024 Carsten Pfeiffer, Daniele Gobbetti, Felix
+ Konstantin Maurer, Petr Van臎k
+
+ 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;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter;
+
+public class AutoStartReceiver extends BroadcastReceiver {
+ private static final String TAG = AutoStartReceiver.class.getName();
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+
+ if (GBApplication.getPrefs().getAutoStart() &&
+ (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction()) ||
+ Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())
+ )) {
+ Log.i(TAG, "Boot or reinstall completed, starting Gadgetbridge");
+ if (GBApplication.getPrefs().getBoolean("general_autoconnectonbluetooth", false)) {
+ Log.i(TAG, "Autoconnect is enabled, attempting to connect");
+ GBApplication.deviceService().connect();
+ }
+ Log.i(TAG, "Going to enable periodic exporter");
+ PeriodicExporter.enablePeriodicExport(context);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothConnectReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothConnectReceiver.java
new file mode 100644
index 0000000..c143565
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothConnectReceiver.java
@@ -0,0 +1,77 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Daniel Dakhno, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.externalevents;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceCommunicationService;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+
+public class BluetoothConnectReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(BluetoothConnectReceiver.class);
+
+ final DeviceCommunicationService service;
+
+ public BluetoothConnectReceiver(final DeviceCommunicationService service) {
+ this.service = service;
+ }
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ final String action = intent.getAction();
+ if (action == null) {
+ return;
+ }
+
+ if (!action.equals(BluetoothDevice.ACTION_ACL_CONNECTED) || !intent.hasExtra(BluetoothDevice.EXTRA_DEVICE)) {
+ return;
+ }
+
+ final BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ if (device == null) {
+ LOG.error("Got no device for {}", action);
+ return;
+ }
+
+ LOG.info("connection attempt detected from {}", device.getAddress());
+
+ final GBDevice gbDevice = GBApplication.app().getDeviceManager().getDeviceByAddress(device.getAddress());
+ if (gbDevice == null) {
+ LOG.info("Connected device {} unknown", device.getAddress());
+ return;
+ }
+ final SharedPreferences deviceSpecificPreferences = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
+ boolean reactToConnection = deviceSpecificPreferences.getBoolean(GBPrefs.DEVICE_CONNECT_BACK, false);
+ reactToConnection |= gbDevice.getState() == GBDevice.State.WAITING_FOR_RECONNECT;
+ reactToConnection |= gbDevice.getState() == GBDevice.State.WAITING_FOR_SCAN;
+ if (!reactToConnection) {
+ LOG.info("Ignoring connection attempt from {}", device.getAddress());
+ return;
+ }
+ LOG.info("Will re-connect to {} ({})", gbDevice.getAddress(), gbDevice.getName());
+ GBApplication.deviceService(gbDevice).connect();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothPairingRequestReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothPairingRequestReceiver.java
new file mode 100644
index 0000000..fcd8a3b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothPairingRequestReceiver.java
@@ -0,0 +1,72 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Daniel Dakhno, Daniele Gobbetti,
+ Joa虄o Paulo Barraca, Taavi Eom盲e
+
+ 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;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceCommunicationService;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+
+public class BluetoothPairingRequestReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(BluetoothConnectReceiver.class);
+
+ final DeviceCommunicationService service;
+
+ public BluetoothPairingRequestReceiver(DeviceCommunicationService service) {
+ this.service = service;
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+
+
+ if (!action.equals(BluetoothDevice.ACTION_PAIRING_REQUEST)) {
+ return;
+ }
+ BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ if (device == null) {
+ return;
+ }
+
+ GBDevice gbDevice = null;
+ try {
+ gbDevice = service.getDeviceByAddress(device.getAddress());
+
+ DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
+ try {
+ if (coordinator.getBondingStyle() == DeviceCoordinator.BONDING_STYLE_NONE) {
+ LOG.info("Aborting unwanted pairing request");
+ abortBroadcast();
+ }
+ } catch (Exception e) {
+ LOG.warn("Could not abort pairing request process");
+ }
+ } catch (DeviceCommunicationService.DeviceNotFoundException e) {
+ e.printStackTrace();
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothStateChangeReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothStateChangeReceiver.java
new file mode 100644
index 0000000..663cafd
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/BluetoothStateChangeReceiver.java
@@ -0,0 +1,70 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Taavi Eom盲e
+
+ 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;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceCommunicationService;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+
+public class BluetoothStateChangeReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(BluetoothStateChangeReceiver.class);
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final String action = intent.getAction();
+
+ if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(action)) {
+ if (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1) == BluetoothAdapter.STATE_ON) {
+ final Intent refreshIntent = new Intent(DeviceManager.ACTION_REFRESH_DEVICELIST);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(refreshIntent);
+
+ final GBPrefs prefs = GBApplication.getPrefs();
+ if (!DeviceCommunicationService.isRunning(context) && !prefs.getAutoStart()) {
+ // Prevent starting the service if it isn't yet running
+ LOG.debug("DeviceCommunicationService not running, ignoring bluetooth on");
+ return;
+ }
+
+ if (!prefs.getBoolean("general_autoconnectonbluetooth", false)) {
+ return;
+ }
+
+ LOG.info("Bluetooth turned on (ACTION_STATE_CHANGED) => connecting...");
+ GBApplication.deviceService().connect();
+ } else if (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1) == BluetoothAdapter.STATE_OFF) {
+ if (!DeviceCommunicationService.isRunning(context)) {
+ // Prevent starting the service if it isn't yet running
+ LOG.debug("DeviceCommunicationService not running, ignoring bluetooth off");
+ return;
+ }
+ LOG.info("Bluetooth turned off => disconnecting...");
+ GBApplication.deviceService().disconnect();
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/CMWeatherReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/CMWeatherReceiver.java
new file mode 100644
index 0000000..7501ab8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/CMWeatherReceiver.java
@@ -0,0 +1,224 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.externalevents;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.List;
+
+import cyanogenmod.weather.CMWeatherManager;
+import cyanogenmod.weather.WeatherInfo;
+import cyanogenmod.weather.WeatherLocation;
+import cyanogenmod.weather.util.WeatherUtils;
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.Weather;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+import static cyanogenmod.providers.WeatherContract.WeatherColumns.TempUnit.FAHRENHEIT;
+import static cyanogenmod.providers.WeatherContract.WeatherColumns.WeatherCode.ISOLATED_THUNDERSHOWERS;
+import static cyanogenmod.providers.WeatherContract.WeatherColumns.WeatherCode.NOT_AVAILABLE;
+import static cyanogenmod.providers.WeatherContract.WeatherColumns.WeatherCode.SCATTERED_SNOW_SHOWERS;
+import static cyanogenmod.providers.WeatherContract.WeatherColumns.WeatherCode.SCATTERED_THUNDERSTORMS;
+import static cyanogenmod.providers.WeatherContract.WeatherColumns.WeatherCode.SHOWERS;
+import static cyanogenmod.providers.WeatherContract.WeatherColumns.WindSpeedUnit.MPH;
+
+public class CMWeatherReceiver extends BroadcastReceiver implements CMWeatherManager.WeatherUpdateRequestListener, CMWeatherManager.LookupCityRequestListener {
+
+ private static final Logger LOG = LoggerFactory.getLogger(CMWeatherReceiver.class);
+
+ private WeatherLocation weatherLocation = null;
+ private Context mContext;
+ private PendingIntent mPendingIntent = null;
+
+ public CMWeatherReceiver() {
+ mContext = GBApplication.getContext();
+ final CMWeatherManager weatherManager = CMWeatherManager.getInstance(mContext);
+ if (weatherManager == null) {
+ return;
+ }
+
+ Prefs prefs = GBApplication.getPrefs();
+
+ String city = prefs.getString("weather_city", null);
+ String cityId = prefs.getString("weather_cityid", null);
+
+ if ((cityId == null || cityId.equals("")) && city != null && !city.equals("")) {
+ lookupCity(city);
+ } else if (city != null && cityId != null) {
+ weatherLocation = new WeatherLocation.Builder(cityId, city).build();
+ enablePeriodicAlarm(true);
+ }
+ }
+
+ private void lookupCity(String city) {
+ final CMWeatherManager weatherManager = CMWeatherManager.getInstance(mContext);
+ if (weatherManager == null) {
+ return;
+ }
+
+ if (city != null && !city.equals("")) {
+ if (weatherManager.getActiveWeatherServiceProviderLabel() != null) {
+ weatherManager.lookupCity(city, this);
+ }
+ }
+ }
+
+ private void enablePeriodicAlarm(boolean enable) {
+ if ((mPendingIntent != null && enable) || (mPendingIntent == null && !enable)) {
+ return;
+ }
+
+ AlarmManager am = (AlarmManager) (mContext.getSystemService(Context.ALARM_SERVICE));
+ if (am == null) {
+ LOG.warn("could not get alarm manager!");
+ return;
+ }
+
+ if (enable) {
+ Intent intent = new Intent("GB_UPDATE_WEATHER");
+ intent.setPackage(BuildConfig.APPLICATION_ID);
+ mPendingIntent = PendingIntentUtils.getBroadcast(mContext, 0, intent, 0, false);
+ am.setInexactRepeating(AlarmManager.RTC_WAKEUP, Calendar.getInstance().getTimeInMillis() + 10000, AlarmManager.INTERVAL_HOUR, mPendingIntent);
+ } else {
+ am.cancel(mPendingIntent);
+ mPendingIntent = null;
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Prefs prefs = GBApplication.getPrefs();
+
+ String city = prefs.getString("weather_city", null);
+ String cityId = prefs.getString("weather_cityid", null);
+
+ if (city != null && !city.equals("") && cityId == null) {
+ lookupCity(city);
+ } else {
+ requestWeather();
+ }
+ }
+
+ private void requestWeather() {
+ final CMWeatherManager weatherManager = CMWeatherManager.getInstance(GBApplication.getContext());
+ if (weatherManager.getActiveWeatherServiceProviderLabel() != null && weatherLocation != null) {
+ weatherManager.requestWeatherUpdate(weatherLocation, this);
+ }
+ }
+
+ @Override
+ public void onWeatherRequestCompleted(int status, WeatherInfo weatherInfo) {
+ if (weatherInfo != null) {
+ LOG.info("weather: " + weatherInfo.toString());
+ WeatherSpec weatherSpec = new WeatherSpec();
+ weatherSpec.timestamp = (int) (weatherInfo.getTimestamp() / 1000);
+ weatherSpec.location = weatherInfo.getCity();
+
+ if (weatherInfo.getTemperatureUnit() == FAHRENHEIT) {
+ weatherSpec.currentTemp = (int) WeatherUtils.fahrenheitToCelsius(weatherInfo.getTemperature()) + 273;
+ weatherSpec.todayMaxTemp = (int) WeatherUtils.fahrenheitToCelsius(weatherInfo.getTodaysHigh()) + 273;
+ weatherSpec.todayMinTemp = (int) WeatherUtils.fahrenheitToCelsius(weatherInfo.getTodaysLow()) + 273;
+ } else {
+ weatherSpec.currentTemp = (int) weatherInfo.getTemperature() + 273;
+ weatherSpec.todayMaxTemp = (int) weatherInfo.getTodaysHigh() + 273;
+ weatherSpec.todayMinTemp = (int) weatherInfo.getTodaysLow() + 273;
+ }
+ if (weatherInfo.getWindSpeedUnit() == MPH) {
+ weatherSpec.windSpeed = (float) weatherInfo.getWindSpeed() * 1.609344f;
+ } else {
+ weatherSpec.windSpeed = (float) weatherInfo.getWindSpeed();
+ }
+ weatherSpec.windDirection = (int) weatherInfo.getWindDirection();
+
+ weatherSpec.currentConditionCode = Weather.mapToOpenWeatherMapCondition(CMtoYahooCondintion(weatherInfo.getConditionCode()));
+ weatherSpec.currentCondition = Weather.getConditionString(weatherSpec.currentConditionCode);
+ weatherSpec.currentHumidity = (int) weatherInfo.getHumidity();
+
+ weatherSpec.forecasts = new ArrayList<>();
+ List<WeatherInfo.DayForecast> forecasts = weatherInfo.getForecasts();
+ for (int i = 1; i < forecasts.size(); i++) {
+ WeatherInfo.DayForecast cmForecast = forecasts.get(i);
+ WeatherSpec.Daily gbForecast = new WeatherSpec.Daily();
+ if (weatherInfo.getTemperatureUnit() == FAHRENHEIT) {
+ gbForecast.maxTemp = (int) WeatherUtils.fahrenheitToCelsius(cmForecast.getHigh()) + 273;
+ gbForecast.minTemp = (int) WeatherUtils.fahrenheitToCelsius(cmForecast.getLow()) + 273;
+ } else {
+ gbForecast.maxTemp = (int) cmForecast.getHigh() + 273;
+ gbForecast.minTemp = (int) cmForecast.getLow() + 273;
+ }
+ gbForecast.conditionCode = Weather.mapToOpenWeatherMapCondition(CMtoYahooCondintion(cmForecast.getConditionCode()));
+ weatherSpec.forecasts.add(gbForecast);
+ }
+ ArrayList<WeatherSpec> weatherSpecs = new ArrayList<>(Collections.singletonList(weatherSpec));
+ Weather.getInstance().setWeatherSpec(weatherSpecs);
+ GBApplication.deviceService().onSendWeather(weatherSpecs);
+ } else {
+ LOG.info("request has returned null for WeatherInfo");
+ }
+ }
+
+ /**
+ * @param cmCondition
+ * @return
+ */
+ private int CMtoYahooCondintion(int cmCondition) {
+ int yahooCondition;
+ if (cmCondition <= SHOWERS) {
+ yahooCondition = cmCondition;
+ } else if (cmCondition <= SCATTERED_THUNDERSTORMS) {
+ yahooCondition = cmCondition + 1;
+ } else if (cmCondition <= SCATTERED_SNOW_SHOWERS) {
+ yahooCondition = cmCondition + 2;
+ } else if (cmCondition <= ISOLATED_THUNDERSHOWERS) {
+ yahooCondition = cmCondition + 3;
+ } else {
+ yahooCondition = NOT_AVAILABLE;
+ }
+ return yahooCondition;
+ }
+
+ @Override
+ public void onLookupCityRequestCompleted(int result, List<WeatherLocation> list) {
+ if (list != null) {
+ weatherLocation = list.get(0);
+ String cityId = weatherLocation.getCityId();
+ String city = weatherLocation.getCity();
+
+ SharedPreferences.Editor editor = GBApplication.getPrefs().getPreferences().edit();
+ editor.putString("weather_city", city).apply();
+ editor.putString("weather_cityid", cityId).apply();
+ enablePeriodicAlarm(true);
+ requestWeather();
+ } else {
+ enablePeriodicAlarm(false);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/CalendarReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/CalendarReceiver.java
new file mode 100644
index 0000000..2e86fef
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/CalendarReceiver.java
@@ -0,0 +1,281 @@
+/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Daniel Hauck, Gabriele Monaco, Jos茅 Rebelo, Petr Van臎k
+
+ 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;
+
+
+import static androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Handler;
+import android.provider.CalendarContract;
+import android.widget.Toast;
+
+import androidx.core.content.ContextCompat;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.concurrent.TimeUnit;
+import java.util.Hashtable;
+import java.util.List;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.entities.CalendarSyncState;
+import nodomain.freeyourgadget.gadgetbridge.entities.CalendarSyncStateDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
+import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarEvent;
+import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarManager;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class CalendarReceiver extends ContentObserver {
+ private static final Logger LOG = LoggerFactory.getLogger(CalendarReceiver.class);
+
+ private static final String ACTION_FORCE_SYNC = "FORCE_CALENDAR_SYNC";
+
+ private final Hashtable<Long, EventSyncState> eventState = new Hashtable<>();
+
+ private final Context mContext;
+ private final GBDevice mGBDevice;
+ private final Handler mSyncHandler;
+ private final BroadcastReceiver mForceSyncReceiver;
+
+ private static class EventSyncState {
+ private int state;
+ private final CalendarEvent event;
+
+ EventSyncState(CalendarEvent event, int state) {
+ this.state = state;
+ this.event = event;
+ }
+
+ public int getState() {
+ return state;
+ }
+
+ public void setState(int state) {
+ this.state = state;
+ }
+
+ public CalendarEvent getEvent() {
+ return event;
+ }
+ }
+
+ private static class EventState {
+ private static final int NOT_SYNCED = 0;
+ private static final int SYNCED = 1;
+ private static final int NEEDS_UPDATE = 2;
+ private static final int NEEDS_DELETE = 3;
+ }
+
+ public CalendarReceiver(final Context context, final GBDevice gbDevice) {
+ super(new Handler());
+ LOG.info("Created calendar receiver");
+ mContext = context;
+ mGBDevice = gbDevice;
+ mSyncHandler = new Handler();
+ mForceSyncReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ LOG.info("Got force sync: {}", intent.getAction());
+ scheduleSync();
+ }
+ };
+ }
+
+ public GBDevice getGBDevice() {
+ return mGBDevice;
+ }
+
+ @Override
+ public void onChange(boolean selfChange, Uri uri) {
+ super.onChange(selfChange, uri);
+ LOG.info("Got calendar change: {}", uri);
+ scheduleSync();
+ }
+
+ public void scheduleSync() {
+ LOG.debug("Scheduling calendar sync");
+ mSyncHandler.removeCallbacksAndMessages(null);
+ mSyncHandler.postDelayed(this::syncCalendar, 2500L);
+ }
+
+ public void syncCalendar() {
+ List<CalendarEvent> eventList = (new CalendarManager(mContext, mGBDevice.getAddress())).getCalendarEventList();
+ LOG.debug("Syncing {} calendar events", eventList.size());
+ syncCalendar(eventList);
+ }
+
+ public void syncCalendar(List<CalendarEvent> eventList) {
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ DaoSession session = dbHandler.getDaoSession();
+ syncCalendar(eventList, session);
+ } catch (Exception e1) {
+ GB.toast("Database Error while syncing Calendar", Toast.LENGTH_SHORT, GB.ERROR, e1);
+ }
+ }
+
+ public void syncCalendar(List<CalendarEvent> eventList, DaoSession session) {
+ LOG.info("Syncing with calendar.");
+ Hashtable<Long, CalendarEvent> eventTable = new Hashtable<>();
+ Long deviceId = DBHelper.getDevice(mGBDevice, session).getId();
+ QueryBuilder<CalendarSyncState> qb = session.getCalendarSyncStateDao().queryBuilder();
+
+ for (CalendarEvent e : eventList) {
+ long id = e.getId();
+ eventTable.put(id, e);
+ if (!eventState.containsKey(e.getId())) {
+ qb = session.getCalendarSyncStateDao().queryBuilder();
+
+ CalendarSyncState calendarSyncState = qb.where(qb.and(CalendarSyncStateDao.Properties.DeviceId.eq(deviceId), CalendarSyncStateDao.Properties.CalendarEntryId.eq(id)))
+ .build().unique();
+ if (calendarSyncState == null) {
+ eventState.put(id, new EventSyncState(e, EventState.NOT_SYNCED));
+ LOG.info("event id={} is yet unknown to device id={}", id, deviceId);
+ } else if (calendarSyncState.getHash() == e.hashCode()) {
+ eventState.put(id, new EventSyncState(e, EventState.SYNCED));
+ LOG.info("event id={} is up to date on device id={}", id, deviceId);
+ } else {
+ eventState.put(id, new EventSyncState(e, EventState.NEEDS_UPDATE));
+ LOG.info("event id={} is not up to date on device id={}", id, deviceId);
+ }
+ }
+ }
+
+ // add all missing calendar ids on the device to sync status (so that they are deleted later)
+ List<CalendarSyncState> CalendarSyncStateList = qb.where(CalendarSyncStateDao.Properties.DeviceId.eq(deviceId)).build().list();
+ for (CalendarSyncState CalendarSyncState : CalendarSyncStateList) {
+ if (!eventState.containsKey(CalendarSyncState.getCalendarEntryId())) {
+ eventState.put(CalendarSyncState.getCalendarEntryId(), new EventSyncState(null, EventState.NEEDS_DELETE));
+ LOG.info("insert null event for orphaned calendar id={} for device={}", CalendarSyncState.getCalendarEntryId(), mGBDevice.getName());
+ }
+ }
+
+ Enumeration<Long> ids = eventState.keys();
+ while (ids.hasMoreElements()) {
+ qb = session.getCalendarSyncStateDao().queryBuilder();
+ Long i = ids.nextElement();
+ EventSyncState es = eventState.get(i);
+ if (es == null) {
+ LOG.error("Failed to get event state for {}", i);
+ continue;
+ }
+ if (eventTable.containsKey(i)) {
+ if (es.getState() == EventState.SYNCED) {
+ if (!es.getEvent().equals(eventTable.get(i))) {
+ eventState.put(i, new EventSyncState(eventTable.get(i), EventState.NEEDS_UPDATE));
+ }
+ }
+ } else {
+ if (es.getState() == EventState.NOT_SYNCED) {
+ // delete for current device only
+ qb.where(qb.and(CalendarSyncStateDao.Properties.DeviceId.eq(deviceId), CalendarSyncStateDao.Properties.CalendarEntryId.eq(i)))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+ eventState.remove(i);
+ } else {
+ es.setState(EventState.NEEDS_DELETE);
+ eventState.put(i, es);
+ }
+ }
+ updateEvents(deviceId, session);
+ }
+ }
+
+ private void updateEvents(Long deviceId, DaoSession session) {
+ Enumeration<Long> ids = eventState.keys();
+ while (ids.hasMoreElements()) {
+ Long i = ids.nextElement();
+ EventSyncState es = eventState.get(i);
+ if (es == null) {
+ LOG.error("Failed to get event state {} for sync", i);
+ continue;
+ }
+ int syncState = es.getState();
+ if (syncState == EventState.NOT_SYNCED || syncState == EventState.NEEDS_UPDATE) {
+ CalendarEvent calendarEvent = es.getEvent();
+ CalendarEventSpec calendarEventSpec = new CalendarEventSpec();
+ 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()) {
+ // As per the CalendarContract, for all-day events, the start timestamp is always in UTC
+ // and corresponds to the midnight boundary
+ final int numDays = (int) TimeUnit.DAYS.convert(
+ calendarEvent.getEnd() - calendarEvent.getBegin(),
+ TimeUnit.MILLISECONDS
+ );
+ calendarEventSpec.durationInSeconds = 24 * 60 * 60 * numDays;
+ }
+ calendarEventSpec.description = calendarEvent.getDescription();
+ calendarEventSpec.location = calendarEvent.getLocation();
+ calendarEventSpec.type = CalendarEventSpec.TYPE_UNKNOWN;
+ calendarEventSpec.calName = calendarEvent.getUniqueCalName();
+ calendarEventSpec.color = calendarEvent.getColor();
+ if (syncState == EventState.NEEDS_UPDATE) {
+ GBApplication.deviceService(mGBDevice).onDeleteCalendarEvent(CalendarEventSpec.TYPE_UNKNOWN, i);
+ }
+ GBApplication.deviceService(mGBDevice).onAddCalendarEvent(calendarEventSpec);
+ es.setState(EventState.SYNCED);
+ eventState.put(i, es);
+ // update db
+ session.insertOrReplace(new CalendarSyncState(null, deviceId, i, es.event.hashCode()));
+ } else if (syncState == EventState.NEEDS_DELETE) {
+ GBApplication.deviceService(mGBDevice).onDeleteCalendarEvent(CalendarEventSpec.TYPE_UNKNOWN, i);
+ eventState.remove(i);
+ // delete from db for current device only
+ QueryBuilder<CalendarSyncState> qb = session.getCalendarSyncStateDao().queryBuilder();
+ qb.where(qb.and(CalendarSyncStateDao.Properties.DeviceId.eq(deviceId), CalendarSyncStateDao.Properties.CalendarEntryId.eq(i)))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+ }
+ }
+
+ public void registerBroadcastReceivers() {
+ mContext.getContentResolver().registerContentObserver(CalendarContract.Events.CONTENT_URI, true, this);
+ // Add a receiver to allow us to quickly force as calendar sync (without having to provide data)
+ ContextCompat.registerReceiver(mContext, mForceSyncReceiver, new IntentFilter(ACTION_FORCE_SYNC), RECEIVER_NOT_EXPORTED);
+ }
+
+ public void dispose() {
+ mContext.getContentResolver().unregisterContentObserver(this);
+ mContext.unregisterReceiver(mForceSyncReceiver);
+ mSyncHandler.removeCallbacksAndMessages(null);
+ }
+
+ public static void forceSync() {
+ final Intent intent = new Intent(ACTION_FORCE_SYNC);
+ intent.setPackage(BuildConfig.APPLICATION_ID);
+ GBApplication.getContext().sendBroadcast(intent);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceSettingsReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceSettingsReceiver.java
new file mode 100644
index 0000000..682bb8f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/DeviceSettingsReceiver.java
@@ -0,0 +1,110 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.externalevents;
+
+import android.annotation.SuppressLint;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class DeviceSettingsReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(DeviceSettingsReceiver.class);
+
+ public static final String COMMAND = "nodomain.freeyourgadget.gadgetbridge.action.SET_DEVICE_SETTING";
+
+ private static final String MAC_ADDR_PATTERN = "^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$";
+
+ @Override
+ @SuppressLint("ApplySharedPref")
+ // use commit to ensure it's already applied when we call the device
+ public void onReceive(final Context context, final Intent intent) {
+ if (!COMMAND.equals(intent.getAction())) {
+ LOG.warn("Unexpected action {}", intent.getAction());
+ }
+
+ final String deviceAddress = intent.getStringExtra("device");
+
+ if (deviceAddress == null) {
+ LOG.warn("Missing device address");
+ return;
+ }
+
+ if (!deviceAddress.matches(MAC_ADDR_PATTERN)) {
+ LOG.warn("Device address '{}' does not match '{}'", deviceAddress, MAC_ADDR_PATTERN);
+ return;
+ }
+
+ final GBDevice targetDevice = GBApplication.app()
+ .getDeviceManager()
+ .getDeviceByAddress(deviceAddress);
+
+ if (targetDevice == null) {
+ LOG.warn("Unknown device {}", deviceAddress);
+ return;
+ }
+
+ final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(targetDevice.getAddress());
+
+ if (!prefs.getBoolean("third_party_apps_set_settings", false)) {
+ LOG.warn("Setting device settings from 3rd party apps not allowed for {}", deviceAddress);
+ return;
+ }
+
+ final String key = intent.getStringExtra("key");
+ final Object value = intent.getExtras().get("value");
+
+ if (key == null) {
+ LOG.warn("No key specified");
+ return;
+ }
+
+ LOG.info("Setting '{}' to '{}'", key, value);
+
+ final SharedPreferences.Editor editor = prefs.edit();
+
+ if (value == null) {
+ editor.remove(key);
+ } else if (value instanceof Integer) {
+ editor.putInt(key, (Integer) value);
+ } else if (value instanceof Boolean) {
+ editor.putBoolean(key, (Boolean) value);
+ } else if (value instanceof String) {
+ editor.putString(key, (String) value);
+ } else if (value instanceof Float) {
+ editor.putFloat(key, (Float) value);
+ } else if (value instanceof Long) {
+ editor.putLong(key, (Long) value);
+ } else if (value instanceof Set) {
+ editor.putStringSet(key, (Set) value);
+ } else {
+ LOG.warn("Unknown preference value type {} for {}", value.getClass(), key);
+ }
+
+ editor.commit();
+
+ GBApplication.deviceService().onSendConfiguration(key);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/GenericWeatherReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/GenericWeatherReceiver.java
new file mode 100644
index 0000000..ee52677
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/GenericWeatherReceiver.java
@@ -0,0 +1,216 @@
+/* Copyright (C) 2022-2024 Daniele Gobbetti, Enrico Brambilla, Jos茅 Rebelo,
+ TylerWilliamson
+
+ 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;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.Weather;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class GenericWeatherReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(GenericWeatherReceiver.class);
+
+ public final static String ACTION_GENERIC_WEATHER = "nodomain.freeyourgadget.gadgetbridge.ACTION_GENERIC_WEATHER";
+ public final static String EXTRA_WEATHER_JSON = "WeatherJson";
+ public final static String EXTRA_WEATHER_SECONDARY_JSON = "WeatherSecondaryJson";
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (intent == null) {
+ LOG.warn("Intent is null");
+ return;
+ }
+
+ if (!ACTION_GENERIC_WEATHER.equals(intent.getAction())) {
+ LOG.warn("Unknown action {}", intent.getAction());
+ return;
+ }
+
+ final Bundle bundle = intent.getExtras();
+ if (bundle == null) {
+ LOG.warn("Intent has no extras");
+ return;
+ }
+
+ if (!bundle.containsKey(EXTRA_WEATHER_JSON)) {
+ LOG.warn("Bundle key {} not found", EXTRA_WEATHER_JSON);
+ return;
+ }
+
+ try {
+ final JSONObject primaryWeatherJson = new JSONObject(Objects.requireNonNull(bundle.getString(EXTRA_WEATHER_JSON)));
+ final WeatherSpec primaryWeather = weatherFromJson(primaryWeatherJson);
+
+ final ArrayList<WeatherSpec> weathers = new ArrayList<>();
+ weathers.add(primaryWeather);
+
+ if (bundle.containsKey(EXTRA_WEATHER_SECONDARY_JSON)) {
+ final JSONArray secondaryWeatherJson = new JSONArray(bundle.getString(EXTRA_WEATHER_SECONDARY_JSON, "[]"));
+
+ for (int i = 0; i < secondaryWeatherJson.length(); i++) {
+ weathers.add(weatherFromJson(secondaryWeatherJson.getJSONObject(i)));
+ }
+ }
+
+ LOG.info("Got generic weather for {} locations", weathers.size());
+ Weather.getInstance().setWeatherSpec(weathers);
+ GBApplication.deviceService().onSendWeather(weathers);
+ } catch (final Exception e) {
+ GB.toast("Gadgetbridge received broken or incompatible weather data", Toast.LENGTH_SHORT, GB.ERROR, e);
+ }
+ }
+
+ private WeatherSpec weatherFromJson(final JSONObject weatherJson) throws JSONException {
+ final WeatherSpec weatherSpec = new WeatherSpec();
+
+ weatherSpec.timestamp = safelyGet(weatherJson, Integer.class, "timestamp", (int) (System.currentTimeMillis() / 1000));
+ weatherSpec.location = safelyGet(weatherJson, String.class, "location", "");
+ weatherSpec.currentTemp = safelyGet(weatherJson, Integer.class, "currentTemp", 0);
+ weatherSpec.todayMinTemp = safelyGet(weatherJson, Integer.class, "todayMinTemp", 0);
+ weatherSpec.todayMaxTemp = safelyGet(weatherJson, Integer.class, "todayMaxTemp", 0);
+ weatherSpec.currentCondition = safelyGet(weatherJson, String.class, "currentCondition", "");
+ weatherSpec.currentConditionCode = safelyGet(weatherJson, Integer.class, "currentConditionCode", 0);
+ weatherSpec.currentHumidity = safelyGet(weatherJson, Integer.class, "currentHumidity", 0);
+ weatherSpec.windSpeed = safelyGet(weatherJson, Number.class, "windSpeed", 0d).floatValue();
+ weatherSpec.windDirection = safelyGet(weatherJson, Integer.class, "windDirection", 0);
+ weatherSpec.uvIndex = safelyGet(weatherJson, Number.class, "uvIndex", 0d).floatValue();
+ weatherSpec.precipProbability = safelyGet(weatherJson, Integer.class, "precipProbability", 0);
+ weatherSpec.dewPoint = safelyGet(weatherJson, Integer.class, "dewPoint", 0);
+ weatherSpec.pressure = safelyGet(weatherJson, Number.class, "pressure", 0).floatValue();
+ weatherSpec.cloudCover = safelyGet(weatherJson, Integer.class, "cloudCover", 0);
+ weatherSpec.visibility = safelyGet(weatherJson, Number.class, "visibility", 0).floatValue();
+ weatherSpec.sunRise = safelyGet(weatherJson, Integer.class, "sunRise", 0);
+ weatherSpec.sunSet = safelyGet(weatherJson, Integer.class, "sunSet", 0);
+ weatherSpec.moonRise = safelyGet(weatherJson, Integer.class, "moonRise", 0);
+ weatherSpec.moonSet = safelyGet(weatherJson, Integer.class, "moonSet", 0);
+ weatherSpec.moonPhase = safelyGet(weatherJson, Integer.class, "moonPhase", 0);
+ weatherSpec.latitude = safelyGet(weatherJson, Number.class, "latitude", 0).floatValue();
+ weatherSpec.longitude = safelyGet(weatherJson, Number.class, "longitude", 0).floatValue();
+ weatherSpec.feelsLikeTemp = safelyGet(weatherJson, Integer.class, "feelsLikeTemp", 0);
+ weatherSpec.isCurrentLocation = safelyGet(weatherJson, Integer.class, "isCurrentLocation", -1);
+
+ if (weatherJson.has("airQuality")) {
+ weatherSpec.airQuality = toAirQuality(weatherJson.getJSONObject("airQuality"));
+ }
+
+ if (weatherJson.has("forecasts")) {
+ final JSONArray forecastArray = weatherJson.getJSONArray("forecasts");
+ weatherSpec.forecasts = new ArrayList<>();
+
+ for (int i = 0, l = forecastArray.length(); i < l; i++) {
+ final JSONObject forecastJson = forecastArray.getJSONObject(i);
+
+ final WeatherSpec.Daily forecast = new WeatherSpec.Daily();
+
+ forecast.conditionCode = safelyGet(forecastJson, Integer.class, "conditionCode", 0);
+ forecast.humidity = safelyGet(forecastJson, Integer.class, "humidity", 0);
+ forecast.maxTemp = safelyGet(forecastJson, Integer.class, "maxTemp", 0);
+ forecast.minTemp = safelyGet(forecastJson, Integer.class, "minTemp", 0);
+ forecast.windSpeed = safelyGet(forecastJson, Number.class, "windSpeed", 0).floatValue();
+ forecast.windDirection = safelyGet(forecastJson, Integer.class, "windDirection", 0);
+ forecast.uvIndex = safelyGet(forecastJson, Number.class, "uvIndex", 0d).floatValue();
+ forecast.precipProbability = safelyGet(forecastJson, Integer.class, "precipProbability", 0);
+ forecast.sunRise = safelyGet(forecastJson, Integer.class, "sunRise", 0);
+ forecast.sunSet = safelyGet(forecastJson, Integer.class, "sunSet", 0);
+ forecast.moonRise = safelyGet(forecastJson, Integer.class, "moonRise", 0);
+ forecast.moonSet = safelyGet(forecastJson, Integer.class, "moonSet", 0);
+ forecast.moonPhase = safelyGet(forecastJson, Integer.class, "moonPhase", 0);
+
+ if (forecastJson.has("airQuality")) {
+ forecast.airQuality = toAirQuality(forecastJson.getJSONObject("airQuality"));
+ }
+
+ weatherSpec.forecasts.add(forecast);
+ }
+ }
+
+ if (weatherJson.has("hourly")) {
+ final JSONArray forecastArray = weatherJson.getJSONArray("hourly");
+ weatherSpec.hourly = new ArrayList<>();
+
+ for (int i = 0, l = forecastArray.length(); i < l; i++) {
+ final JSONObject forecastJson = forecastArray.getJSONObject(i);
+
+ final WeatherSpec.Hourly forecast = new WeatherSpec.Hourly();
+
+ forecast.timestamp = safelyGet(forecastJson, Integer.class, "timestamp", 0);
+ forecast.temp = safelyGet(forecastJson, Integer.class, "temp", 0);
+ forecast.conditionCode = safelyGet(forecastJson, Integer.class, "conditionCode", 0);
+ forecast.humidity = safelyGet(forecastJson, Integer.class, "humidity", 0);
+ forecast.windSpeed = safelyGet(forecastJson, Number.class, "windSpeed", 0).floatValue();
+ forecast.windDirection = safelyGet(forecastJson, Integer.class, "windDirection", 0);
+ forecast.uvIndex = safelyGet(forecastJson, Number.class, "uvIndex", 0d).floatValue();
+ forecast.precipProbability = safelyGet(forecastJson, Integer.class, "precipProbability", 0);
+
+ weatherSpec.hourly.add(forecast);
+ }
+ }
+
+ return weatherSpec;
+ }
+
+ private WeatherSpec.AirQuality toAirQuality(final JSONObject jsonObject) {
+ final WeatherSpec.AirQuality airQuality = new WeatherSpec.AirQuality();
+ airQuality.aqi = safelyGet(jsonObject, Integer.class, "aqi", -1);
+ airQuality.co = safelyGet(jsonObject, Number.class, "co", -1).floatValue();
+ airQuality.no2 = safelyGet(jsonObject, Number.class, "no2", -1).floatValue();
+ airQuality.o3 = safelyGet(jsonObject, Number.class, "o3", -1).floatValue();
+ airQuality.pm10 = safelyGet(jsonObject, Number.class, "pm10", -1).floatValue();
+ airQuality.pm25 = safelyGet(jsonObject, Number.class, "pm25", -1).floatValue();
+ airQuality.so2 = safelyGet(jsonObject, Number.class, "so2", -1).floatValue();
+ airQuality.coAqi = safelyGet(jsonObject, Integer.class, "coAqi", -1);
+ airQuality.no2Aqi = safelyGet(jsonObject, Integer.class, "no2Aqi", -1);
+ airQuality.o3Aqi = safelyGet(jsonObject, Integer.class, "o3Aqi", -1);
+ airQuality.pm10Aqi = safelyGet(jsonObject, Integer.class, "pm10Aqi", -1);
+ airQuality.pm25Aqi = safelyGet(jsonObject, Integer.class, "pm25Aqi", -1);
+ airQuality.so2Aqi = safelyGet(jsonObject, Integer.class, "so2Aqi", -1);
+
+ return airQuality;
+ }
+
+ private <T> T safelyGet(JSONObject jsonObject, Class<T> tClass, String name, T defaultValue) {
+ try {
+ if (jsonObject.has(name)) {
+ Object value = jsonObject.get(name);
+
+ if (tClass.isInstance(value)) {
+ return (T) value;
+ }
+ }
+ } catch (Exception e) {
+ //
+ }
+ return defaultValue;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/IntentApiReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/IntentApiReceiver.java
new file mode 100644
index 0000000..4b62949
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/IntentApiReceiver.java
@@ -0,0 +1,302 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo, octospacc
+
+ 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;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.database.PeriodicExporter;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
+import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class IntentApiReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(IntentApiReceiver.class);
+
+ private static final String msgDebugNotAllowed = "Intent API Allow Debug Commands not allowed";
+
+ public static final String COMMAND_ACTIVITY_SYNC = "nodomain.freeyourgadget.gadgetbridge.command.ACTIVITY_SYNC";
+ public static final String COMMAND_TRIGGER_EXPORT = "nodomain.freeyourgadget.gadgetbridge.command.TRIGGER_EXPORT";
+ public static final String COMMAND_DEBUG_SEND_NOTIFICATION = "nodomain.freeyourgadget.gadgetbridge.command.DEBUG_SEND_NOTIFICATION";
+ public static final String COMMAND_DEBUG_INCOMING_CALL = "nodomain.freeyourgadget.gadgetbridge.command.DEBUG_INCOMING_CALL";
+ public static final String COMMAND_DEBUG_END_CALL = "nodomain.freeyourgadget.gadgetbridge.command.DEBUG_END_CALL";
+ public static final String COMMAND_DEBUG_SET_DEVICE_ADDRESS = "nodomain.freeyourgadget.gadgetbridge.command.DEBUG_SET_DEVICE_ADDRESS";
+ public static final String COMMAND_DEBUG_TEST_NEW_FUNCTION = "nodomain.freeyourgadget.gadgetbridge.command.DEBUG_TEST_NEW_FUNCTION";
+
+ private static final String MAC_ADDR_PATTERN = "^([0-9A-F]{2}:){5}[0-9A-F]{2}$";
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (intent.getAction() == null) {
+ LOG.warn("Action is null");
+ return;
+ }
+
+ final Prefs prefs = GBApplication.getPrefs();
+
+ switch (intent.getAction()) {
+ case COMMAND_ACTIVITY_SYNC:
+ if (!prefs.getBoolean("intent_api_allow_activity_sync", false)) {
+ LOG.warn("Intent API activity sync trigger not allowed");
+ return;
+ }
+
+ final int dataTypes;
+ final String dataTypesHex = intent.getStringExtra("dataTypesHex");
+ if (dataTypesHex != null) {
+ final Matcher matcher = Pattern.compile("^0[xX]([0-9a-fA-F]+)$").matcher(dataTypesHex);
+ if (!matcher.find()) {
+ LOG.warn("Failed to parse dataTypesHex '{}' as hex", dataTypesHex);
+ return;
+ }
+ dataTypes = Integer.parseInt(matcher.group(1), 16);
+ } else {
+ dataTypes = RecordedDataTypes.TYPE_SYNC;
+ }
+
+ LOG.info("Triggering activity sync for data types {}", String.format("0x%08x", dataTypes));
+
+ GBApplication.deviceService().onFetchRecordedData(dataTypes);
+ break;
+
+ case COMMAND_TRIGGER_EXPORT:
+ if (!prefs.getBoolean("intent_api_allow_trigger_export", false)) {
+ LOG.warn("Intent API export trigger not allowed");
+ return;
+ }
+
+ LOG.info("Triggering export");
+
+ final Intent exportIntent = new Intent(context, PeriodicExporter.class);
+ context.sendBroadcast(exportIntent);
+ break;
+
+ case COMMAND_DEBUG_SEND_NOTIFICATION:
+ if (!prefs.getBoolean("intent_api_allow_debug_commands", false)) {
+ LOG.warn(msgDebugNotAllowed);
+ return;
+ }
+ LOG.info("Triggering Debug Send notification message");
+ NotificationSpec notificationSpec = new NotificationSpec();
+ notificationSpec.sender = intent.getStringExtra("sender");
+ if (notificationSpec.sender == null) {
+ notificationSpec.sender = "DEBUG Sender";
+ }
+ notificationSpec.phoneNumber = intent.getStringExtra("phoneNumber");
+ if (notificationSpec.phoneNumber == null) {
+ notificationSpec.phoneNumber = "DEBUG PhoneNumber";
+ }
+ notificationSpec.subject = intent.getStringExtra("subject");
+ if (notificationSpec.subject == null) {
+ notificationSpec.subject = "DEBUG Subject";
+ }
+ notificationSpec.body = intent.getStringExtra("body");
+ if (notificationSpec.body == null) {
+ notificationSpec.body = "DEBUG Body";
+ }
+ notificationSpec.type = NotificationType.GENERIC_SMS;
+ if (intent.getStringExtra("type") != null) {
+ try {
+ notificationSpec.type = NotificationType.valueOf(intent.getStringExtra("type"));
+ } catch (IllegalArgumentException e) {
+ }
+ }
+ if (notificationSpec.type != NotificationType.GENERIC_SMS) {
+ // SMS notifications don't have a source app ID when sent by the SMSReceiver,
+ // so let's not set it here as well for consistency
+ notificationSpec.sourceAppId = BuildConfig.APPLICATION_ID;
+ }
+ notificationSpec.sourceName = context.getApplicationInfo()
+ .loadLabel(context.getPackageManager())
+ .toString();
+ notificationSpec.pebbleColor = notificationSpec.type.color;
+ notificationSpec.attachedActions = new ArrayList<>();
+ if (notificationSpec.type == NotificationType.GENERIC_SMS) {
+ // REPLY action
+ NotificationSpec.Action replyAction = new NotificationSpec.Action();
+ replyAction.title = "Reply";
+ replyAction.type = NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR;
+ notificationSpec.attachedActions.add(replyAction);
+ }
+ GBApplication.deviceService().onNotification(notificationSpec);
+ break;
+
+ case COMMAND_DEBUG_INCOMING_CALL:
+ if (!prefs.getBoolean("intent_api_allow_debug_commands", false)) {
+ LOG.warn(msgDebugNotAllowed);
+ return;
+ }
+ LOG.info("Triggering Debug Incoming Call");
+ CallSpec callSpec = new CallSpec();
+ callSpec.command = CallSpec.CALL_INCOMING;
+ callSpec.number = intent.getStringExtra("caller");
+ if (callSpec.number == null) {
+ callSpec.number = "DEBUG_INCOMING_CALL";
+ }
+ GBApplication.deviceService().onSetCallState(callSpec);
+ break;
+
+ case COMMAND_DEBUG_END_CALL:
+ if (!prefs.getBoolean("intent_api_allow_debug_commands", false)) {
+ LOG.warn(msgDebugNotAllowed);
+ return;
+ }
+ LOG.info("Triggering Debug End Call");
+ CallSpec callSpecEnd = new CallSpec();
+ callSpecEnd.command = CallSpec.CALL_END;
+ GBApplication.deviceService().onSetCallState(callSpecEnd);
+ break;
+
+ case COMMAND_DEBUG_SET_DEVICE_ADDRESS:
+ if (!prefs.getBoolean("intent_api_allow_debug_commands", false)) {
+ LOG.warn(msgDebugNotAllowed);
+ return;
+ }
+ setDeviceAddress(intent);
+ break;
+
+ case COMMAND_DEBUG_TEST_NEW_FUNCTION:
+ if (!prefs.getBoolean("intent_api_allow_debug_commands", false)) {
+ LOG.warn(msgDebugNotAllowed);
+ return;
+ }
+ LOG.info("Triggering Debug Test New Function");
+ GBApplication.deviceService().onTestNewFunction();
+ break;
+ }
+ }
+
+ public IntentFilter buildFilter() {
+ final IntentFilter intentFilter = new IntentFilter();
+ intentFilter.addAction(COMMAND_ACTIVITY_SYNC);
+ intentFilter.addAction(COMMAND_TRIGGER_EXPORT);
+ intentFilter.addAction(COMMAND_DEBUG_SEND_NOTIFICATION);
+ intentFilter.addAction(COMMAND_DEBUG_INCOMING_CALL);
+ intentFilter.addAction(COMMAND_DEBUG_END_CALL);
+ intentFilter.addAction(COMMAND_DEBUG_SET_DEVICE_ADDRESS);
+ intentFilter.addAction(COMMAND_DEBUG_TEST_NEW_FUNCTION);
+ return intentFilter;
+ }
+
+ private void setDeviceAddress(final Intent intent) {
+ final String oldAddress = intent.getStringExtra("oldAddress");
+ if (!validAddress(oldAddress)) {
+ return;
+ }
+
+ final String newAddress = intent.getStringExtra("newAddress");
+ if (!validAddress(newAddress)) {
+ return;
+ }
+
+ if (oldAddress.equals(newAddress)) {
+ LOG.warn("Old and new addresses are the same");
+ return;
+ }
+
+ final GBDevice oldDevice = GBApplication.app()
+ .getDeviceManager()
+ .getDeviceByAddress(oldAddress);
+ if (oldDevice == null) {
+ LOG.warn("Old device with address {} not found", oldAddress);
+ return;
+ }
+
+ final GBDevice newDevice = GBApplication.app()
+ .getDeviceManager()
+ .getDeviceByAddress(newAddress);
+ if (newDevice != null) {
+ LOG.warn("New device address {} already exists", newAddress);
+ return;
+ }
+
+ LOG.info("Updating device address from {} to {}", oldAddress, newAddress);
+
+ final SharedPreferences settingsOld = GBApplication.getDeviceSpecificSharedPrefs(oldAddress);
+ final SharedPreferences settingsNew = GBApplication.getDeviceSpecificSharedPrefs(newAddress);
+ final SharedPreferences.Editor editorNew = settingsNew.edit().clear();
+ final Map<String, ?> allSettings = settingsOld.getAll();
+ LOG.debug("Copying {} preferences to new device", allSettings.size());
+ for (final Map.Entry<String, ?> e : allSettings.entrySet()) {
+ if (e.getValue().getClass().equals(Boolean.class)) {
+ editorNew.putBoolean(e.getKey(), (Boolean) e.getValue());
+ } else if (e.getValue().getClass().equals(Float.class)) {
+ editorNew.putFloat(e.getKey(), (Float) e.getValue());
+ } else if (e.getValue().getClass().equals(Integer.class)) {
+ editorNew.putInt(e.getKey(), (Integer) e.getValue());
+ } else if (e.getValue().getClass().equals(Long.class)) {
+ editorNew.putLong(e.getKey(), (Long) e.getValue());
+ } else if (e.getValue().getClass().equals(String.class)) {
+ editorNew.putString(e.getKey(), (String) e.getValue());
+ } else if (e.getValue().getClass().equals(HashSet.class)) {
+ editorNew.putStringSet(e.getKey(), (HashSet<String>) e.getValue());
+ } else {
+ LOG.error("Unexpected preference type {}", e.getValue().getClass());
+ return;
+ }
+ }
+ if (!editorNew.commit()) {
+ LOG.error("Failed to persist preferences for new address");
+ return;
+ }
+
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ final DaoSession session = dbHandler.getDaoSession();
+ DBHelper.updateDeviceMacAddress(session, oldAddress, newAddress);
+ } catch (final Exception e) {
+ LOG.error("Failed to update device address", e);
+ return;
+ }
+
+ LOG.info("Quitting GB after device address update");
+
+ GBApplication.quit();
+ }
+
+ private boolean validAddress(final String address) {
+ if (address == null) {
+ return false;
+ }
+
+ if (!address.matches(MAC_ADDR_PATTERN)) {
+ LOG.warn("Device address '{}' does not match '{}'", address, MAC_ADDR_PATTERN);
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/LineageOsWeatherReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/LineageOsWeatherReceiver.java
new file mode 100644
index 0000000..4da0da7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/LineageOsWeatherReceiver.java
@@ -0,0 +1,229 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Jos茅 Rebelo, keeshii,
+ Taavi Eom盲e
+
+ 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;
+
+import static lineageos.providers.WeatherContract.WeatherColumns.TempUnit.FAHRENHEIT;
+import static lineageos.providers.WeatherContract.WeatherColumns.WeatherCode.ISOLATED_THUNDERSHOWERS;
+import static lineageos.providers.WeatherContract.WeatherColumns.WeatherCode.NOT_AVAILABLE;
+import static lineageos.providers.WeatherContract.WeatherColumns.WeatherCode.SCATTERED_SNOW_SHOWERS;
+import static lineageos.providers.WeatherContract.WeatherColumns.WeatherCode.SCATTERED_THUNDERSTORMS;
+import static lineageos.providers.WeatherContract.WeatherColumns.WeatherCode.SHOWERS;
+import static lineageos.providers.WeatherContract.WeatherColumns.WindSpeedUnit.MPH;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Build;
+
+import androidx.annotation.RequiresApi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.List;
+
+import lineageos.weather.LineageWeatherManager;
+import lineageos.weather.WeatherInfo;
+import lineageos.weather.WeatherLocation;
+import lineageos.weather.util.WeatherUtils;
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.Weather;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+@RequiresApi(api = Build.VERSION_CODES.M)
+public class LineageOsWeatherReceiver extends BroadcastReceiver implements LineageWeatherManager.WeatherUpdateRequestListener, LineageWeatherManager.LookupCityRequestListener {
+
+ private static final Logger LOG = LoggerFactory.getLogger(LineageOsWeatherReceiver.class);
+
+ private WeatherLocation weatherLocation = null;
+ private Context mContext;
+ private PendingIntent mPendingIntent = null;
+
+ public LineageOsWeatherReceiver() {
+ mContext = GBApplication.getContext();
+ final LineageWeatherManager weatherManager = LineageWeatherManager.getInstance(mContext);
+ if (weatherManager == null) {
+ return;
+ }
+
+ Prefs prefs = GBApplication.getPrefs();
+
+ String city = prefs.getString("weather_city", null);
+ String cityId = prefs.getString("weather_cityid", null);
+
+ if ((cityId == null || cityId.equals("")) && city != null && !city.equals("")) {
+ lookupCity(city);
+ } else if (city != null && cityId != null) {
+ weatherLocation = new WeatherLocation.Builder(cityId, city).build();
+ enablePeriodicAlarm(true);
+ }
+ }
+
+ private void lookupCity(String city) {
+ final LineageWeatherManager weatherManager = LineageWeatherManager.getInstance(mContext);
+ if (weatherManager == null) {
+ return;
+ }
+
+ if (city != null && !city.equals("")) {
+ if (weatherManager.getActiveWeatherServiceProviderLabel() != null) {
+ weatherManager.lookupCity(city, this);
+ }
+ }
+ }
+
+ private void enablePeriodicAlarm(boolean enable) {
+ if ((mPendingIntent != null && enable) || (mPendingIntent == null && !enable)) {
+ return;
+ }
+
+ AlarmManager am = (AlarmManager) (mContext.getSystemService(Context.ALARM_SERVICE));
+ if (am == null) {
+ LOG.warn("could not get alarm manager!");
+ return;
+ }
+
+ if (enable) {
+ Intent intent = new Intent("GB_UPDATE_WEATHER");
+ intent.setPackage(BuildConfig.APPLICATION_ID);
+ mPendingIntent = PendingIntentUtils.getBroadcast(mContext, 0, intent, 0, false);
+ am.setInexactRepeating(AlarmManager.RTC_WAKEUP, Calendar.getInstance().getTimeInMillis() + 10000, AlarmManager.INTERVAL_HOUR, mPendingIntent);
+ } else {
+ am.cancel(mPendingIntent);
+ mPendingIntent = null;
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Prefs prefs = GBApplication.getPrefs();
+
+ String city = prefs.getString("weather_city", null);
+ String cityId = prefs.getString("weather_cityid", null);
+
+ if (city != null && !city.equals("") && cityId == null) {
+ lookupCity(city);
+ } else {
+ requestWeather();
+ }
+ }
+
+ private void requestWeather() {
+ final LineageWeatherManager weatherManager = LineageWeatherManager.getInstance(GBApplication.getContext());
+ if (weatherManager.getActiveWeatherServiceProviderLabel() != null && weatherLocation != null) {
+ weatherManager.requestWeatherUpdate(weatherLocation, this);
+ }
+ }
+
+ /**
+ * @param cmCondition LineageOS/CyanogenMod condition code
+ * @return Yahoo condition code
+ */
+ private static int LineageOSToYahooCondition(int cmCondition) {
+ int yahooCondition;
+ if (cmCondition <= SHOWERS) {
+ yahooCondition = cmCondition;
+ } else if (cmCondition <= SCATTERED_THUNDERSTORMS) {
+ yahooCondition = cmCondition + 1;
+ } else if (cmCondition <= SCATTERED_SNOW_SHOWERS) {
+ yahooCondition = cmCondition + 2;
+ } else if (cmCondition <= ISOLATED_THUNDERSHOWERS) {
+ yahooCondition = cmCondition + 3;
+ } else {
+ yahooCondition = NOT_AVAILABLE;
+ }
+ return yahooCondition;
+ }
+
+ @Override
+ public void onWeatherRequestCompleted(int status, WeatherInfo weatherInfo) {
+ if (weatherInfo != null) {
+ LOG.info("weather: " + weatherInfo.toString());
+ WeatherSpec weatherSpec = new WeatherSpec();
+ weatherSpec.timestamp = (int) (weatherInfo.getTimestamp() / 1000);
+ weatherSpec.location = weatherInfo.getCity();
+
+ if (weatherInfo.getTemperatureUnit() == FAHRENHEIT) {
+ weatherSpec.currentTemp = (int) WeatherUtils.fahrenheitToCelsius(weatherInfo.getTemperature()) + 273;
+ weatherSpec.todayMaxTemp = (int) WeatherUtils.fahrenheitToCelsius(weatherInfo.getTodaysHigh()) + 273;
+ weatherSpec.todayMinTemp = (int) WeatherUtils.fahrenheitToCelsius(weatherInfo.getTodaysLow()) + 273;
+ } else {
+ weatherSpec.currentTemp = (int) weatherInfo.getTemperature() + 273;
+ weatherSpec.todayMaxTemp = (int) weatherInfo.getTodaysHigh() + 273;
+ weatherSpec.todayMinTemp = (int) weatherInfo.getTodaysLow() + 273;
+ }
+ if (weatherInfo.getWindSpeedUnit() == MPH) {
+ weatherSpec.windSpeed = (float) weatherInfo.getWindSpeed() * 1.609344f;
+ } else {
+ weatherSpec.windSpeed = (float) weatherInfo.getWindSpeed();
+ }
+ weatherSpec.windDirection = (int) weatherInfo.getWindDirection();
+
+ weatherSpec.currentConditionCode = Weather.mapToOpenWeatherMapCondition(LineageOSToYahooCondition(weatherInfo.getConditionCode()));
+ weatherSpec.currentCondition = Weather.getConditionString(weatherSpec.currentConditionCode);
+ weatherSpec.currentHumidity = (int) weatherInfo.getHumidity();
+
+ weatherSpec.forecasts = new ArrayList<>();
+ List<WeatherInfo.DayForecast> forecasts = weatherInfo.getForecasts();
+ for (int i = 1; i < forecasts.size(); i++) {
+ WeatherInfo.DayForecast cmForecast = forecasts.get(i);
+ WeatherSpec.Daily gbForecast = new WeatherSpec.Daily();
+ if (weatherInfo.getTemperatureUnit() == FAHRENHEIT) {
+ gbForecast.maxTemp = (int) WeatherUtils.fahrenheitToCelsius(cmForecast.getHigh()) + 273;
+ gbForecast.minTemp = (int) WeatherUtils.fahrenheitToCelsius(cmForecast.getLow()) + 273;
+ } else {
+ gbForecast.maxTemp = (int) cmForecast.getHigh() + 273;
+ gbForecast.minTemp = (int) cmForecast.getLow() + 273;
+ }
+ gbForecast.conditionCode = Weather.mapToOpenWeatherMapCondition(LineageOSToYahooCondition(cmForecast.getConditionCode()));
+ weatherSpec.forecasts.add(gbForecast);
+ }
+ ArrayList<WeatherSpec> weatherSpecs = new ArrayList<>(Collections.singletonList(weatherSpec));
+ Weather.getInstance().setWeatherSpec(weatherSpecs);
+ GBApplication.deviceService().onSendWeather(weatherSpecs);
+ } else {
+ LOG.info("request has returned null for WeatherInfo");
+ }
+ }
+
+ @Override
+ public void onLookupCityRequestCompleted(int result, List<WeatherLocation> list) {
+ if (list != null) {
+ weatherLocation = list.get(0);
+ String cityId = weatherLocation.getCityId();
+ String city = weatherLocation.getCity();
+
+ SharedPreferences.Editor editor = GBApplication.getPrefs().getPreferences().edit();
+ editor.putString("weather_city", city).apply();
+ editor.putString("weather_cityid", cityId).apply();
+ enablePeriodicAlarm(true);
+ requestWeather();
+ } else {
+ enablePeriodicAlarm(false);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/MusicPlaybackReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/MusicPlaybackReceiver.java
new file mode 100644
index 0000000..ff5c1e1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/MusicPlaybackReceiver.java
@@ -0,0 +1,117 @@
+/* Copyright (C) 2015-2024 andre, Andreas Shimokawa, Avamander, Carsten
+ Pfeiffer, Daniele Gobbetti
+
+ 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;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
+
+public class MusicPlaybackReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(MusicPlaybackReceiver.class);
+ private static MusicSpec lastMusicSpec = new MusicSpec();
+ private static MusicStateSpec lastStateSpec = new MusicStateSpec();
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ /*
+ Bundle bundle = intent.getExtras();
+ for (String key : bundle.keySet()) {
+ Object value = bundle.get(key);
+ LOG.info(String.format("%s %s (%s)", key,
+ value != null ? value.toString() : "null", value != null ? value.getClass().getName() : "no class"));
+ }
+ */
+ MusicSpec musicSpec = new MusicSpec(lastMusicSpec);
+ MusicStateSpec stateSpec = new MusicStateSpec(lastStateSpec);
+
+ Bundle incomingBundle = intent.getExtras();
+
+ if (incomingBundle == null) {
+ LOG.warn("Not processing incoming null bundle.");
+ return;
+ }
+
+ for (String key : incomingBundle.keySet()) {
+ Object incoming = incomingBundle.get(key);
+ if (incoming instanceof String && "artist".equals(key)) {
+ musicSpec.artist = (String) incoming;
+ } else if (incoming instanceof String && "album".equals(key)) {
+ musicSpec.album = (String) incoming;
+ } else if (incoming instanceof String && "track".equals(key)) {
+ musicSpec.track = (String) incoming;
+ } else if (incoming instanceof String && "title".equals(key) && musicSpec.track == null) {
+ musicSpec.track = (String) incoming;
+ } else if (incoming instanceof Integer && "duration".equals(key)) {
+ musicSpec.duration = (Integer) incoming / 1000;
+ } else if (incoming instanceof Long && "duration".equals(key)) {
+ musicSpec.duration = ((Long) incoming).intValue() / 1000;
+ } else if (incoming instanceof Integer && "position".equals(key)) {
+ stateSpec.position = (Integer) incoming / 1000;
+ } else if (incoming instanceof Long && "position".equals(key)) {
+ stateSpec.position = ((Long) incoming).intValue() / 1000;
+ } else if (incoming instanceof Boolean && "playing".equals(key)) {
+ stateSpec.state = (byte) (((Boolean) incoming) ? MusicStateSpec.STATE_PLAYING : MusicStateSpec.STATE_PAUSED);
+ stateSpec.playRate = (byte) (((Boolean) incoming) ? 100 : 0);
+ } else if (incoming instanceof String && "duration".equals(key)) {
+ musicSpec.duration = Integer.parseInt((String) incoming) / 1000;
+ } else if (incoming instanceof String && "trackno".equals(key)) {
+ musicSpec.trackNr = Integer.parseInt((String) incoming);
+ } else if (incoming instanceof String && "totaltrack".equals(key)) {
+ musicSpec.trackCount = Integer.parseInt((String) incoming);
+ } else if (incoming instanceof Integer && "pos".equals(key)) {
+ stateSpec.position = (Integer) incoming;
+ } else if (incoming instanceof Integer && "repeat".equals(key)) {
+ if ((Integer) incoming > 0) {
+ stateSpec.repeat = 1;
+ } else {
+ stateSpec.repeat = 0;
+ }
+ } else if (incoming instanceof Integer && "shuffle".equals(key)) {
+ if ((Integer) incoming > 0) {
+ stateSpec.shuffle = 1;
+ } else {
+ stateSpec.shuffle = 0;
+ }
+ }
+ }
+
+ if (!lastMusicSpec.equals(musicSpec)) {
+ lastMusicSpec = musicSpec;
+ LOG.info("Update Music Info: " + musicSpec.artist + " / " + musicSpec.album + " / " + musicSpec.track);
+ GBApplication.deviceService().onSetMusicInfo(musicSpec);
+ } else {
+ LOG.info("Got metadata changed intent, but nothing changed, ignoring.");
+ }
+
+ if (!lastStateSpec.equals(stateSpec)) {
+ lastStateSpec = stateSpec;
+ LOG.info("Update Music State: state=" + stateSpec.state + ", position= " + stateSpec.position);
+ GBApplication.deviceService().onSetMusicState(stateSpec);
+ } else {
+ LOG.info("Got state changed intent, but not enough has changed, ignoring.");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java
new file mode 100644
index 0000000..cb2754f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java
@@ -0,0 +1,1113 @@
+/* Copyright (C) 2015-2024 abettenburg, Andreas B枚hler, Andreas Shimokawa,
+ AndrewBedscastle, Arjan Schrijver, Carsten Pfeiffer, Daniel Dakhno, Daniele
+ Gobbetti, Davis Mosenkovs, Dmitriy Bogdanov, Dmitry Markin, Frank Slezak,
+ gnufella, Gordon Williams, Hasan Ammar, Jos茅 Rebelo, Julien Pivotto,
+ Kevin Richter, mamucho, Matthieu Baerts, mvn23, Normano64, Petr Kadlec,
+ Petr Van臎k, Steffen Liebergeld, Taavi Eom盲e, theghostofheathledger, t-m-w,
+ veecue, Zhong Jianxin
+
+ 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;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.media.session.MediaController;
+import android.media.session.MediaSession;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.PowerManager;
+import android.os.Process;
+import android.os.UserHandle;
+import android.provider.MediaStore;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.StatusBarNotification;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.RemoteInput;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.palette.graphics.Palette;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import de.greenrobot.dao.query.Query;
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleColor;
+import nodomain.freeyourgadget.gadgetbridge.entities.NotificationFilter;
+import nodomain.freeyourgadget.gadgetbridge.entities.NotificationFilterDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.NotificationFilterEntry;
+import nodomain.freeyourgadget.gadgetbridge.entities.NotificationFilterEntryDao;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.notifications.GoogleMapsNotificationHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.AppNotificationType;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceCommunicationService;
+import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
+import nodomain.freeyourgadget.gadgetbridge.util.MediaManager;
+import nodomain.freeyourgadget.gadgetbridge.util.NotificationUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.PebbleUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.NotificationFilterActivity.NOTIFICATION_FILTER_MODE_BLACKLIST;
+import static nodomain.freeyourgadget.gadgetbridge.activities.NotificationFilterActivity.NOTIFICATION_FILTER_MODE_WHITELIST;
+import static nodomain.freeyourgadget.gadgetbridge.activities.NotificationFilterActivity.NOTIFICATION_FILTER_SUBMODE_ALL;
+import static nodomain.freeyourgadget.gadgetbridge.util.StringUtils.ensureNotNull;
+
+public class NotificationListener extends NotificationListenerService {
+
+ private static final Logger LOG = LoggerFactory.getLogger(NotificationListener.class);
+
+ public static final String ACTION_DISMISS
+ = "nodomain.freeyourgadget.gadgetbridge.notificationlistener.action.dismiss";
+ public static final String ACTION_DISMISS_ALL
+ = "nodomain.freeyourgadget.gadgetbridge.notificationlistener.action.dismiss_all";
+ public static final String ACTION_OPEN
+ = "nodomain.freeyourgadget.gadgetbridge.notificationlistener.action.open";
+ public static final String ACTION_MUTE
+ = "nodomain.freeyourgadget.gadgetbridge.notificationlistener.action.mute";
+ public static final String ACTION_REPLY
+ = "nodomain.freeyourgadget.gadgetbridge.notificationlistener.action.reply";
+
+ private final LimitedQueue<Integer, NotificationCompat.Action> mActionLookup = new LimitedQueue<>(32);
+ private final LimitedQueue<Integer, String> mPackageLookup = new LimitedQueue<>(64);
+ private final LimitedQueue<Integer, Long> mNotificationHandleLookup = new LimitedQueue<>(128);
+
+ private final HashMap<String, Long> notificationBurstPrevention = new HashMap<>();
+ private final HashMap<String, Long> notificationOldRepeatPrevention = new HashMap<>();
+
+ private static final Set<String> GROUP_SUMMARY_WHITELIST = new HashSet<String>() {{
+ add("com.microsoft.office.lync15");
+ add("com.skype.raider");
+ add("mikado.bizcalpro");
+ }};
+
+ public static final ArrayList<String> notificationStack = new ArrayList<>();
+ private static final ArrayList<Integer> notificationsActive = new ArrayList<>();
+
+ private static final Set<String> supportedPictureMimeTypes = new HashSet<String>() {{
+ add("image/"); //for im.vector.app
+ add("image/jpeg");
+ add("image/png");
+ add("image/gif");
+ add("image/bmp");
+ add("image/webp");
+ }};
+
+ private File notificationPictureCacheDirectory;
+
+ private long activeCallPostTime;
+ private int mLastCallCommand = CallSpec.CALL_UNDEFINED;
+
+ private final Handler mHandler = new Handler();
+ private Runnable mSetMusicInfoRunnable = null;
+ private Runnable mSetMusicStateRunnable = null;
+
+ private final GoogleMapsNotificationHandler googleMapsNotificationHandler = new GoogleMapsNotificationHandler();
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action == null) {
+ LOG.warn("no action");
+ return;
+ }
+
+ int handle = (int) intent.getLongExtra("handle", -1);
+ switch (action) {
+ case GBApplication.ACTION_QUIT:
+ stopSelf();
+ break;
+
+ case ACTION_OPEN: {
+ StatusBarNotification[] sbns = NotificationListener.this.getActiveNotifications();
+ Long ts = mNotificationHandleLookup.lookup(handle);
+ if (ts == null) {
+ LOG.info("could not lookup handle for open action");
+ break;
+ }
+
+ for (StatusBarNotification sbn : sbns) {
+ if (sbn.getPostTime() == ts) {
+ try {
+ PendingIntent pi = sbn.getNotification().contentIntent;
+ if (pi != null) {
+ pi.send();
+ }
+ } catch (final PendingIntent.CanceledException e) {
+ LOG.error("Failed to open notification {}", sbn.getId());
+ }
+ }
+ }
+ break;
+ }
+ case ACTION_MUTE:
+ String packageName = mPackageLookup.lookup(handle);
+ if (packageName == null) {
+ LOG.info("could not lookup handle for mute action");
+ break;
+ }
+ LOG.info("going to mute {}", packageName);
+ if (GBApplication.getPrefs().getString("notification_list_is_blacklist", "true").equals("true")) {
+ GBApplication.addAppToNotifBlacklist(packageName);
+ } else {
+ GBApplication.removeFromAppsNotifBlacklist(packageName);
+ }
+ break;
+ case ACTION_DISMISS: {
+ StatusBarNotification[] sbns = NotificationListener.this.getActiveNotifications();
+ Long ts = mNotificationHandleLookup.lookup(handle);
+ if (ts == null) {
+ LOG.info("could not lookup handle for dismiss action");
+ break;
+ }
+ for (StatusBarNotification sbn : sbns) {
+ if (sbn.getPostTime() == ts) {
+ String key = sbn.getKey();
+ NotificationListener.this.cancelNotification(key);
+ }
+ }
+ break;
+ }
+ case ACTION_DISMISS_ALL:
+ NotificationListener.this.cancelAllNotifications();
+ break;
+ case ACTION_REPLY:
+ NotificationCompat.Action wearableAction = mActionLookup.lookup(handle);
+ String reply = intent.getStringExtra("reply");
+ if (wearableAction != null) {
+ PendingIntent actionIntent = wearableAction.getActionIntent();
+ if (actionIntent == null) {
+ LOG.warn("Action intent is null");
+ break;
+ }
+ Intent localIntent = new Intent();
+ localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ if (wearableAction.getRemoteInputs() != null && wearableAction.getRemoteInputs().length > 0) {
+ RemoteInput[] remoteInputs = wearableAction.getRemoteInputs();
+ Bundle extras = new Bundle();
+ extras.putCharSequence(remoteInputs[0].getResultKey(), reply);
+ RemoteInput.addResultsToIntent(remoteInputs, localIntent, extras);
+ }
+ try {
+ LOG.info("will send exec intent to remote application");
+ actionIntent.send(context, 0, localIntent);
+ mActionLookup.remove(handle);
+ } catch (final PendingIntent.CanceledException e) {
+ LOG.warn("replyToLastNotification error", e);
+ }
+ } else {
+ LOG.warn("Received ACTION_REPLY but cannot find the corresponding wearableAction");
+ }
+ break;
+ }
+ }
+ };
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ IntentFilter filterLocal = new IntentFilter();
+ filterLocal.addAction(GBApplication.ACTION_QUIT);
+ filterLocal.addAction(ACTION_OPEN);
+ filterLocal.addAction(ACTION_DISMISS);
+ filterLocal.addAction(ACTION_DISMISS_ALL);
+ filterLocal.addAction(ACTION_MUTE);
+ filterLocal.addAction(ACTION_REPLY);
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filterLocal);
+ createNotificationPictureCacheDirectory();
+ cleanUpNotificationPictureProvider();
+ }
+
+ @Override
+ public void onDestroy() {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
+ notificationStack.clear();
+ notificationsActive.clear();
+ cleanUpNotificationPictureProvider();
+ super.onDestroy();
+ }
+
+ @Override
+ public void onNotificationPosted(StatusBarNotification sbn) {
+ onNotificationPosted(sbn, null);
+ }
+
+ @Override
+ public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
+ logNotification(sbn, true);
+
+ notificationStack.remove(sbn.getPackageName());
+ notificationStack.add(sbn.getPackageName());
+
+ if (isServiceNotRunningAndShouldIgnoreNotifications()) return;
+
+ final GBPrefs prefs = GBApplication.getPrefs();
+
+ if (isOutsideNotificationTimes(prefs)) {
+ return;
+ }
+
+ final boolean ignoreWorkProfile = prefs.getBoolean("notifications_ignore_work_profile", false);
+ if (ignoreWorkProfile && isWorkProfile(sbn)) {
+ LOG.debug("Ignoring notification from work profile");
+ return;
+ }
+
+ final boolean mediaIgnoresAppList = prefs.getBoolean("notification_media_ignores_application_list", false);
+
+ // If media notifications ignore app list, check them before
+ if (mediaIgnoresAppList && handleMediaSessionNotification(sbn)) return;
+
+ if (shouldIgnoreSource(sbn)) return;
+
+ /* Check for navigation notifications and ignore if we're handling them */
+ if (googleMapsNotificationHandler.handle(getApplicationContext(), sbn)) return;
+
+ // If media notifications do NOT ignore app list, check them after
+ if (!mediaIgnoresAppList && handleMediaSessionNotification(sbn)) return;
+
+ int dndSuppressed = 0;
+ if (rankingMap != null) {
+ // Handle priority notifications for Do Not Disturb
+ Ranking ranking = new Ranking();
+ if (rankingMap.getRanking(sbn.getKey(), ranking)) {
+ if (!ranking.matchesInterruptionFilter()) dndSuppressed = 1;
+ }
+ }
+
+ if (prefs.getBoolean("notification_filter", false) && dndSuppressed == 1) {
+ LOG.debug("Ignoring notification because of do not disturb");
+ return;
+ }
+
+ if (NotificationCompat.CATEGORY_CALL.equals(sbn.getNotification().category)
+ && prefs.getBoolean("notification_support_voip_calls", false)
+ && (sbn.isOngoing() || shouldDisplayNonOngoingCallNotification(sbn))) {
+ handleCallNotification(sbn);
+ return;
+ }
+
+ if (shouldIgnoreNotification(sbn, false)) {
+ if (!"com.sec.android.app.clockpackage".equals(sbn.getPackageName())) { // workaround to allow phone alarm notification
+ LOG.info("Ignoring notification: {}", sbn.getPackageName()); // need to fix
+ return;
+ }
+ }
+
+ String source = sbn.getPackageName();
+ Notification notification = sbn.getNotification();
+
+ Long notificationOldRepeatPreventionValue = notificationOldRepeatPrevention.get(source);
+ if (notificationOldRepeatPreventionValue != null
+ && notification.when <= notificationOldRepeatPreventionValue
+ && !shouldIgnoreRepeatPrevention(sbn)
+ ) {
+ LOG.info("NOT processing notification, already sent newer notifications from this source.");
+ return;
+ }
+
+ // Ignore too frequent notifications, according to user preference
+ long curTime = System.nanoTime();
+ Long notificationBurstPreventionValue = notificationBurstPrevention.get(source);
+ if (notificationBurstPreventionValue != null) {
+ long diff = curTime - notificationBurstPreventionValue;
+ if (diff < TimeUnit.SECONDS.toNanos(prefs.getInt("notifications_timeout", 0))) {
+ LOG.info("Ignoring frequent notification, last one was {} ms ago", TimeUnit.NANOSECONDS.toMillis(diff));
+ return;
+ }
+ }
+
+ NotificationSpec notificationSpec = new NotificationSpec();
+ notificationSpec.key = sbn.getKey();
+ notificationSpec.when = notification.when;
+
+ // determinate Source App Name ("Label")
+ String name = NotificationUtils.getApplicationLabel(this, source);
+ if (name != null) {
+ notificationSpec.sourceName = name;
+ }
+
+ // Get the app ID that generated this notification. For now only used by pebble color, but may be more useful later.
+ notificationSpec.sourceAppId = source;
+
+ // Get the icon of the notification
+ notificationSpec.iconId = notification.icon;
+
+ notificationSpec.type = AppNotificationType.getInstance().get(source);
+
+ //FIXME: some quirks lookup table would be the minor evil here
+ if (source.startsWith("com.fsck.k9")) {
+ if (NotificationCompat.isGroupSummary(notification)) {
+ LOG.info("ignore K9 group summary");
+ return;
+ }
+ }
+
+ if (notificationSpec.type == null) {
+ notificationSpec.type = NotificationType.UNKNOWN;
+ }
+
+ // Get color
+ notificationSpec.pebbleColor = getPebbleColorForNotification(notificationSpec);
+
+ LOG.info(
+ "Processing notification {}, age: {}, source: {}, flags: {}",
+ notificationSpec.getId(),
+ (System.currentTimeMillis() - notification.when) ,
+ source,
+ notification.flags
+ );
+
+ boolean preferBigText = prefs.getBoolean("notification_prefer_long_text", true);
+
+ dissectNotificationTo(notification, notificationSpec, preferBigText);
+
+ if (notificationSpec.title != null || notificationSpec.body != null) {
+ final String textToCheck = ensureNotNull(notificationSpec.title) + " " + ensureNotNull(notificationSpec.body);
+ if (!checkNotificationContentForWhiteAndBlackList(sbn.getPackageName().toLowerCase(), textToCheck)) {
+ return;
+ }
+ }
+
+ // ignore Gadgetbridge's very own notifications, except for those from the debug screen
+ if (getApplicationContext().getPackageName().equals(source)) {
+ if (!getApplicationContext().getString(R.string.test_notification).equals(notificationSpec.title)) {
+ return;
+ }
+ }
+
+ NotificationCompat.WearableExtender wearableExtender = new NotificationCompat.WearableExtender(notification);
+ List<NotificationCompat.Action> actions = wearableExtender.getActions();
+
+ // Some apps such as Telegram send both a group + normal notifications, which would get sent in duplicate to the devices
+ // Others only send the group summary, so they need to be whitelisted
+ if (actions.isEmpty() && NotificationCompat.isGroupSummary(notification)
+ && !GROUP_SUMMARY_WHITELIST.contains(source)) { //this could cause #395 to come back
+ LOG.info("Not forwarding notification, FLAG_GROUP_SUMMARY is set and no wearable action present. Notification flags: " + notification.flags);
+ return;
+ }
+
+ notificationSpec.attachedActions = new ArrayList<>();
+ notificationSpec.dndSuppressed = dndSuppressed;
+
+ // DISMISS action
+ NotificationSpec.Action dismissAction = new NotificationSpec.Action();
+ dismissAction.title = "Dismiss";
+ dismissAction.type = NotificationSpec.Action.TYPE_SYNTECTIC_DISMISS;
+ notificationSpec.attachedActions.add(dismissAction);
+
+ for (NotificationCompat.Action act : actions) {
+ if (act != null) {
+ NotificationSpec.Action wearableAction = new NotificationSpec.Action();
+ wearableAction.title = act.getTitle().toString();
+ if (act.getRemoteInputs() != null && act.getRemoteInputs().length > 0) {
+ wearableAction.type = NotificationSpec.Action.TYPE_WEARABLE_REPLY;
+ } else {
+ wearableAction.type = NotificationSpec.Action.TYPE_WEARABLE_SIMPLE;
+ }
+ notificationSpec.attachedActions.add(wearableAction);
+ wearableAction.handle = (notificationSpec.getId() << 4) + notificationSpec.attachedActions.size();
+ mActionLookup.add((int)wearableAction.handle, act);
+ LOG.info("Found wearable action: {} - {} {}", notificationSpec.attachedActions.size(), act.getTitle(), sbn.getTag());
+ }
+ }
+
+ // OPEN action
+ NotificationSpec.Action openAction = new NotificationSpec.Action();
+ openAction.title = getString(R.string._pebble_watch_open_on_phone);
+ openAction.type = NotificationSpec.Action.TYPE_SYNTECTIC_OPEN;
+ notificationSpec.attachedActions.add(openAction);
+
+ // MUTE action
+ NotificationSpec.Action muteAction = new NotificationSpec.Action();
+ muteAction.title = getString(R.string._pebble_watch_mute);
+ muteAction.type = NotificationSpec.Action.TYPE_SYNTECTIC_MUTE;
+ notificationSpec.attachedActions.add(muteAction);
+
+ mNotificationHandleLookup.add(notificationSpec.getId(), sbn.getPostTime()); // for both DISMISS and OPEN
+ mPackageLookup.add(notificationSpec.getId(), sbn.getPackageName()); // for MUTE
+
+ notificationBurstPrevention.put(source, curTime);
+ if (notification.when == 0) {
+ LOG.info("This app might show old/duplicate notifications. notification.when is 0 for {}", source);
+ } else if ((notification.when - System.currentTimeMillis()) > 30_000L) {
+ // #4327 - Some apps such as outlook send reminder notifications in the future
+ // If we add them to the oldRepeatPrevention, they never show up again
+ LOG.info("This app might show old/duplicate notifications. notification.when is in the future for {}", source);
+ } else {
+ notificationOldRepeatPrevention.put(source, notification.when);
+ }
+ notificationsActive.add(notificationSpec.getId());
+ // NOTE for future developers: this call goes to implementations of DeviceService.onNotification(NotificationSpec), like in GBDeviceService
+ // this does NOT directly go to implementations of DeviceSupport.onNotification(NotificationSpec)!
+ GBApplication.deviceService().onNotification(notificationSpec);
+ }
+
+ private static boolean isOutsideNotificationTimes(final GBPrefs prefs) {
+ if (!prefs.getNotificationTimesEnabled()) {
+ return false;
+ }
+
+ final LocalTime now = LocalTime.now();
+ final LocalTime start = prefs.getNotificationTimesStart();
+ final LocalTime end = prefs.getNotificationTimesEnd();
+ final boolean shouldIgnore;
+ if (start.isBefore(end)) {
+ // eg. 06:00 -> 22:00
+ shouldIgnore = now.isAfter(start) && now.isBefore(end);
+ } else {
+ // goes past midnight, eg. 22:00 -> 06:00
+ shouldIgnore = now.isAfter(start) || now.isBefore(end);
+ }
+
+ if (shouldIgnore) {
+ LOG.debug("Ignoring notification outside of notification times {}/{}", start, end);
+ }
+
+ return shouldIgnore;
+ }
+
+ private boolean checkNotificationContentForWhiteAndBlackList(String packageName, String body) {
+ long start = System.currentTimeMillis();
+
+ List<String> wordsList = new ArrayList<>();
+ NotificationFilter notificationFilter;
+
+ try (DBHandler db = GBApplication.acquireDB()) {
+
+ NotificationFilterDao notificationFilterDao = db.getDaoSession().getNotificationFilterDao();
+ NotificationFilterEntryDao notificationFilterEntryDao = db.getDaoSession().getNotificationFilterEntryDao();
+
+ Query<NotificationFilter> query = notificationFilterDao.queryBuilder().where(NotificationFilterDao.Properties.AppIdentifier.eq(packageName.toLowerCase())).build();
+ notificationFilter = query.unique();
+
+ if (notificationFilter == null) {
+ LOG.debug("No Notification Filter found");
+ return true;
+ }
+
+ LOG.debug("Loaded notification filter for '{}'", packageName);
+ Query<NotificationFilterEntry> queryEntries = notificationFilterEntryDao.queryBuilder().where(NotificationFilterEntryDao.Properties.NotificationFilterId.eq(notificationFilter.getId())).build();
+
+ List<NotificationFilterEntry> filterEntries = queryEntries.list();
+
+ if (BuildConfig.DEBUG) {
+ LOG.info("Database lookup took '{}' ms", System.currentTimeMillis() - start);
+ }
+
+ if (!filterEntries.isEmpty()) {
+ for (NotificationFilterEntry temp : filterEntries) {
+ wordsList.add(temp.getNotificationFilterContent());
+ LOG.debug("Loaded filter word: " + temp.getNotificationFilterContent());
+ }
+ }
+
+ } catch (Exception e) {
+ LOG.error("Could not acquire DB.", e);
+ return true;
+ }
+
+ return shouldContinueAfterFilter(body, wordsList, notificationFilter);
+ }
+
+ private void handleCallNotification(StatusBarNotification sbn) {
+ String app = sbn.getPackageName();
+ LOG.debug("got call from: " + app);
+ if (app.equals("com.android.dialer") || app.equals("com.android.incallui") || app.equals("com.google.android.dialer") || app.equals("com.asus.asusincallui") || app.equals("com.samsung.android.incallui")) {
+ LOG.debug("Ignoring non-voip call");
+ return;
+ }
+ Notification noti = sbn.getNotification();
+ dumpExtras(noti.extras);
+ boolean callStarted = false;
+ if (noti.actions != null && noti.actions.length > 0) {
+ for (Notification.Action action : noti.actions) {
+ LOG.info("Found call action: " + action.title);
+ }
+ if (noti.actions.length == 1) {
+ if (mLastCallCommand == CallSpec.CALL_INCOMING) {
+ LOG.info("There is only one call action and previous state was CALL_INCOMING, assuming call started");
+ callStarted = true;
+ } else {
+ LOG.info("There is only one call action and previous state was not CALL_INCOMING, assuming outgoing call / duplicate notification and ignoring");
+ // FIXME: is there a way to detect transition CALL_OUTGOING -> CALL_START for more complete VoIP call state tracking?
+ return;
+ }
+ }
+ /*try {
+ LOG.info("Executing first action");
+ noti.actions[0].actionIntent.send();
+ } catch (PendingIntent.CanceledException e) {
+ e.printStackTrace();
+ }*/
+ }
+
+ // figure out sender
+ String number;
+ String appName = NotificationUtils.getApplicationLabel(this, app);
+ if (noti.extras.containsKey(Notification.EXTRA_PEOPLE)) {
+ number = noti.extras.getString(Notification.EXTRA_PEOPLE);
+ } else if (noti.extras.containsKey(Notification.EXTRA_TITLE)) {
+ number = noti.extras.getString(Notification.EXTRA_TITLE);
+ } else {
+ number = appName != null ? appName : app;
+ }
+ activeCallPostTime = sbn.getPostTime();
+ CallSpec callSpec = new CallSpec();
+ callSpec.number = number;
+ callSpec.sourceAppId = app;
+ if (appName != null) {
+ callSpec.sourceName = appName;
+ }
+ callSpec.command = callStarted ? CallSpec.CALL_START : CallSpec.CALL_INCOMING;
+ mLastCallCommand = callSpec.command;
+ GBApplication.deviceService().onSetCallState(callSpec);
+ }
+
+ boolean shouldContinueAfterFilter(String body, @NonNull List<String> wordsList, @NonNull NotificationFilter notificationFilter) {
+ LOG.debug("Mode: '{}' Submode: '{}' WordsList: '{}'", notificationFilter.getNotificationFilterMode(), notificationFilter.getNotificationFilterSubMode(), wordsList);
+
+ boolean allMode = notificationFilter.getNotificationFilterSubMode() == NOTIFICATION_FILTER_SUBMODE_ALL;
+
+ switch (notificationFilter.getNotificationFilterMode()) {
+ case NOTIFICATION_FILTER_MODE_BLACKLIST:
+ if (allMode) {
+ for (String word : wordsList) {
+ if (!body.contains(word)) {
+ LOG.info("Not every word was found, blacklist has no effect, processing continues.");
+ return true;
+ }
+ }
+ LOG.info("Every word was found, blacklist has effect, processing stops.");
+ return false;
+ } else {
+ boolean containsAny = StringUtils.containsAny(body, wordsList.toArray(new CharSequence[0]));
+ if (!containsAny) {
+ LOG.info("No matching word was found, blacklist has no effect, processing continues.");
+ } else {
+ LOG.info("At least one matching word was found, blacklist has effect, processing stops.");
+ }
+ return !containsAny;
+ }
+
+ case NOTIFICATION_FILTER_MODE_WHITELIST:
+ if (allMode) {
+ for (String word : wordsList) {
+ if (!body.contains(word)) {
+ LOG.info("Not every word was found, whitelist has no effect, processing stops.");
+ return false;
+ }
+ }
+ LOG.info("Every word was found, whitelist has effect, processing continues.");
+ return true;
+ } else {
+ boolean containsAny = StringUtils.containsAny(body, wordsList.toArray(new CharSequence[0]));
+ if (containsAny) {
+ LOG.info("At least one matching word was found, whitelist has effect, processing continues.");
+ } else {
+ LOG.info("No matching word was found, whitelist has no effect, processing stops.");
+ }
+ return containsAny;
+ }
+
+ default:
+ return true;
+ }
+ }
+
+ // Strip Unicode control sequences: some apps like Telegram add a lot of them for unknown reasons.
+ // Keep newline and whitespace characters
+ private String sanitizeUnicode(String orig) {
+ return orig.replaceAll("[\\p{C}&&\\S]", "");
+ }
+
+ private void dissectNotificationTo(Notification notification, NotificationSpec notificationSpec,
+ boolean preferBigText) {
+
+ Bundle extras = NotificationCompat.getExtras(notification);
+
+ //dumpExtras(extras);
+ if (extras == null) {
+ return;
+ }
+
+ CharSequence title = extras.getCharSequence(Notification.EXTRA_TITLE);
+ if (title != null) {
+ notificationSpec.title = sanitizeUnicode(title.toString());
+ }
+
+ CharSequence contentCS = null;
+ final CharSequence bigText = extras.getCharSequence(NotificationCompat.EXTRA_BIG_TEXT);
+ if (preferBigText && !StringUtils.isBlank(bigText)) {
+ contentCS = bigText;
+ } else if (extras.containsKey(Notification.EXTRA_TEXT)) {
+ contentCS = extras.getCharSequence(NotificationCompat.EXTRA_TEXT);
+ }
+ if (contentCS != null) {
+ notificationSpec.body = sanitizeUnicode(contentCS.toString());
+ }
+
+ NotificationCompat.MessagingStyle messagingStyle = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(notification);
+ if (messagingStyle != null) {
+ List<NotificationCompat.MessagingStyle.Message> messages = messagingStyle.getMessages();
+ if (!messages.isEmpty()) {
+ // Get the last message (assumed to be the most recent)
+ NotificationCompat.MessagingStyle.Message lastMessage = messages.get(messages.size() - 1);
+
+ if (supportedPictureMimeTypes.contains(lastMessage.getDataMimeType())) {
+ ContentResolver contentResolver = getContentResolver();
+ try (Cursor cursor = contentResolver.query(lastMessage.getDataUri(), null, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ int dataIndex = cursor.getColumnIndex(MediaStore.Images.Media.DATA);
+ notificationSpec.picturePath = cursor.getString(dataIndex);
+ }
+ } catch (Exception e) {
+ LOG.error(e.getMessage());
+ }
+ }
+ }
+ }
+
+ if (extras.containsKey(NotificationCompat.EXTRA_PICTURE)) {
+ final Bitmap bmp = (Bitmap) extras.get(NotificationCompat.EXTRA_PICTURE);
+ File pictureFile = new File(this.notificationPictureCacheDirectory, String.valueOf(notificationSpec.getId()));
+
+ try (FileOutputStream fos = new FileOutputStream(pictureFile)) {
+ bmp.compress(Bitmap.CompressFormat.PNG, 100, fos);
+ notificationSpec.picturePath = pictureFile.getAbsolutePath();
+ } catch (IOException e) {
+ LOG.error("Failed to save picture to notification cache: {}", e.getMessage());
+ } finally {
+ bmp.recycle();
+ }
+ }
+
+ if (notificationSpec.type == NotificationType.COL_REMINDER
+ && notificationSpec.body == null
+ && notificationSpec.title != null) {
+ notificationSpec.body = notificationSpec.title;
+ notificationSpec.title = null;
+ }
+ }
+
+ private boolean handleMediaSessionNotification(final StatusBarNotification sbn) {
+ final MediaSession.Token token = sbn.getNotification().extras.getParcelable(Notification.EXTRA_MEDIA_SESSION);
+ return token != null && handleMediaSessionNotification(token);
+ }
+
+ /**
+ * Try to handle media session notifications that tell info about the current play state.
+ *
+ * @param mediaSession The mediasession to handle.
+ * @return true if notification was handled, false otherwise
+ */
+ public boolean handleMediaSessionNotification(MediaSession.Token mediaSession) {
+ try {
+ final MediaController c = new MediaController(getApplicationContext(), mediaSession);
+ if (c.getMetadata() == null) {
+ return false;
+ }
+
+ final MusicStateSpec stateSpec = MediaManager.extractMusicStateSpec(c.getPlaybackState());
+ final MusicSpec musicSpec = MediaManager.extractMusicSpec(c.getMetadata());
+
+ // finally, tell the device about it
+ if (mSetMusicInfoRunnable != null) {
+ mHandler.removeCallbacks(mSetMusicInfoRunnable);
+ }
+ mSetMusicInfoRunnable = new Runnable() {
+ @Override
+ public void run() {
+ GBApplication.deviceService().onSetMusicInfo(musicSpec);
+ }
+ };
+ mHandler.postDelayed(mSetMusicInfoRunnable, 100);
+
+ if (stateSpec != null) {
+ if (mSetMusicStateRunnable != null) {
+ mHandler.removeCallbacks(mSetMusicStateRunnable);
+ }
+ mSetMusicStateRunnable = new Runnable() {
+ @Override
+ public void run() {
+ GBApplication.deviceService().onSetMusicState(stateSpec);
+ }
+ };
+ }
+ mHandler.postDelayed(mSetMusicStateRunnable, 100);
+
+ return true;
+ } catch (final NullPointerException | SecurityException e) {
+ LOG.error("Failed to handle media session notification", e);
+ return false;
+ }
+ }
+
+ @Override
+ public void onNotificationRemoved(StatusBarNotification sbn) {
+ logNotification(sbn, false);
+
+ notificationStack.remove(sbn.getPackageName());
+
+ if (isServiceNotRunningAndShouldIgnoreNotifications()) return;
+
+ final GBPrefs prefs = GBApplication.getPrefs();
+
+ if (isOutsideNotificationTimes(prefs)) {
+ return;
+ }
+
+ final boolean ignoreWorkProfile = prefs.getBoolean("notifications_ignore_work_profile", false);
+ if (ignoreWorkProfile && isWorkProfile(sbn)) {
+ LOG.debug("Ignoring notification removal from work profile");
+ return;
+ }
+
+ final boolean mediaIgnoresAppList = prefs.getBoolean("notification_media_ignores_application_list", false);
+
+ // If media notifications ignore app list, check them before
+ if (mediaIgnoresAppList && handleMediaSessionNotification(sbn)) return;
+
+ if (shouldIgnoreSource(sbn)) return;
+
+ googleMapsNotificationHandler.handleRemove(sbn);
+
+ // If media notifications do NOT ignore app list, check them after
+ if (!mediaIgnoresAppList && handleMediaSessionNotification(sbn)) return;
+
+ if (Notification.CATEGORY_CALL.equals(sbn.getNotification().category)
+ && activeCallPostTime == sbn.getPostTime()) {
+ activeCallPostTime = 0;
+ CallSpec callSpec = new CallSpec();
+ callSpec.command = CallSpec.CALL_END;
+ mLastCallCommand = callSpec.command;
+ GBApplication.deviceService().onSetCallState(callSpec);
+ }
+
+ if (shouldIgnoreNotification(sbn, true)) return;
+
+ // Build list of all currently active notifications
+ ArrayList<Integer> activeNotificationsIds = new ArrayList<>();
+ for (StatusBarNotification notification : getActiveNotifications()) {
+ Integer id = mNotificationHandleLookup.lookupByValue(notification.getPostTime());
+ if (id != null) {
+ activeNotificationsIds.add(id);
+ }
+ }
+
+ // Build list of notifications that aren't active anymore
+ ArrayList<Integer> notificationsToRemove = new ArrayList<>();
+ for (int notificationId : notificationsActive) {
+ if (!activeNotificationsIds.contains(notificationId)) {
+ notificationsToRemove.add(notificationId);
+ deleteNotificationPicture(notificationId);
+ }
+ }
+
+ // Clean up removed notifications from internal list
+ notificationsActive.removeAll(notificationsToRemove);
+
+ // Send notification remove request to device
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
+ for (GBDevice device : devices) {
+ if (!device.isInitialized()) {
+ continue;
+ }
+
+ Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
+ if (devicePrefs.getBoolean("autoremove_notifications", true)) {
+ for (int id : notificationsToRemove) {
+ LOG.info("Notification {} removed, deleting from {}", id, device.getAliasOrName());
+ GBApplication.deviceService(device).onDeleteNotification(id);
+ }
+ }
+ }
+ }
+
+ private void deleteNotificationPicture(int notificationId) {
+ File pictureFile = new File(this.notificationPictureCacheDirectory, String.valueOf(notificationId));
+ if (pictureFile.exists())
+ pictureFile.delete();
+ }
+
+ private void cleanUpNotificationPictureProvider() {
+ File[] pictureFiles = this.notificationPictureCacheDirectory.listFiles();
+ if (pictureFiles == null)
+ return;
+
+ for (File pictureFile : pictureFiles) {
+ pictureFile.delete();
+ }
+ }
+
+ private void createNotificationPictureCacheDirectory() {
+ final File cacheDir = getApplicationContext().getExternalCacheDir();
+ this.notificationPictureCacheDirectory = new File(cacheDir, "notification-pictures");
+ this.notificationPictureCacheDirectory.mkdir();
+ }
+ private void logNotification(StatusBarNotification sbn, boolean posted) {
+ LOG.debug(
+ "Notification {} {}: packageName={}, when={}, priority={}, category={}",
+ sbn.getId(),
+ posted ? "posted" : "removed",
+ sbn.getPackageName(),
+ sbn.getNotification().when,
+ sbn.getNotification().priority,
+ sbn.getNotification().category
+ );
+ }
+
+ private void dumpExtras(Bundle bundle) {
+ for (String key : bundle.keySet()) {
+ Object value = bundle.get(key);
+ if (value == null) {
+ continue;
+ }
+ LOG.debug(String.format("Notification extra: %s %s (%s)", key, value.toString(), value.getClass().getName()));
+ }
+ }
+
+ private boolean isServiceNotRunningAndShouldIgnoreNotifications() {
+ /*
+ * return early if DeviceCommunicationService is not running,
+ * else the service would get started every time we get a notification.
+ * unfortunately we cannot enable/disable NotificationListener at runtime like we do with
+ * broadcast receivers because it seems to invalidate the permissions that are
+ * necessary for NotificationListenerService
+ */
+ if (!DeviceCommunicationService.isRunning(this)) {
+ LOG.trace("Service is not running, ignoring notification");
+ return true;
+ }
+ return false;
+ }
+
+ private boolean shouldIgnoreSource(StatusBarNotification sbn) {
+ String source = sbn.getPackageName();
+
+ Prefs prefs = GBApplication.getPrefs();
+
+ /* do not display messages from "android"
+ * This includes keyboard selection message, usb connection messages, etc
+ * Hope it does not filter out too much, we will see...
+ */
+
+ if (source.equals("android") ||
+ source.equals("com.android.systemui") ||
+ source.equals("com.android.dialer") ||
+ source.equals("com.google.android.dialer") ||
+ source.equals("com.cyanogenmod.eleven")) {
+ LOG.info("Ignoring notification, is a system event");
+ return true;
+ }
+
+ if (source.equals("com.moez.QKSMS") ||
+ source.equals("com.android.mms") ||
+ source.equals("com.sonyericsson.conversations") ||
+ source.equals("com.android.messaging") ||
+ source.equals("org.smssecure.smssecure")) {
+ if (!"never".equals(prefs.getString("notification_mode_sms", "when_screen_off"))) {
+ LOG.info("Ignoring notification, it's an sms notification");
+ return true;
+ }
+ }
+
+ if (GBApplication.getPrefs().getString("notification_list_is_blacklist", "true").equals("true")) {
+ if (GBApplication.appIsNotifBlacklisted(source)) {
+ LOG.info("Ignoring notification, application is blacklisted");
+ return true;
+ }
+ } else {
+ if (GBApplication.appIsNotifBlacklisted(source)) {
+ LOG.info("Allowing notification, application is whitelisted");
+ return false;
+ } else {
+ LOG.info("Ignoring notification, application is not whitelisted");
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private boolean shouldIgnoreRepeatPrevention(StatusBarNotification sbn) {
+ if (isFitnessApp(sbn)) {
+ return true;
+ }
+ return false;
+ }
+
+ private boolean shouldIgnoreOngoing(StatusBarNotification sbn, NotificationType type) {
+ if (isFitnessApp(sbn)) {
+ return true;
+ }
+ if (type == NotificationType.COL_REMINDER) {
+ return true;
+ }
+ return false;
+ }
+
+ private boolean isFitnessApp(StatusBarNotification sbn) {
+ String source = sbn.getPackageName();
+ if (source.equals("de.dennisguse.opentracks")
+ || source.equals("de.dennisguse.opentracks.debug")
+ || source.equals("de.dennisguse.opentracks.nightly")
+ || source.equals("de.dennisguse.opentracks.playstore")
+ || source.equals("de.tadris.fitness")
+ || source.equals("de.tadris.fitness.debug")
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ private boolean isWorkProfile(StatusBarNotification sbn) {
+ final UserHandle currentUser = Process.myUserHandle();
+ return !sbn.getUser().equals(currentUser);
+ }
+
+ private boolean shouldDisplayNonOngoingCallNotification(StatusBarNotification sbn) {
+ String source = sbn.getPackageName();
+ NotificationType type = AppNotificationType.getInstance().get(source);
+
+ if (type == NotificationType.TELEGRAM) {
+ return true;
+ }
+
+ return false;
+ }
+
+ private boolean shouldIgnoreNotification(StatusBarNotification sbn, boolean remove) {
+ Notification notification = sbn.getNotification();
+ String source = sbn.getPackageName();
+
+ NotificationType type = AppNotificationType.getInstance().get(source);
+ //ignore notifications marked as LocalOnly https://developer.android.com/reference/android/app/Notification.html#FLAG_LOCAL_ONLY
+ //some Apps always mark their notifcations as read-only
+ if (NotificationCompat.getLocalOnly(notification) &&
+ type != NotificationType.WECHAT &&
+ type != NotificationType.TELEGRAM &&
+ type != NotificationType.OUTLOOK &&
+ type != NotificationType.COL_REMINDER &&
+ type != NotificationType.SKYPE) { //see https://github.com/Freeyourgadget/Gadgetbridge/issues/1109
+ LOG.info("Ignoring notification, local only");
+ return true;
+ }
+
+ Prefs prefs = GBApplication.getPrefs();
+
+ // Check for screen on when posting the notification; for removal, the screen
+ // has to be on (obviously)
+ if (!remove) {
+ if (!prefs.getBoolean("notifications_generic_whenscreenon", false)) {
+ PowerManager powermanager = (PowerManager) getSystemService(POWER_SERVICE);
+ if (powermanager != null && powermanager.isScreenOn()) {
+ LOG.info("Not forwarding notification, screen seems to be on and settings do not allow this");
+ return true;
+ }
+ }
+ }
+
+ if (sbn.getNotification().priority < Notification.PRIORITY_DEFAULT) {
+ if (prefs.getBoolean("notifications_ignore_low_priority", true)) {
+ LOG.info("Ignoring low priority notification");
+ return true;
+ }
+ }
+
+ if (shouldIgnoreOngoing(sbn, type)) {
+ return false;
+ }
+
+ return (notification.flags & Notification.FLAG_ONGOING_EVENT) == Notification.FLAG_ONGOING_EVENT;
+
+ }
+
+
+ /**
+ * Get the notification color that should be used for this Pebble notification.
+ *
+ * Note that this method will *not* edit the NotificationSpec passed in. It will only evaluate the PebbleColor.
+ *
+ * See Issue #815 on GitHub to see how notification colors are set.
+ *
+ * @param notificationSpec The NotificationSpec to read from.
+ * @return Returns a PebbleColor that best represents this notification.
+ */
+ private byte getPebbleColorForNotification(NotificationSpec notificationSpec) {
+ String appId = notificationSpec.sourceAppId;
+ NotificationType existingType = notificationSpec.type;
+
+ // If the notification type is known, return the associated color.
+ if (existingType != NotificationType.UNKNOWN) {
+ return existingType.color;
+ }
+
+ // Otherwise, we go and attempt to find the color from the app icon.
+ Drawable icon;
+ try {
+ icon = NotificationUtils.getAppIcon(getApplicationContext(), appId);
+ Objects.requireNonNull(icon);
+ } catch (Exception ex) {
+ // If we can't get the icon, we go with the default defined above.
+ LOG.warn("Could not get icon for AppID " + appId, ex);
+ return PebbleColor.IslamicGreen;
+ }
+
+ Bitmap bitmapIcon = BitmapUtil.convertDrawableToBitmap(icon);
+ int iconPrimaryColor = new Palette.Builder(bitmapIcon)
+ .generate()
+ .getVibrantColor(Color.parseColor("#aa0000"));
+
+ return PebbleUtils.getPebbleColor(iconPrimaryColor);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/OmniJawsObserver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/OmniJawsObserver.java
new file mode 100644
index 0000000..34ca688
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/OmniJawsObserver.java
@@ -0,0 +1,194 @@
+/* Copyright (C) 2017-2024 Carsten Pfeiffer, Daniele Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.externalevents;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Handler;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.Weather;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+
+
+public class OmniJawsObserver extends ContentObserver {
+
+ private static final Logger LOG = LoggerFactory.getLogger(OmniJawsObserver.class);
+
+ private static final String SERVICE_PACKAGE = "org.omnirom.omnijaws";
+ public static final Uri WEATHER_URI = Uri.parse("content://org.omnirom.omnijaws.provider/weather");
+ private static final Uri SETTINGS_URI = Uri.parse("content://org.omnirom.omnijaws.provider/settings");
+
+ private Context mContext;
+ private boolean mInstalled;
+ private boolean mEnabled = false;
+ private boolean mMetric = true;
+
+
+ private final String[] WEATHER_PROJECTION = new String[]{
+ "city",
+ "condition", //unused, see below
+ "condition_code",
+ "temperature",
+ "humidity",
+ "forecast_low",
+ "forecast_high",
+ "forecast_condition",
+ "forecast_condition_code",
+ "time_stamp",
+ "forecast_date",
+ "wind_speed",
+ "wind_direction"
+ };
+
+ private final String[] SETTINGS_PROJECTION = new String[]{
+ "enabled",
+ "units"
+ };
+
+ public OmniJawsObserver(Handler handler) throws NameNotFoundException {
+ super(handler);
+ mContext = GBApplication.getContext();
+ mInstalled = isOmniJawsServiceAvailable();
+ LOG.info("OmniJaws installation status: " + mInstalled);
+ checkSettings();
+ LOG.info("OmniJaws is enabled: " + mEnabled);
+ if (!mEnabled) {
+ throw new NameNotFoundException();
+ }
+
+ updateWeather();
+ }
+
+ @Override
+ public void onChange(boolean selfChange) {
+ super.onChange(selfChange);
+ LOG.info("Weather update received");
+ checkSettings();
+ if (!mEnabled) {
+ LOG.info("Provider was disabled, ignoring.");
+ return;
+ }
+ queryWeather();
+ }
+
+ private void queryWeather() {
+ Cursor c = mContext.getContentResolver().query(WEATHER_URI, WEATHER_PROJECTION, null, null, null);
+ if (c != null) {
+ try {
+
+ WeatherSpec weatherSpec = new WeatherSpec();
+ weatherSpec.forecasts = new ArrayList<>();
+
+ int count = c.getCount();
+ if (count > 0) {
+ for (int i = 0; i < count; i++) {
+ c.moveToPosition(i);
+ if (i == 0) {
+
+ weatherSpec.location = c.getString(0);
+ weatherSpec.currentConditionCode = Weather.mapToOpenWeatherMapCondition(c.getInt(2));
+ weatherSpec.currentCondition = Weather.getConditionString(weatherSpec.currentConditionCode);
+ //alternatively the following would also be possible
+ //weatherSpec.currentCondition = c.getString(1);
+
+ weatherSpec.currentTemp = toKelvin(c.getFloat(3));
+ weatherSpec.currentHumidity = (int) c.getFloat(4);
+
+ weatherSpec.windSpeed = toKmh(c.getFloat(11));
+ weatherSpec.windDirection = c.getInt(12);
+ weatherSpec.timestamp = (int) (Long.parseLong(c.getString(9)) / 1000);
+ } else if (i == 1) {
+ weatherSpec.todayMinTemp = toKelvin(c.getFloat(5));
+ weatherSpec.todayMaxTemp = toKelvin(c.getFloat(6));
+ } else {
+
+ WeatherSpec.Daily gbForecast = new WeatherSpec.Daily();
+ gbForecast.minTemp = toKelvin(c.getFloat(5));
+ gbForecast.maxTemp = toKelvin(c.getFloat(6));
+ gbForecast.conditionCode = Weather.mapToOpenWeatherMapCondition(c.getInt(8));
+ weatherSpec.forecasts.add(gbForecast);
+ }
+ }
+ }
+
+ ArrayList<WeatherSpec> weatherSpecs = new ArrayList<>(Collections.singletonList(weatherSpec));
+ Weather.getInstance().setWeatherSpec(weatherSpecs);
+ GBApplication.deviceService().onSendWeather(weatherSpecs);
+
+ } finally {
+ c.close();
+ }
+ }
+
+ }
+
+ private void updateWeather() {
+ Intent updateIntent = new Intent(Intent.ACTION_MAIN)
+ .setClassName(SERVICE_PACKAGE, SERVICE_PACKAGE + ".WeatherService");
+ updateIntent.setAction(SERVICE_PACKAGE + ".ACTION_UPDATE");
+ mContext.startService(updateIntent);
+ }
+
+ private boolean isOmniJawsServiceAvailable() throws NameNotFoundException {
+ final PackageManager pm = mContext.getPackageManager();
+ pm.getPackageInfo("org.omnirom.omnijaws", 0);
+ return true;
+ }
+
+ private void checkSettings() {
+ if (!mInstalled) {
+ return;
+ }
+ final Cursor c = mContext.getContentResolver().query(SETTINGS_URI, SETTINGS_PROJECTION,
+ null, null, null);
+ if (c != null) {
+ int count = c.getCount();
+ if (count == 1) {
+ c.moveToPosition(0);
+ mEnabled = c.getInt(0) == 1 ? true : false;
+ mMetric = c.getInt(1) == 0 ? true : false;
+ }
+ }
+ }
+
+ private int toKelvin(double temperature) {
+ if (mMetric) {
+ return (int) (temperature + 273.15);
+ }
+ return (int) ((temperature - 32) * 0.5555555555555556D + 273.15);
+ }
+
+ private float toKmh(float speed) {
+ if (mMetric) {
+ return speed;
+ }
+ return (speed * 1.61f);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/OsmandEventReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/OsmandEventReceiver.java
new file mode 100644
index 0000000..c33a508
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/OsmandEventReceiver.java
@@ -0,0 +1,237 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, Arjan Schrijver, Gordon
+ Williams, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.externalevents;
+
+import android.app.Application;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.pm.PackageManager;
+import android.os.IBinder;
+import android.os.PowerManager;
+import android.os.RemoteException;
+import android.view.KeyEvent;
+
+import net.osmand.aidlapi.IOsmAndAidlCallback;
+import net.osmand.aidlapi.IOsmAndAidlInterface;
+import net.osmand.aidlapi.gpx.AGpxBitmap;
+import net.osmand.aidlapi.navigation.ADirectionInfo;
+import net.osmand.aidlapi.navigation.ANavigationUpdateParams;
+import net.osmand.aidlapi.navigation.ANavigationVoiceRouterMessageParams;
+import net.osmand.aidlapi.navigation.OnVoiceNavigationParams;
+import net.osmand.aidlapi.search.SearchResult;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.model.NavigationInfoSpec;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class OsmandEventReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(OsmandEventReceiver.class);
+
+ private final Application app;
+ private IOsmAndAidlInterface mIOsmAndAidlInterface;
+
+ private NavigationInfoSpec navigationInfoSpec = new NavigationInfoSpec();
+
+ private final IOsmAndAidlCallback.Stub mIOsmAndAidlCallback = new IOsmAndAidlCallback.Stub() {
+ @Override
+ public void onSearchComplete(List<SearchResult> resultSet) {
+ }
+
+ @Override
+ public void onUpdate() {
+ }
+
+ @Override
+ public void onAppInitialized() {
+ }
+
+ @Override
+ public void onGpxBitmapCreated(AGpxBitmap bitmap) {
+ }
+
+ @Override
+ public void updateNavigationInfo(ADirectionInfo directionInfo) {
+ navigationInfoSpec.nextAction = directionInfo.getTurnType();
+ navigationInfoSpec.distanceToTurn = directionInfo.getDistanceTo()+"m";
+
+ if (shouldSendNavigation()) {
+ GBApplication.deviceService().onSetNavigationInfo(navigationInfoSpec);
+ }
+
+ LOG.debug("Distance: {}, turnType: {}", directionInfo.getDistanceTo(), directionInfo.getTurnType());
+ }
+
+ @Override
+ public void onContextMenuButtonClicked(int buttonId, String pointId, String layerId) {
+ }
+
+ @Override
+ public void onVoiceRouterNotify(OnVoiceNavigationParams params) {
+ List<String> played = params.getPlayed();
+ for (String instuction : played) {
+ navigationInfoSpec.instruction = instuction;
+ LOG.debug("instruction: {}", instuction);
+ // only first one for now
+ break;
+ }
+ }
+
+ @Override
+ public void onKeyEvent(KeyEvent keyEvent) {
+
+ }
+ };
+
+ private final ServiceConnection mConnection = new ServiceConnection() {
+ public void onServiceConnected(ComponentName className,
+ IBinder service) {
+ mIOsmAndAidlInterface = IOsmAndAidlInterface.Stub.asInterface(service);
+ LOG.info("OsmAnd service connected");
+ registerForNavigationUpdates(true, 6666); // what is this id for?
+ registerForVoiceRouterMessages(true, 6667);
+// Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("osmand.api://GET_INFO"));
+// OsmandEventReceiver.this.startActivityForResult(intent,666);
+ }
+
+ public void onServiceDisconnected(ComponentName className) {
+ mIOsmAndAidlInterface = null;
+ LOG.info("OsmAnd service disconnected");
+ }
+ };
+
+ public OsmandEventReceiver(Application application) {
+ this.app = application;
+ if (!bindService()) {
+ LOG.warn("Could not bind to OsmAnd");
+ }
+ }
+
+ private boolean bindService() {
+ if (mIOsmAndAidlInterface == null) {
+ List<CharSequence> installedOsmandPackages = findInstalledOsmandPackages();
+ if (installedOsmandPackages.isEmpty()) {
+ LOG.warn("OsmAnd is not installed");
+ return false;
+ }
+ Prefs prefs = GBApplication.getPrefs();
+ String packageName = prefs.getString("pref_key_osmand_packagename", "autodetect");
+ if (packageName.equals("autodetect")) packageName = installedOsmandPackages.get(0).toString();
+ Intent intent = new Intent("net.osmand.aidl.OsmandAidlServiceV2");
+ intent.setPackage(packageName);
+ boolean res = app.bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
+ if (res) {
+ LOG.info("Bound to OsmAnd service (package "+packageName+")");
+ return true;
+ } else {
+ LOG.warn("Could not bind to OsmAnd service (package "+packageName+")");
+ return false;
+ }
+ } else {
+ return true;
+ }
+ }
+
+ public void cleanupResources() {
+ if (mIOsmAndAidlInterface != null) {
+ app.unbindService(mConnection);
+ }
+ }
+
+ public long registerForNavigationUpdates(boolean subscribeToUpdates, long callbackId) {
+ if (mIOsmAndAidlInterface != null) {
+ try {
+ ANavigationUpdateParams params = new ANavigationUpdateParams();
+ params.setCallbackId(callbackId);
+ params.setSubscribeToUpdates(subscribeToUpdates);
+
+ return mIOsmAndAidlInterface.registerForNavigationUpdates(params, mIOsmAndAidlCallback);
+ } catch (RemoteException e) {
+ LOG.error("could not subscribe to navigation updates", e);
+ }
+ }
+ return -1L;
+ }
+
+ private boolean shouldSendNavigation() {
+ Prefs prefs = GBApplication.getPrefs();
+
+ boolean navigationForward = prefs.getBoolean("navigation_forward", true);
+ boolean navigationOsmAnd = prefs.getBoolean("navigation_app_osmand", true);
+ if (!navigationForward || !navigationOsmAnd) {
+ return false;
+ }
+
+ boolean navigationScreenOn = prefs.getBoolean("nagivation_screen_on", true);
+ if (!navigationScreenOn) {
+ PowerManager powermanager = (PowerManager) app.getSystemService(Context.POWER_SERVICE);
+ if (powermanager != null && powermanager.isScreenOn()) {
+ LOG.info("Not forwarding navigation instructions, screen seems to be on and settings do not allow this");
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Method to register for Voice Router voice messages during navigation. Notifies user about voice messages.
+ *
+ * @param subscribeToUpdates (boolean) - boolean flag to subscribe or unsubscribe from messages
+ * @param callbackId (long) - id of callback, needed to unsubscribe from messages
+ */
+ public long registerForVoiceRouterMessages(boolean subscribeToUpdates, long callbackId) {
+ ANavigationVoiceRouterMessageParams params = new ANavigationVoiceRouterMessageParams();
+ params.setCallbackId(callbackId);
+ params.setSubscribeToUpdates(subscribeToUpdates);
+ if (mIOsmAndAidlInterface != null) {
+ try {
+ return mIOsmAndAidlInterface.registerForVoiceRouterMessages(params, mIOsmAndAidlCallback);
+ } catch (RemoteException e) {
+ LOG.error("could not register for voice router messages", e);
+ }
+ }
+ return -1L;
+ }
+
+ public List<CharSequence> findInstalledOsmandPackages() {
+ List<CharSequence> installedPackages = new ArrayList<>();
+ for (String knownPackage : app.getBaseContext().getResources().getStringArray(R.array.osmand_package_names)) {
+ if (isPackageInstalled(knownPackage)) {
+ installedPackages.add(knownPackage);
+ }
+ }
+ return installedPackages;
+ }
+
+ private boolean isPackageInstalled(final String packageName) {
+ try {
+ return app.getBaseContext().getPackageManager().getApplicationInfo(packageName, 0).enabled;
+ } catch (PackageManager.NameNotFoundException e) {
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/PebbleReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/PebbleReceiver.java
new file mode 100644
index 0000000..8e4ca40
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/PebbleReceiver.java
@@ -0,0 +1,100 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Petr Van臎k
+
+ 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;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.PowerManager;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class PebbleReceiver extends BroadcastReceiver {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PebbleReceiver.class);
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+
+ Prefs prefs = GBApplication.getPrefs();
+ if ("never".equals(prefs.getString("notification_mode_pebblemsg", "when_screen_off"))) {
+ return;
+ }
+ if ("when_screen_off".equals(prefs.getString("notification_mode_pebblemsg", "when_screen_off"))) {
+ PowerManager powermanager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
+ if (powermanager.isScreenOn()) {
+ return;
+ }
+ }
+
+ String messageType = intent.getStringExtra("messageType");
+ if (!messageType.equals("PEBBLE_ALERT")) {
+ LOG.info("non PEBBLE_ALERT message type not supported");
+ return;
+ }
+
+ if (!intent.hasExtra("notificationData")) {
+ LOG.info("missing notificationData extra");
+ return;
+ }
+
+ NotificationSpec notificationSpec = new NotificationSpec();
+
+ String notificationData = intent.getStringExtra("notificationData");
+ try {
+ JSONArray notificationJSON = new JSONArray(notificationData);
+ notificationSpec.title = notificationJSON.getJSONObject(0).getString("title");
+ notificationSpec.body = notificationJSON.getJSONObject(0).getString("body");
+ } catch (JSONException e) {
+ e.printStackTrace();
+ return;
+ }
+
+ if (notificationSpec.title != null) {
+ notificationSpec.type = NotificationType.UNKNOWN;
+ String sender = intent.getStringExtra("sender");
+ if (GBApplication.getPrefs().getString("notification_list_is_blacklist", "true").equals("true")) {
+ if (GBApplication.appIsPebbleBlacklisted(sender)) {
+ LOG.info("Ignoring Pebble message, application " + sender + " is blacklisted");
+ return;
+ }
+ } else {
+ if (!GBApplication.appIsPebbleBlacklisted(sender)) {
+ LOG.info("Ignoring Pebble message, application " + sender + " is not whitelisted");
+ return;
+ }
+ }
+
+ if ("Conversations".equals(sender)) {
+ notificationSpec.type = NotificationType.CONVERSATIONS;
+ }
+ else if ("OsmAnd".equals(sender)) {
+ notificationSpec.type = NotificationType.GENERIC_NAVIGATION;
+ }
+ GBApplication.deviceService().onNotification(notificationSpec);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/PhoneCallReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/PhoneCallReceiver.java
new file mode 100644
index 0000000..7b700a5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/PhoneCallReceiver.java
@@ -0,0 +1,172 @@
+/* Copyright (C) 2015-2024 Andreas B枚hler, Andreas Shimokawa, Carsten
+ Pfeiffer, Daniele Gobbetti, Johannes Tysiak, Jos茅 Rebelo, mvn23, Normano64,
+ Taavi Eom盲e
+
+ 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;
+
+import android.app.NotificationManager;
+import android.app.NotificationManager.Policy;
+import android.app.Service;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.media.AudioManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.telephony.TelephonyManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+
+public class PhoneCallReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(PhoneCallReceiver.class);
+
+ private static int mLastState = TelephonyManager.CALL_STATE_IDLE;
+ private static String mSavedNumber;
+ private boolean mRestoreMutedCall = false;
+ private int mLastRingerMode;
+
+ private final Handler delayHandler = new Handler(Looper.getMainLooper());
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ TelephonyManager tm = (TelephonyManager) context.getSystemService(Service.TELEPHONY_SERVICE);
+ if (intent.getAction().equals("android.intent.action.NEW_OUTGOING_CALL")) {
+ mSavedNumber = intent.getExtras().getString("android.intent.extra.PHONE_NUMBER");
+ } else if (intent.getAction().equals("nodomain.freeyourgadget.gadgetbridge.MUTE_CALL")) {
+ // Handle the mute request only if the phone is currently ringing
+ if (mLastState != TelephonyManager.CALL_STATE_RINGING)
+ return;
+
+ AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ mLastRingerMode = audioManager.getRingerMode();
+ try {
+ audioManager.setRingerMode(AudioManager.RINGER_MODE_SILENT);
+ } catch (SecurityException e) {
+ LOG.error("SecurityException when trying to set ringer (no permission granted :/ ?), not setting it then.");
+ }
+ mRestoreMutedCall = true;
+ } else {
+ if (intent.hasExtra(TelephonyManager.EXTRA_INCOMING_NUMBER)) {
+ String number = intent.getExtras().getString(TelephonyManager.EXTRA_INCOMING_NUMBER);
+ int state = tm.getCallState();
+ onCallStateChanged(context, state, number);
+ }
+ }
+ }
+
+ public void onCallStateChanged(Context context, int state, String number) {
+ if (mLastState == state) {
+ return;
+ }
+
+ int callCommand = CallSpec.CALL_UNDEFINED;
+ switch (state) {
+ case TelephonyManager.CALL_STATE_RINGING:
+ mSavedNumber = number;
+ callCommand = CallSpec.CALL_INCOMING;
+ break;
+ case TelephonyManager.CALL_STATE_OFFHOOK:
+ if (mLastState == TelephonyManager.CALL_STATE_RINGING) {
+ callCommand = CallSpec.CALL_START;
+ } else {
+ callCommand = CallSpec.CALL_OUTGOING;
+ mSavedNumber = number;
+ }
+ break;
+ case TelephonyManager.CALL_STATE_IDLE:
+ if (mLastState == TelephonyManager.CALL_STATE_RINGING) {
+ //missed call would be correct here
+ callCommand = CallSpec.CALL_END;
+ } else {
+ callCommand = CallSpec.CALL_END;
+ }
+ if (mRestoreMutedCall) {
+ mRestoreMutedCall = false;
+ AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ audioManager.setRingerMode(mLastRingerMode);
+ }
+ break;
+ }
+ if (callCommand != CallSpec.CALL_UNDEFINED) {
+ Prefs prefs = GBApplication.getPrefs();
+ if ("never".equals(prefs.getString("notification_mode_calls", "always"))) {
+ return;
+ }
+ int dndSuppressed = 0;
+ switch (GBApplication.getGrantedInterruptionFilter()) {
+ case NotificationManager.INTERRUPTION_FILTER_ALL:
+ break;
+ case NotificationManager.INTERRUPTION_FILTER_ALARMS:
+ case NotificationManager.INTERRUPTION_FILTER_NONE:
+ dndSuppressed = 1;
+ break;
+ case NotificationManager.INTERRUPTION_FILTER_PRIORITY:
+ if (GBApplication.isPriorityNumber(Policy.PRIORITY_CATEGORY_CALLS, mSavedNumber)) {
+ break;
+ }
+ // FIXME: Handle Repeat callers if it is enabled in Do Not Disturb
+ dndSuppressed = 1;
+ }
+ if (prefs.getBoolean("notification_filter", false) && dndSuppressed == 1) {
+ return;
+ }
+ CallSpec callSpec = new CallSpec();
+ callSpec.number = mSavedNumber;
+ callSpec.command = callCommand;
+ callSpec.dndSuppressed = dndSuppressed;
+
+ int callDelay = prefs.getInt("notification_delay_calls", 0);
+ if (callCommand == CallSpec.CALL_INCOMING) {
+ // Delay incoming call notifications by a configurable number of seconds
+ if (callDelay <= 0) {
+ GBApplication.deviceService().onSetCallState(callSpec);
+ } else {
+ scheduleOnSetCallState(callSpec, callDelay);
+ }
+ } else {
+ if (callCommand == CallSpec.CALL_START || callCommand == CallSpec.CALL_END) {
+ // Call started or ended, unschedule any outstanding notifications
+ unscheduleAllOnSetCallState();
+ }
+
+ // propagate the event to the device
+ GBApplication.deviceService().onSetCallState(callSpec);
+ }
+ }
+ mLastState = state;
+ }
+
+ private void scheduleOnSetCallState(final CallSpec callSpec, final int delaySeconds) {
+ final Runnable runnable = new Runnable() {
+ @Override public void run() {
+ GBApplication.deviceService().onSetCallState(callSpec);
+ }
+ };
+
+ delayHandler.postDelayed(runnable, delaySeconds * 1000);
+ }
+
+ private void unscheduleAllOnSetCallState() {
+ delayHandler.removeCallbacksAndMessages(null);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/SMSReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/SMSReceiver.java
new file mode 100644
index 0000000..d05ba28
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/SMSReceiver.java
@@ -0,0 +1,114 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, mvn23, Normano64, Zhong Jianxin
+
+ 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;
+
+import android.app.NotificationManager;
+import android.app.NotificationManager.Policy;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.PowerManager;
+import android.telephony.SmsMessage;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class SMSReceiver extends BroadcastReceiver {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Prefs prefs = GBApplication.getPrefs();
+ if ("never".equals(prefs.getString("notification_mode_sms", "when_screen_off"))) {
+ return;
+ }
+ if ("when_screen_off".equals(prefs.getString("notification_mode_sms", "when_screen_off"))) {
+ PowerManager powermanager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
+ if (powermanager != null && powermanager.isScreenOn()) {
+ return;
+ }
+ }
+
+ NotificationSpec notificationSpec = new NotificationSpec();
+ notificationSpec.type = NotificationType.GENERIC_SMS;
+
+ Bundle bundle = intent.getExtras();
+ if (bundle != null) {
+ Object[] pdus = (Object[]) bundle.get("pdus");
+ if (pdus != null) {
+ int pduSize = pdus.length;
+ Map<String, StringBuilder> messageMap = new LinkedHashMap<>();
+ SmsMessage[] messages = new SmsMessage[pduSize];
+ for (int i = 0; i < pduSize; i++) {
+ messages[i] = SmsMessage.createFromPdu((byte[]) pdus[i]);
+ String originatingAddress = messages[i].getOriginatingAddress();
+ if (!messageMap.containsKey(originatingAddress)) {
+ messageMap.put(originatingAddress, new StringBuilder());
+ }
+ messageMap.get(originatingAddress).append(messages[i].getMessageBody());
+ }
+ for (Map.Entry<String, StringBuilder> entry : messageMap.entrySet()) {
+ String originatingAddress = entry.getKey();
+ if (originatingAddress != null) {
+ notificationSpec.body = entry.getValue().toString();
+ notificationSpec.phoneNumber = originatingAddress;
+ notificationSpec.attachedActions = new ArrayList<>();
+
+ // REPLY action
+ NotificationSpec.Action replyAction = new NotificationSpec.Action();
+ replyAction.title = "Reply";
+ replyAction.type = NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR;
+ notificationSpec.attachedActions.add(replyAction);
+
+ // DISMISS ALL action
+ NotificationSpec.Action dismissAllAction = new NotificationSpec.Action();
+ dismissAllAction.title = "Dismiss All";
+ dismissAllAction.type = NotificationSpec.Action.TYPE_SYNTECTIC_DISMISS_ALL;
+ notificationSpec.attachedActions.add(dismissAllAction);
+
+ int dndSuppressed = 0;
+ switch (GBApplication.getGrantedInterruptionFilter()) {
+ case NotificationManager.INTERRUPTION_FILTER_ALL:
+ break;
+ case NotificationManager.INTERRUPTION_FILTER_ALARMS:
+ case NotificationManager.INTERRUPTION_FILTER_NONE:
+ dndSuppressed = 1;
+ break;
+ case NotificationManager.INTERRUPTION_FILTER_PRIORITY:
+ if (GBApplication.isPriorityNumber(Policy.PRIORITY_CATEGORY_MESSAGES, notificationSpec.phoneNumber)) {
+ break;
+ }
+ dndSuppressed = 1;
+ }
+ if (prefs.getBoolean("notification_filter", false) && dndSuppressed == 1) {
+ return;
+ }
+ notificationSpec.dndSuppressed = dndSuppressed;
+ GBApplication.deviceService().onNotification(notificationSpec);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/SilentModeReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/SilentModeReceiver.java
new file mode 100644
index 0000000..6b8032d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/SilentModeReceiver.java
@@ -0,0 +1,48 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.externalevents;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.media.AudioManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+
+public class SilentModeReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(SilentModeReceiver.class);
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (intent.getAction() == null) {
+ return;
+ }
+
+ if (!AudioManager.RINGER_MODE_CHANGED_ACTION.equals(intent.getAction())) {
+ LOG.warn("Unexpected action {}", intent.getAction());
+ return;
+ }
+
+ final AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ final int ringerMode = audioManager.getRingerMode();
+
+ GBApplication.deviceService().onChangePhoneSilentMode(ringerMode);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/TimeChangeReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/TimeChangeReceiver.java
new file mode 100644
index 0000000..12f62e4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/TimeChangeReceiver.java
@@ -0,0 +1,165 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.externalevents;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.SystemClock;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.zone.ZoneOffsetTransition;
+import java.time.zone.ZoneRules;
+import java.util.Date;
+import java.util.GregorianCalendar;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+
+public class TimeChangeReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(TimeChangeReceiver.class);
+
+ public static final String ACTION_DST_CHANGED_OR_PERIODIC_SYNC = "nodomain.freeyourgadget.gadgetbridge.DST_CHANGED_OR_PERIODIC_SYNC";
+ public static final long PERIODIC_SYNC_INTERVAL_MS = 158003000; // 43:53:23.000
+ public static final long PERIODIC_SYNC_INTERVAL_MAX_MS = 172800000; // 48 hours
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final Prefs prefs = GBApplication.getPrefs();
+ final String action = intent.getAction();
+ if (action == null) {
+ LOG.warn("Null action");
+ return;
+ }
+
+ if (!prefs.getBoolean("datetime_synconconnect", true)) {
+ LOG.warn("Ignoring time change for {}, time sync is disabled", action);
+ return;
+ }
+
+ switch (action) {
+ case Intent.ACTION_TIME_CHANGED:
+ case Intent.ACTION_TIMEZONE_CHANGED:
+ case ACTION_DST_CHANGED_OR_PERIODIC_SYNC:
+ // Continue after the switch
+ break;
+ default:
+ LOG.warn("Unknown action {}", action);
+ return;
+ }
+
+ // acquire wake lock, otherwise device might enter deep sleep immediately after returning from onReceive()
+ AndroidUtils.acquirePartialWakeLock(context, "TimeSyncWakeLock", 10100);
+
+ final Date newTime = GregorianCalendar.getInstance().getTime();
+ LOG.info("Time/Timezone changed or periodic sync, syncing with device: {} ({}), {}", DateTimeUtils.formatDate(newTime), newTime.toGMTString(), intent.getAction());
+ GBApplication.deviceService().onSetTime();
+
+ // Reschedule the next DST change (since the timezone may have changed) or periodic sync
+ scheduleNextDstChangeOrPeriodicSync(context);
+ }
+
+ /**
+ * Schedule an alarm to trigger on the next DST change, since ACTION_TIMEZONE_CHANGED is not broadcast otherwise
+ * or schedule an alarm to trigger after PERIODIC_SYNC_INTERVAL_MS (whichever is earlier).
+ *
+ * @param context the context
+ */
+ public static void scheduleNextDstChangeOrPeriodicSync(final Context context) {
+ final ZoneId zoneId = ZoneId.systemDefault();
+ final ZoneRules zoneRules = zoneId.getRules();
+ final Instant now = Instant.now();
+ final ZoneOffsetTransition transition = zoneRules.nextTransition(now);
+
+ final Intent i = new Intent(ACTION_DST_CHANGED_OR_PERIODIC_SYNC);
+ i.setPackage(BuildConfig.APPLICATION_ID);
+ final PendingIntent pi = PendingIntentUtils.getBroadcast(context, 0, i, 0, false);
+
+ final AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+
+ boolean exactAlarm = false;
+ long delayMillis = PERIODIC_SYNC_INTERVAL_MS;
+
+ if (transition != null) {
+ final long nextDstMillis = transition.getInstant().toEpochMilli();
+ final long dstDelayMillis = nextDstMillis - now.toEpochMilli() + 5000L;
+ if (dstDelayMillis < PERIODIC_SYNC_INTERVAL_MAX_MS) {
+ exactAlarm = canScheduleExactAlarms(context, am);
+ delayMillis = dstDelayMillis;
+ LOG.info("Scheduling next DST change: {} (in {} millis) (exact = {})", nextDstMillis, delayMillis, exactAlarm);
+ }
+ } else {
+ LOG.warn("No DST transition found for {}", zoneId);
+ }
+
+ if (delayMillis == PERIODIC_SYNC_INTERVAL_MS) {
+ LOG.info("Scheduling next periodic time sync in {} millis (exact = {})", delayMillis, exactAlarm);
+ }
+
+ am.cancel(pi);
+
+ boolean scheduledExact = false;
+ if (exactAlarm) {
+ try {
+ am.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + delayMillis, pi);
+ scheduledExact = true;
+ } catch (final Exception e) {
+ LOG.error("Failed to schedule exact alarm for next DST change or periodic time sync", e);
+ }
+ }
+
+ // Fallback to inexact alarm if the exact one failed
+ if (!scheduledExact) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ am.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + delayMillis, pi);
+ } else {
+ am.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + delayMillis, pi);
+ }
+ } catch (final Exception e) {
+ LOG.error("Failed to schedule inexact alarm for next DST change or periodic time sync", e);
+ }
+ }
+ }
+
+ public static void ifEnabledScheduleNextDstChangeOrPeriodicSync(final Context context) {
+ if (GBApplication.getPrefs().getBoolean("datetime_synconconnect", true)) {
+ scheduleNextDstChangeOrPeriodicSync(context);
+ }
+ }
+
+ private static boolean canScheduleExactAlarms(final Context context, final AlarmManager am) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ return am.canScheduleExactAlarms();
+ } else {
+ return GB.checkPermission(context, "android.permission.SCHEDULE_EXACT_ALARM");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/TinyWeatherForecastGermanyReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/TinyWeatherForecastGermanyReceiver.java
new file mode 100644
index 0000000..f8f3ab4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/TinyWeatherForecastGermanyReceiver.java
@@ -0,0 +1,55 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+import java.util.Collections;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.Weather;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class TinyWeatherForecastGermanyReceiver extends BroadcastReceiver {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent != null) {
+ Bundle bundle = intent.getExtras();
+ if (bundle != null) {
+ try {
+ WeatherSpec weatherSpec = bundle.getParcelable("WeatherSpec");
+ if (weatherSpec != null) {
+ ArrayList<WeatherSpec> weatherSpecs = new ArrayList<>(Collections.singletonList(weatherSpec));
+ weatherSpec.timestamp = (int) (System.currentTimeMillis() / 1000);
+ Weather.getInstance().setWeatherSpec(weatherSpecs);
+ GBApplication.deviceService().onSendWeather(weatherSpecs);
+ }
+ } catch (Exception e) {
+ GB.toast("Gadgetbridge received broken or incompatible weather data", Toast.LENGTH_SHORT, GB.ERROR, e);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/WeatherNotificationConfig.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/WeatherNotificationConfig.java
new file mode 100644
index 0000000..e07254d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/WeatherNotificationConfig.java
@@ -0,0 +1,31 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti
+
+ 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;
+
+import android.os.Bundle;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
+
+public class WeatherNotificationConfig extends AbstractGBActivity {
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_weather_notification);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/WeatherNotificationReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/WeatherNotificationReceiver.java
new file mode 100644
index 0000000..fa49c9c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/WeatherNotificationReceiver.java
@@ -0,0 +1,64 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Daniele Gobbetti
+
+ 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;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.Weather;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import ru.gelin.android.weather.notification.ParcelableWeather2;
+
+
+public class WeatherNotificationReceiver extends BroadcastReceiver {
+ private static final Logger LOG = LoggerFactory.getLogger(WeatherNotificationReceiver.class);
+
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent.getAction() == null || !intent.getAction().contains("WEATHER_UPDATE_2")) {
+ LOG.info("Wrong action");
+ return;
+ }
+ ParcelableWeather2 parcelableWeather2 = null;
+ try {
+ parcelableWeather2 = intent.getParcelableExtra("ru.gelin.android.weather.notification.EXTRA_WEATHER");
+ } catch (RuntimeException e) {
+ LOG.error("cannot get ParcelableWeather2", e);
+ }
+
+ if (parcelableWeather2 != null) {
+ Weather weather = Weather.getInstance();
+ weather.setReconstructedOWMForecast(parcelableWeather2.reconstructedOWMForecast);
+
+ WeatherSpec weatherSpec = parcelableWeather2.weatherSpec;
+ LOG.info("weather in " + weatherSpec.location + " is " + weatherSpec.currentCondition + " (" + (weatherSpec.currentTemp - 273) + "掳C)");
+
+ ArrayList<WeatherSpec> weatherSpecs = new ArrayList<>(Collections.singletonList(weatherSpec));
+ Weather.getInstance().setWeatherSpec(weatherSpecs);
+ GBApplication.deviceService().onSendWeather(weatherSpecs);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationListener.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationListener.java
new file mode 100644
index 0000000..59c51b4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationListener.java
@@ -0,0 +1,90 @@
+/* Copyright (C) 2022-2024 illis, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.externalevents.gps;
+
+import android.location.Location;
+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
+ * provided {@link EventHandler}.
+ */
+public class GBLocationListener implements LocationListener {
+ private static final Logger LOG = LoggerFactory.getLogger(GBLocationListener.class);
+
+ 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 GBDevice device) {
+ this.device = device;
+ }
+
+ @Override
+ public void onLocationChanged(@NonNull final Location location) {
+ LOG.info("Location changed: {}", location);
+
+ // Correct the location time
+ location.setTime(getLocationTimestamp(location));
+
+ // The location usually doesn't contain speed, compute it from the previous location
+ // Some devices report hasSpeed() as true, and yet only return a 0 value, so we have to check against a speed threshold
+ boolean hasValidSpeed = location.hasSpeed() && (location.getSpeed() > SPEED_THRESHOLD);
+ if (previousLocation != null && !hasValidSpeed) {
+ long timeInterval = (location.getTime() - previousLocation.getTime());
+ float distanceInMeters = previousLocation.distanceTo(location);
+ location.setSpeed(distanceInMeters / timeInterval * 1000L);
+ }
+
+ previousLocation = location;
+
+ GBApplication.deviceService(device).onSetGpsLocation(location);
+ }
+
+ @Override
+ public void onProviderDisabled(@NonNull final String provider) {
+ LOG.info("onProviderDisabled: {}", provider);
+ }
+
+ @Override
+ public void onProviderEnabled(@NonNull final String provider) {
+ LOG.info("onProviderDisabled: {}", provider);
+ }
+
+ @Override
+ public void onStatusChanged(final String provider, final int status, final Bundle extras) {
+ LOG.info("onStatusChanged: {}, {}", provider, status);
+ }
+
+ private static long getLocationTimestamp(final Location location) {
+ long nanosSinceLocation = SystemClock.elapsedRealtimeNanos() - location.getElapsedRealtimeNanos();
+ return System.currentTimeMillis() - (nanosSinceLocation / 100_000L);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationProvider.java
new file mode 100644
index 0000000..efb8f77
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationProvider.java
@@ -0,0 +1,51 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo, 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.LocationListener;
+
+/**
+ * An abstract location provider, which periodically sends a location update to the provided {@link LocationListener}.
+ */
+public abstract class GBLocationProvider {
+ private final Context context;
+ private final LocationListener locationListener;
+
+ public GBLocationProvider(final Context context, final LocationListener locationListener) {
+ this.context = context;
+ this.locationListener = locationListener;
+ }
+
+ public final Context getContext() {
+ return this.context;
+ }
+
+ public final LocationListener getLocationListener() {
+ return this.locationListener;
+ }
+
+ /**
+ * Start sending periodic location updates.
+ */
+ public abstract void start(final int interval);
+
+ /**
+ * Stop sending periodic location updates.
+ */
+ public abstract void stop();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationProviderType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationProviderType.java
new file mode 100644
index 0000000..0834fb3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationProviderType.java
@@ -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);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationService.java
new file mode 100644
index 0000000..8dc948a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/GBLocationService.java
@@ -0,0 +1,186 @@
+/* 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.BuildConfig;
+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.setPackage(BuildConfig.APPLICATION_ID);
+ 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);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/providers/MockLocationProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/providers/MockLocationProvider.java
new file mode 100644
index 0000000..06cce41
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/providers/MockLocationProvider.java
@@ -0,0 +1,97 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo, 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.providers;
+
+import android.content.Context;
+import android.location.Location;
+import android.location.LocationListener;
+import android.os.Handler;
+import android.os.Looper;
+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 GBLocationProvider {
+ private static final Logger LOG = LoggerFactory.getLogger(MockLocationProvider.class);
+
+ private Location previousLocation = new CurrentPosition().getLastKnownLocation();
+
+ /**
+ * Interval between location updates, in milliseconds.
+ */
+ private static final int DEFAULT_INTERVAL = 1000;
+
+ /**
+ * Difference between location updates, in degrees.
+ */
+ private static final float COORD_DIFF = 0.0002f;
+
+ /**
+ * Whether the handler is running.
+ */
+ private boolean running = false;
+
+ private final Handler handler = new Handler(Looper.getMainLooper());
+
+ public MockLocationProvider(final Context context, final LocationListener locationListener) {
+ super(context, locationListener);
+ }
+
+ @Override
+ public void start(final int interval) {
+ LOG.info("Starting mock location provider");
+
+ running = true;
+ 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
+ public void stop() {
+ LOG.info("Stopping mock location provider");
+
+ running = false;
+ handler.removeCallbacksAndMessages(null);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/providers/PhoneLocationProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/providers/PhoneLocationProvider.java
new file mode 100644
index 0000000..7049b7c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/gps/providers/PhoneLocationProvider.java
@@ -0,0 +1,78 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo, 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.providers;
+
+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.externalevents.gps.GBLocationProvider;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+/**
+ * A location provider that uses the phone GPS, using {@link LocationManager}.
+ */
+public class PhoneLocationProvider extends GBLocationProvider {
+ private static final Logger LOG = LoggerFactory.getLogger(PhoneLocationProvider.class);
+
+ private final String provider;
+
+ private static final int INTERVAL_MIN_DISTANCE = 0;
+
+ public PhoneLocationProvider(final Context context, final LocationListener locationListener, final String provider) {
+ super(context, locationListener);
+ this.provider = provider;
+ }
+
+ @Override
+ public void start(final int interval) {
+ LOG.info("Starting phone gps location provider");
+
+ 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) getContext().getSystemService(Context.LOCATION_SERVICE);
+ locationManager.removeUpdates(getLocationListener());
+ locationManager.requestLocationUpdates(
+ provider,
+ interval > 0 ? interval : 1_000,
+ INTERVAL_MIN_DISTANCE,
+ getLocationListener(),
+ Looper.getMainLooper()
+ );
+
+ final Location lastKnownLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
+ LOG.debug("Last known gps location: {}", lastKnownLocation);
+ }
+
+ @Override
+ public void stop() {
+ LOG.info("Stopping phone gps location provider");
+
+ final LocationManager locationManager = (LocationManager) getContext().getSystemService(Context.LOCATION_SERVICE);
+ locationManager.removeUpdates(getLocationListener());
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/notifications/GoogleMapsNotificationHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/notifications/GoogleMapsNotificationHandler.java
new file mode 100644
index 0000000..9a50712
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/notifications/GoogleMapsNotificationHandler.java
@@ -0,0 +1,1301 @@
+/* Copyright (C) 2023-2024 Arjan Schrijver, Gordon Williams
+
+ 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.notifications;
+
+import android.app.Notification;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
+import android.os.PowerManager;
+import android.service.notification.StatusBarNotification;
+
+import androidx.core.app.NotificationCompat;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.NavigationInfoSpec;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class GoogleMapsNotificationHandler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(GoogleMapsNotificationHandler.class);
+
+ private boolean shouldSendNavigation = false;
+
+ static class IconType {
+ int[] icon;
+ int iconType;
+
+ public IconType(int iconType, int[] icon) {
+ this.icon = icon;
+ this.iconType = iconType;
+ }
+ }
+ List<IconType> knownImages;
+
+ public GoogleMapsNotificationHandler() {
+ knownImages = new ArrayList<>();
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_CONTINUE, new int[]{
+ 0b00000000000000000000000000000000, // Straight on with gaps
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000001000000000000000,
+ 0b00000000000000011100000000000000,
+ 0b00000000000000111110000000000000,
+ 0b00000000000011111111000000000000,
+ 0b00000000000111111111100000000000,
+ 0b00000000001111111111110000000000,
+ 0b00000000011111111111111000000000,
+ 0b00000000111110111101111100000000,
+ 0b00000000111100111100111100000000,
+ 0b00000000011000111100011100000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_CONTINUE, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000001000000000000000,
+ 0b00000000000000011100000000000000,
+ 0b00000000000000111110000000000000,
+ 0b00000000000011111111000000000000,
+ 0b00000000000111111111100000000000,
+ 0b00000000001111111111110000000000,
+ 0b00000000011111111111111000000000,
+ 0b00000000111110111101111100000000,
+ 0b00000000111100111100111100000000,
+ 0b00000000011000111100011100000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_TURN_LEFT, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000001100000000000000000000,
+ 0b00000000011110000000000000000000,
+ 0b00000000111110000000000000000000,
+ 0b00000001111100000000000000000000,
+ 0b00000011111000000000000000000000,
+ 0b00000111110000000000000000000000,
+ 0b00001111111111111111111110000000,
+ 0b00011111111111111111111111100000,
+ 0b00001111111111111111111111110000,
+ 0b00000111100000000000000111111000,
+ 0b00000011110000000000000001111000,
+ 0b00000011111000000000000000111000,
+ 0b00000001111100000000000000111000,
+ 0b00000000111110000000000000111000,
+ 0b00000000011110000000000000111000,
+ 0b00000000000100000000000000111000,
+ 0b00000000000000000000000000111000,
+ 0b00000000000000000000000000111000,
+ 0b00000000000000000000000000111000,
+ 0b00000000000000000000000000111000,
+ 0b00000000000000000000000000111000,
+ 0b00000000000000000000000000111000,
+ 0b00000000000000000000000000111000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_TURN_RIGHT, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000110000000000,
+ 0b00000000000000000001111000000000,
+ 0b00000000000000000001111100000000,
+ 0b00000000000000000000111110000000,
+ 0b00000000000000000000011111000000,
+ 0b00000000000000000000001111100000,
+ 0b00000001111111111111111111110000,
+ 0b00000111111111111111111111111000,
+ 0b00001111111111111111111111111000,
+ 0b00001111100000000000000111110000,
+ 0b00011110000000000000001111100000,
+ 0b00011100000000000000011111000000,
+ 0b00011100000000000000111110000000,
+ 0b00011100000000000001111100000000,
+ 0b00011100000000000001111000000000,
+ 0b00011100000000000000100000000000,
+ 0b00011100000000000000000000000000,
+ 0b00011100000000000000000000000000,
+ 0b00011100000000000000000000000000,
+ 0b00011100000000000000000000000000,
+ 0b00011100000000000000000000000000,
+ 0b00011100000000000000000000000000,
+ 0b00011100000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_TURN_LEFT_SLIGHTLY, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000111111111111000000000000,
+ 0b00000000111111111111000000000000,
+ 0b00000000111111111111000000000000,
+ 0b00000000111111000000000000000000,
+ 0b00000000111111100000000000000000,
+ 0b00000000111111110000000000000000,
+ 0b00000000111011111000000000000000,
+ 0b00000000111001111100000000000000,
+ 0b00000000111000111110000000000000,
+ 0b00000000111000011111000000000000,
+ 0b00000000111000001111100000000000,
+ 0b00000000111000000111110000000000,
+ 0b00000000000000000011111000000000,
+ 0b00000000000000000001111000000000,
+ 0b00000000000000000000111100000000,
+ 0b00000000000000000000011100000000,
+ 0b00000000000000000000011110000000,
+ 0b00000000000000000000011110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_TURN_LEFT_SLIGHTLY, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000011111111111000000000000000,
+ 0b00000111111111111000000000000000,
+ 0b00000111111111111000000000000000,
+ 0b00000111111100000000000000000000,
+ 0b00000111111100000000000000000000,
+ 0b00000111111110000000000000000000,
+ 0b00000111111111000000000000000000,
+ 0b00000111101111100000000000000000,
+ 0b00000111100011110000000000000000,
+ 0b00000111100011111000000000000000,
+ 0b00000111100001111100000000000000,
+ 0b00000011000000111110000000000000,
+ 0b00000000000000011110000000000000,
+ 0b00000000000000011111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_TURN_LEFT_SLIGHTLY, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000111111111111000000000000000,
+ 0b00000111111111111000000000000000,
+ 0b00000111111111111000000000000000,
+ 0b00000111111000000000000000000000,
+ 0b00000111111100000000000000000000,
+ 0b00000111111110000000000000000000,
+ 0b00000111111111000000000000000000,
+ 0b00000111101111100000000000000000,
+ 0b00000111100111110000000000000000,
+ 0b00000111100011111000000000000000,
+ 0b00000111100001111100000000000000,
+ 0b00000011000000111100000000000000,
+ 0b00000000000000011110000000000000,
+ 0b00000000000000011110000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_TURN_RIGHT_SLIGHTLY, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000011111111111100000000,
+ 0b00000000000011111111111100000000,
+ 0b00000000000011111111111100000000,
+ 0b00000000000000000011111100000000,
+ 0b00000000000000000111111100000000,
+ 0b00000000000000001111111100000000,
+ 0b00000000000000011111011100000000,
+ 0b00000000000000111110011100000000,
+ 0b00000000000001111100011100000000,
+ 0b00000000000011111000011100000000,
+ 0b00000000000111110000011100000000,
+ 0b00000000001111100000011100000000,
+ 0b00000000011111000000000000000000,
+ 0b00000000011110000000000000000000,
+ 0b00000000111100000000000000000000,
+ 0b00000000111000000000000000000000,
+ 0b00000001111000000000000000000000,
+ 0b00000001111000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_TURN_RIGHT_SLIGHTLY, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000111110000000000,
+ 0b00000000000000000111111000000000,
+ 0b00000000000000000011111110000000,
+ 0b00000000000000000000111111100000,
+ 0b00000000000000000000011111110000,
+ 0b00000000000000000001111111110000,
+ 0b00000000000000001111111111110000,
+ 0b00000000000000111111111111100000,
+ 0b00000000000011111111101111100000,
+ 0b00000000000111111100001111000000,
+ 0b00000000011111100000001110000000,
+ 0b00000011111110000000011110000000,
+ 0b00000011111100000000111100000000,
+ 0b00000011111000000000011100000000,
+ 0b00000011111000000000001000000000,
+ 0b00000011110000000000000000000000,
+ 0b00000011100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_TURN_RIGHT_SLIGHTLY, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000011111111111100000,
+ 0b00000000000000011111111111100000,
+ 0b00000000000000011111111111100000,
+ 0b00000000000000000000011111100000,
+ 0b00000000000000000000111111100000,
+ 0b00000000000000000001111111100000,
+ 0b00000000000000000011111111100000,
+ 0b00000000000000000111110111100000,
+ 0b00000000000000001111100111100000,
+ 0b00000000000000011111000111100000,
+ 0b00000000000000111110000111100000,
+ 0b00000000000000111100000011000000,
+ 0b00000000000001111000000000000000,
+ 0b00000000000001111000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_TURN_RIGHT_SLIGHTLY, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000011000000000000,
+ 0b00000000000000000011100000000000,
+ 0b00000000000000000111111000000000,
+ 0b00000000000000000011111110000000,
+ 0b00000000000000000000111111100000,
+ 0b00000000000000000000001111110000,
+ 0b00000000000000000000111111110000,
+ 0b00000000000000001111111111110000,
+ 0b00000000000000111111111111100000,
+ 0b00000000000011111111110111100000,
+ 0b00000000000111111100001111000000,
+ 0b00000000001111100000001111000000,
+ 0b00000000111110000000011110000000,
+ 0b00000001111100000000011100000000,
+ 0b00000001111000000000111100000000,
+ 0b00000011111000000000001000000000,
+ 0b00000011110000000000000000000000,
+ 0b00000011110000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_TURN_RIGHT_SLIGHTLY, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000011111111111100000,
+ 0b00000000000000011111111111100000,
+ 0b00000000000000011111111111100000,
+ 0b00000000000000000000011111100000,
+ 0b00000000000000000000111111100000,
+ 0b00000000000000000001111111100000,
+ 0b00000000000000000011111111100000,
+ 0b00000000000000000111110111100000,
+ 0b00000000000000001111100111100000,
+ 0b00000000000000011111000111100000,
+ 0b00000000000000111110000111100000,
+ 0b00000000000000111100000011000000,
+ 0b00000000000001111000000000000000,
+ 0b00000000000001111000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_UTURN_LEFT, new int[]{
+ 0b00000000000000000000000000000000, // LHD
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000001111110000000000,
+ 0b00000000000000111111111100000000,
+ 0b00000000000001111111111110000000,
+ 0b00000000000011111000111111000000,
+ 0b00000000000111100000001111100000,
+ 0b00000000001111000000000111100000,
+ 0b00000000001111000000000011110000,
+ 0b00000000001110000000000011110000,
+ 0b00000000001110000000000001110000,
+ 0b00000000001110000000000001110000,
+ 0b00000000001110000000000001110000,
+ 0b00001100001110001100000001110000,
+ 0b00011110001110011110000001110000,
+ 0b00001111001110111110000001110000,
+ 0b00001111101111111100000001110000,
+ 0b00000111111111111000000001110000,
+ 0b00000011111111110000000001110000,
+ 0b00000001111111100000000001110000,
+ 0b00000000111111000000000001110000,
+ 0b00000000001110000000000001110000,
+ 0b00000000001100000000000001110000,
+ 0b00000000000000000000000001110000,
+ 0b00000000000000000000000001110000,
+ 0b00000000000000000000000001110000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_UTURN_RIGHT, new int[]{
+ 0b00000000000000000000000000000000, // RHD
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000001111110000000000000000,
+ 0b00000000111111111100000000000000,
+ 0b00000001111111111110000000000000,
+ 0b00000011111100011111000000000000,
+ 0b00000111110000000111100000000000,
+ 0b00000111100000000011110000000000,
+ 0b00001111000000000011110000000000,
+ 0b00001111000000000001110000000000,
+ 0b00001110000000000001110000000000,
+ 0b00001110000000000001110000000000,
+ 0b00001110000000000001110000000000,
+ 0b00001110000000110001110000110000,
+ 0b00001110000001111001110001111000,
+ 0b00001110000001111101110011110000,
+ 0b00001110000000111111110111110000,
+ 0b00001110000000011111111111100000,
+ 0b00001110000000001111111111000000,
+ 0b00001110000000000111111110000000,
+ 0b00001110000000000011111100000000,
+ 0b00001110000000000001110000000000,
+ 0b00001110000000000000110000000000,
+ 0b00001110000000000000000000000000,
+ 0b00001110000000000000000000000000,
+ 0b00001110000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_KEEP_LEFT, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000001000000000000000,
+ 0b00000000000000011100000000000000,
+ 0b00000000000001111110000000000000,
+ 0b00000000000001111111000000000000,
+ 0b00000000000011111111100000000000,
+ 0b00000000001111111111110000000000,
+ 0b00000000011111011111111000000000,
+ 0b00000000011110011101111100000000,
+ 0b00000000111100011100111100000000,
+ 0b00000000011000011100011000000000,
+ 0b00000000000000011100000000000000,
+ 0b00000000000000011100000000000000,
+ 0b00000000000000011110000000000000,
+ 0b00000000000000011110000000000000,
+ 0b00000000000000011111000000000000,
+ 0b00000000000000001111100000000000,
+ 0b00000000000000000111110000000000,
+ 0b00000000000000000011111000000000,
+ 0b00000000000000000001111000000000,
+ 0b00000000000000000000111100000000,
+ 0b00000000000000000000011100000000,
+ 0b00000000000000000000011110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_KEEP_RIGHT, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000010000000000000000,
+ 0b00000000000000111000000000000000,
+ 0b00000000000001111110000000000000,
+ 0b00000000000011111110000000000000,
+ 0b00000000000111111111000000000000,
+ 0b00000000001111111111110000000000,
+ 0b00000000011111111011111000000000,
+ 0b00000000111110111001111000000000,
+ 0b00000000111100111000111100000000,
+ 0b00000000011000111000011000000000,
+ 0b00000000000000111000000000000000,
+ 0b00000000000000111000000000000000,
+ 0b00000000000001111000000000000000,
+ 0b00000000000001111000000000000000,
+ 0b00000000000011111000000000000000,
+ 0b00000000000111110000000000000000,
+ 0b00000000001111100000000000000000,
+ 0b00000000011111000000000000000000,
+ 0b00000000011110000000000000000000,
+ 0b00000000111100000000000000000000,
+ 0b00000000111000000000000000000000,
+ 0b00000001111000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ }));
+
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_FINISH, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000011111111000000000000,
+ 0b00000000001111111111110000000000,
+ 0b00000000011111000011111000000000,
+ 0b00000000111100000000111100000000,
+ 0b00000001111000000000011110000000,
+ 0b00000001110000000000001110000000,
+ 0b00000011100000000000000111000000,
+ 0b00000011100000000000000111000000,
+ 0b00000011000000111100000011000000,
+ 0b00000111000001111110000011100000,
+ 0b00000111000001111110000011100000,
+ 0b00000111000000111100000011100000,
+ 0b00000111000000111100000011100000,
+ 0b00000011000000000000000011000000,
+ 0b00000011100000000000000111000000,
+ 0b00000011100000000000000111000000,
+ 0b00000001110000000000001110000000,
+ 0b00000000111000000000011100000000,
+ 0b00000000111100000000111100000000,
+ 0b00000000011100000000111000000000,
+ 0b00000000001110000001110000000000,
+ 0b00000000000111000011100000000000,
+ 0b00000000000011111111000000000000,
+ 0b00000000000001111110000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000011000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_FINISH, new int[]{
+ 0b00000000000000000000000000000000, // RHD
+ 0b00000000111000000000000000000000,
+ 0b00000111111110000000000000000000,
+ 0b00001111111111100000000000000000,
+ 0b00011110000011110000000000000000,
+ 0b00111100000001110000000000000000,
+ 0b00111000000000111000000000000000,
+ 0b00110000010000111000000000000000,
+ 0b01110000111000011000000000000000,
+ 0b01110001111000011000000000000000,
+ 0b01110000111000011000000000000000,
+ 0b00110000000000111000000000000000,
+ 0b00111000000000111000000000000000,
+ 0b00111000000001110000000000000000,
+ 0b00011100000001110000000000000000,
+ 0b00011100000011100011000111100000,
+ 0b00001110000011100111000111100000,
+ 0b00001111000111000111111111110000,
+ 0b00000111001111001111111111110000,
+ 0b00000011101110011111111111110000,
+ 0b00000011111100011111000111110000,
+ 0b00000001111000111111000111111000,
+ 0b00000000111000111111000111111000,
+ 0b00000000010001111111111111111000,
+ 0b00000000000001111111111111111100,
+ 0b00000000000001111111000111111100,
+ 0b00000000000011111111000111111100,
+ 0b00000000000011111111000111111110,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_FINISH, new int[]{
+ 0b00000000000000000000000000000000, // LHD
+ 0b00000000000000000000011100000000,
+ 0b00000000000000000011111111100000,
+ 0b00000000000000000111111111110000,
+ 0b00000000000000001111000001111000,
+ 0b00000000000000001110000000111100,
+ 0b00000000000000011100000000011100,
+ 0b00000000000000011100001000001100,
+ 0b00000000000000011000011100001110,
+ 0b00000000000000011000011110001110,
+ 0b00000000000000011000011100001110,
+ 0b00000000000000011100000000001100,
+ 0b00000000000000011100000000011100,
+ 0b00000000000000001110000000011100,
+ 0b00000000000000001110000000111000,
+ 0b00000111100011000111000000111000,
+ 0b00000111100011100111000001110000,
+ 0b00000111111111100011100011110000,
+ 0b00001111111111110011110011100000,
+ 0b00001111111111111001110111000000,
+ 0b00001111100011111000111110000000,
+ 0b00011111100011111100011110000000,
+ 0b00011111100011111100011100000000,
+ 0b00011111111111111110001000000000,
+ 0b00111111111111111110000000000000,
+ 0b00111111100011111110000000000000,
+ 0b00111111100011111111000000000000,
+ 0b01111111100011111111000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_LEFT, new int[]{
+ 0b000000000000000000000000000000000,
+ 0b000000000000000000000000000000000,
+ 0b000000000000000000000000000000000,
+ 0b000000000000000000000000000000000,
+ 0b000000001111111111100000000000000,
+ 0b000000001111111111110000000000000,
+ 0b000000001111111111110000000000000,
+ 0b000000001111100000000000000000000,
+ 0b000000001111110000000000000000000,
+ 0b000000001111111000000000000000000,
+ 0b000000001111111100000000000000000,
+ 0b000000001110111110000000000000000,
+ 0b000000001110011111000000000000000,
+ 0b000000001110001111100000000000000,
+ 0b000000001110001111000000000000000,
+ 0b000000001110011110000000000000000,
+ 0b000000000000011110000000000000000,
+ 0b000000000000011100000000000000000,
+ 0b000000000000011100000000000000000,
+ 0b000000000000011110000000000000000,
+ 0b000000000000011110000000000000000,
+ 0b000000000000001111000000000000000,
+ 0b000000000000000111111100000000000,
+ 0b000000000000000111111100000000000,
+ 0b000000000000000001111100000000000,
+ 0b000000000000000000011100000000000,
+ 0b000000000000000000011100000000000,
+ 0b000000000000000000011100000000000,
+ 0b000000000000000000000000000000000,
+ 0b000000000000000000000000000000000,
+ 0b000000000000000000000000000000000,
+ 0b000000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_RIGHT, new int[]{
+ 0b00000000000000000000000000000000,// GUESSED - this may not be an exact match
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000111111111110000000,
+ 0b00000000000001111111111110000000,
+ 0b00000000000001111111111110000000,
+ 0b00000000000000000000111110000000,
+ 0b00000000000000000001111110000000,
+ 0b00000000000000000011111110000000,
+ 0b00000000000000000111111110000000,
+ 0b00000000000000001111101110000000,
+ 0b00000000000000011111001110000000,
+ 0b00000000000000111110001110000000,
+ 0b00000000000000011110001110000000,
+ 0b00000000000000001111001110000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000000111000000000000,
+ 0b00000000000000000111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000000011110000000000000,
+ 0b00000000000111111100000000000000,
+ 0b00000000000111111100000000000000,
+ 0b00000000000111110000000000000000,
+ 0b00000000000111000000000000000000,
+ 0b00000000000111000000000000000000,
+ 0b00000000000111000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_STRAIGHT, new int[]{
+ 0b00000000000000000000000000000000,// GUESSED - this may not be an exact match
+ 0b00000000000000011000000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000001111110000000000000,
+ 0b00000000000011111111000000000000,
+ 0b00000000000111111111100000000000,
+ 0b00000000001111111111110000000000,
+ 0b00000000011110111101111000000000,
+ 0b00000000111100111100111100000000,
+ 0b00000000111000111100011100000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000011111100000000000000,
+ 0b00000000000111111100000000000000,
+ 0b00000000001111111100000000000000,
+ 0b00000000011111000000000000000000,
+ 0b00000000011110000000000000000000,
+ 0b00000000011100000000000000000000,
+ 0b00000000011100000000000000000000,
+ 0b00000000011100000000000000000000,
+ 0b00000000011100000000000000000000,
+ 0b00000000011110000000000000000000,
+ 0b00000000011111000000000000000000,
+ 0b00000000001111111100000000000000,
+ 0b00000000000111111100000000000000,
+ 0b00000000000011111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_STRAIGHT, new int[]{
+ 0b00000000000000000000000000000000,// GUESSED - this may not be an exact match
+ 0b00000000000000011000000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000001111110000000000000,
+ 0b00000000000011111111000000000000,
+ 0b00000000000111111111100000000000,
+ 0b00000000001111111111110000000000,
+ 0b00000000011110111101111000000000,
+ 0b00000000111100111100111100000000,
+ 0b00000000111000111100011100000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111111000000000000,
+ 0b00000000000000111111100000000000,
+ 0b00000000000000111111110000000000,
+ 0b00000000000000000011111000000000,
+ 0b00000000000000000001111000000000,
+ 0b00000000000000000000111000000000,
+ 0b00000000000000000000111000000000,
+ 0b00000000000000000000111000000000,
+ 0b00000000000000000000111000000000,
+ 0b00000000000000000001111000000000,
+ 0b00000000000000000011111000000000,
+ 0b00000000000000111111110000000000,
+ 0b00000000000000111111100000000000,
+ 0b00000000000000111111000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_LEFT, new int[]{
+ 0b00000000000000000000000000000000,// GUESSED - this may not be an exact match
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000011000000000000000000000,
+ 0b00000000111100000000000000000000,
+ 0b00000001111100000000000000000000,
+ 0b00000011111000000000000000000000,
+ 0b00000111110000000000000000000000,
+ 0b00001111100000000000000000000000,
+ 0b00011111111111111110000000000000,
+ 0b00111111111111111110000000000000,
+ 0b00111111111111111111000000000000,
+ 0b00011111111111111111000000000000,
+ 0b00001111100000001111000000000000,
+ 0b00000111110000000111110000000000,
+ 0b00000011111000000011111110000000,
+ 0b00000001111100000011111111000000,
+ 0b00000000111100000000111110000000,
+ 0b00000000011000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_RIGHT, new int[]{
+ 0b00000000000000000000000000000000,// GUESSED - this may not be an exact match
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000011000000000,
+ 0b00000000000000000000111100000000,
+ 0b00000000000000000000111110000000,
+ 0b00000000000000000000011111000000,
+ 0b00000000000000000000001111100000,
+ 0b00000000000000000000000111110000,
+ 0b00000000000001111111111111111000,
+ 0b00000000000001111111111111111100,
+ 0b00000000000011111111111111111100,
+ 0b00000000000011111111111111111000,
+ 0b00000000000011110000000111110000,
+ 0b00000000001111100000001111100000,
+ 0b00000001111111000000011111000000,
+ 0b00000011111111000000111110000000,
+ 0b00000001111100000000111100000000,
+ 0b00000001110000000000011000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_LEFT, new int[]{
+ 0b00000000000000000000000000000000,// GUESSED - this may not be an exact match
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000111111111111000000000000000,
+ 0b00000111111111111000000000000000,
+ 0b00000111111111111000000000000000,
+ 0b00000111111100000000000000000000,
+ 0b00000111111100000000000000000000,
+ 0b00000111111110000000000000000000,
+ 0b00000111111111000000000000000000,
+ 0b00000111101111101111111000000000,
+ 0b00000111100111111111111100000000,
+ 0b00000111100011111111111110000000,
+ 0b00000111100001111100011111000000,
+ 0b00000011000000110000001111000000,
+ 0b00000000000000000000000111100000,
+ 0b00000000000000000000000111100000,
+ 0b00000000000000000000000111100000,
+ 0b00000000000000000000000111100000,
+ 0b00000000000000000000001111000000,
+ 0b00000000000000000000011111000000,
+ 0b00000000000000000111111110000000,
+ 0b00000000000000000111111100000000,
+ 0b00000000000000000111111000000000,
+ 0b00000000000000000011100000000000,
+ 0b00000000000000000011100000000000,
+ 0b00000000000000000011100000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_RIGHT, new int[]{
+ 0b00000000000000000000000000000000,// GUESSED - this may not be an exact match
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000011111111111100000,
+ 0b00000000000000011111111111100000,
+ 0b00000000000000011111111111100000,
+ 0b00000000000000000000111111100000,
+ 0b00000000000000000000111111100000,
+ 0b00000000000000000001111111100000,
+ 0b00000000000000000011111111100000,
+ 0b00000000011111110111110111100000,
+ 0b00000000111111111111100111100000,
+ 0b00000001111111111111000111100000,
+ 0b00000011111000111110000111100000,
+ 0b00000011110000001100000011000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000111100000000000000000000000,
+ 0b00000011110000000000000000000000,
+ 0b00000011111000000000000000000000,
+ 0b00000001111111100000000000000000,
+ 0b00000000111111100000000000000000,
+ 0b00000000011111100000000000000000,
+ 0b00000000000111000000000000000000,
+ 0b00000000000111000000000000000000,
+ 0b00000000000111000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_LEFT, new int[]{
+ 0b00000000000000000000000000000000,// GUESSED - this may not be an exact match
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000011000000000000000000000,
+ 0b00000000111100000000111111100000,
+ 0b00000001111100000011111111110000,
+ 0b00000011111000000111111111111000,
+ 0b00000111110000000111110001111100,
+ 0b00001111100000001111000000111100,
+ 0b00011111111111111111000000011110,
+ 0b00111111111111111110000000011110,
+ 0b00111111111111111110000000011110,
+ 0b00011111111111111110000000011110,
+ 0b00001111100000000000000000111100,
+ 0b00000111110000000000000001111100,
+ 0b00000011111000000000011111111000,
+ 0b00000001111100000000011111110000,
+ 0b00000000111100000000011111100000,
+ 0b00000000011000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000001110000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_RIGHT, new int[]{
+ 0b00000000000000000000000000000000,// GUESSED - this may not be an exact match
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000011000000000,
+ 0b00000111111100000000111100000000,
+ 0b00001111111111000000111110000000,
+ 0b00011111111111100000011111000000,
+ 0b00111110001111100000001111100000,
+ 0b00111100000011110000000111110000,
+ 0b01111000000011111111111111111000,
+ 0b01111000000001111111111111111100,
+ 0b01111000000001111111111111111100,
+ 0b01111000000001111111111111111000,
+ 0b00111100000000000000000111110000,
+ 0b00111110000000000000001111100000,
+ 0b00011111111000000000011111000000,
+ 0b00001111111000000000111110000000,
+ 0b00000111111000000000111100000000,
+ 0b00000001110000000000011000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000001110000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_UTURN, new int[]{
+ 0b00000000000000000000000000000000, // LHD
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000001111111000000000000,
+ 0b00000000000011111111110000000000,
+ 0b00000000000111111111110000000000,
+ 0b00000000001111100001111000000000,
+ 0b00000000001111000000111100000000,
+ 0b00000000011110000000111100000000,
+ 0b00000000011110000000011100000000,
+ 0b00000000011110000000011100000000,
+ 0b00000000011110000000111100000000,
+ 0b00000000001111000000111100000000,
+ 0b00000000001111100001111000000000,
+ 0b00000000000111110011110000000000,
+ 0b00000000000011110011110000000000,
+ 0b00000000000011110010000000000000,
+ 0b00000000000011110000000000000000,
+ 0b00000001100011110001100000000000,
+ 0b00000011110011110011110000000000,
+ 0b00000001111011110111110000000000,
+ 0b00000000111111111111100000000000,
+ 0b00000000011111111111000000000000,
+ 0b00000000001111111110100000000000,
+ 0b00000000000111111101100000000000,
+ 0b00000000000011111011100000000000,
+ 0b00000000000001110011100000000000,
+ 0b00000000000000100011100000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_ROUNDABOUT_UTURN, new int[]{
+ 0b00000000000000000000000000000000, // RHD
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000011111110000000000000,
+ 0b00000000001111111111000000000000,
+ 0b00000000001111111111100000000000,
+ 0b00000000011110000111110000000000,
+ 0b00000000111100000011110000000000,
+ 0b00000000111100000001111000000000,
+ 0b00000000111000000001111000000000,
+ 0b00000000111000000001111000000000,
+ 0b00000000111100000001111000000000,
+ 0b00000000111100000011110000000000,
+ 0b00000000011110000111110000000000,
+ 0b00000000001111001111100000000000,
+ 0b00000000001111001111000000000000,
+ 0b00000000000001001111000000000000,
+ 0b00000000000000001111000000000000,
+ 0b00000000000110001111000110000000,
+ 0b00000000001111001111001111000000,
+ 0b00000000001111101111011110000000,
+ 0b00000000000111111111111100000000,
+ 0b00000000000011111111111000000000,
+ 0b00000000000101111111110000000000,
+ 0b00000000000110111111100000000000,
+ 0b00000000000111011111000000000000,
+ 0b00000000000111001110000000000000,
+ 0b00000000000111000100000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_MERGE, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000011000000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000001111110000000000000,
+ 0b00000000000011111111000000000000,
+ 0b00000000000111111111100000000000,
+ 0b00000000001111111111110000000000,
+ 0b00000000011111111111111000000000,
+ 0b00000000011111111111111000000000,
+ 0b00000000111100111100111100000000,
+ 0b00000000011000111100011000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000001111110000000000000,
+ 0b00000000000001111110000000000000,
+ 0b00000000000011111111000000000000,
+ 0b00000000000111100111100000000000,
+ 0b00000000001111100111110000000000,
+ 0b00000000011111000011110000000000,
+ 0b00000000011110000001111000000000,
+ 0b00000000011100000000111000000000,
+ 0b00000000111100000000111100000000,
+ 0b00000000111100000000111100000000,
+ 0b00000000111000000000011100000000,
+ 0b00000000111000000000011100000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ knownImages.add(new IconType(NavigationInfoSpec.ACTION_MERGE, new int[]{
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000011000000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000001111110000000000000,
+ 0b00000000000011111111000000000000,
+ 0b00000000000111111111100000000000,
+ 0b00000000001111111111110000000000,
+ 0b00000000011111111111111000000000,
+ 0b00000000111110111101111100000000,
+ 0b00000000111100111100111100000000,
+ 0b00000000011000111100011000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000000111100000000000000,
+ 0b00000000000001111110000000000000,
+ 0b00000000000001111110000000000000,
+ 0b00000000000011111111000000000000,
+ 0b00000000000111100111100000000000,
+ 0b00000000001111000011110000000000,
+ 0b00000000001111000011110000000000,
+ 0b00000000011110000001111000000000,
+ 0b00000000011100000000111000000000,
+ 0b00000000111100000000111100000000,
+ 0b00000000111000000000011100000000,
+ 0b00000000111000000000011100000000,
+ 0b00000000111000000000011100000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000,
+ 0b00000000000000000000000000000000
+ }));
+ }
+
+ public boolean handle(Context context, StatusBarNotification sbn) {
+ if (sbn.getPackageName().equals("com.google.android.apps.maps")) {
+ checkShouldSendNavigation(context);
+ if (!shouldSendNavigation) return false;
+ Notification notification = sbn.getNotification();
+ if (!NotificationCompat.getLocalOnly(notification))
+ return false; // ignore non-local notifications
+
+ String distance = (notification.extras.get("android.title")==null) ? "" :
+ notification.extras.get("android.title").toString(); // eg 100 yd
+ String instruction = (notification.extras.get("android.text")==null) ? "" :
+ notification.extras.get("android.text").toString(); // eg: High St towards Blah
+ String navLine = (notification.extras.get("android.subText")==null) ? "" :
+ notification.extras.get("android.subText").toString().replaceAll("\u00A0", " "); // eg: 13 min 路 4.6聽mi 路 11:55 ETA
+ String[] navLines = navLine.split("路");
+ for (int i = 0; i < navLines.length; i++) navLines[i] = navLines[i].trim();
+ if (navLines.length < 3) return false; // not in the format we expected - might be 'Shared with you' notification
+ // navLines now has at least 3 elements
+ LOG.info("Navigation: " + instruction + "," + distance + "," + navLines[0] + "," + navLines[1] + "," + navLines[2]);
+ int matchedIcon = -1;
+ // getLargeIcon only works in API 23+ - don't try and get icons on older devices
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
+ Icon icon = notification.getLargeIcon();
+ if (icon != null) {
+ // We convert the icon to a bitmap, then to x 32x32 1bpp which we'll check against known images
+ Drawable drawable = icon.loadDrawable(context);
+ Bitmap bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888); // Single color bitmap will be created of 1x1 pixel
+ Canvas canvas = new Canvas(bitmap);
+ drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+ drawable.draw(canvas);
+ int[] pixelsRGBA = new int[32 * 32];
+ bitmap.getPixels(pixelsRGBA, 0, 32, 0, 0, 32, 32);
+ int[] pixelPack = new int[32]; // pixels packed down 1bpp
+ char[] pixelStringChars = new char[33 * 32];
+
+ for (int y = 0; y < 32; y++) {
+ int pack = 0;
+ for (int x = 0; x < 32; x++) {
+ int pixel = ((pixelsRGBA[(y * 32) + x] & 0x80000000) != 0) ? 1 : 0;
+ pack = (pack << 1) | pixel; // check top bit (=alpha)
+ pixelStringChars[x + y * 33] = (pixel != 0) ? '1' : '0';
+ }
+ pixelStringChars[32 + y * 33] = '\n';
+ pixelPack[y] = pack;
+ }
+ // This is now a printable string showing the image, which we can log if we don't recognise it
+ String pixelString = new String(pixelStringChars);
+
+ int bestDiff = 100;
+ for (int i = 0; i < knownImages.size(); i++) {
+ IconType knownImage = knownImages.get(i);
+ int diff = 0;
+ // Work out how many bits differ over the whole image
+ for (int j = 0; j < 32; j++)
+ diff += Integer.bitCount(knownImage.icon[j] ^ pixelPack[j]);
+ // if it's close enough, match
+ if (diff < 32 && diff < bestDiff) {
+ matchedIcon = knownImage.iconType;
+ bestDiff = diff;
+ }
+ }
+ if (matchedIcon < 0) {
+ LOG.info("Icon NEW:\n" + pixelString);
+ knownImages.add(new IconType(255, pixelPack));
+ }
+ }
+ }
+ NavigationInfoSpec navInfo = new NavigationInfoSpec();
+ if (matchedIcon>=0)
+ navInfo.nextAction = matchedIcon;
+ navInfo.instruction = instruction;
+ if (distance != null)
+ navInfo.distanceToTurn = distance;
+ if (navLines[2].contains("ETA"))
+ navInfo.ETA = navLines[2].replace("ETA","").trim();
+ GBApplication.deviceService().onSetNavigationInfo(navInfo);
+
+ return true;
+ }
+ return false;
+ }
+
+ public boolean handleRemove(StatusBarNotification sbn) {
+ if (sbn.getPackageName().equals("com.google.android.apps.maps")) {
+ if (!shouldSendNavigation) return false;
+ Notification notification = sbn.getNotification();
+ if (!NotificationCompat.getLocalOnly(notification))
+ return false; // ignore non-local notifications
+ NavigationInfoSpec navInfo = new NavigationInfoSpec(); // send a blank one to remove
+ GBApplication.deviceService().onSetNavigationInfo(navInfo);
+ return true;
+ }
+ return false;
+ }
+
+ private void checkShouldSendNavigation(Context context) {
+ Prefs prefs = GBApplication.getPrefs();
+
+ boolean navigationForward = prefs.getBoolean("navigation_forward", true);
+ boolean navigationGMaps = prefs.getBoolean("navigation_app_gmaps", true);
+ if (!navigationForward || !navigationGMaps) {
+ shouldSendNavigation = false;
+ return;
+ }
+
+ boolean navigationScreenOn = prefs.getBoolean("nagivation_screen_on", true);
+ if (!navigationScreenOn) {
+ PowerManager powermanager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
+ if (powermanager != null && powermanager.isScreenOn()) {
+ LOG.info("Not forwarding navigation instructions, screen seems to be on and settings do not allow this");
+ shouldSendNavigation = false;
+ return;
+ }
+ }
+
+ shouldSendNavigation = true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksContentObserver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksContentObserver.java
new file mode 100644
index 0000000..f9c60d1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksContentObserver.java
@@ -0,0 +1,100 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+
+package nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks;
+
+import android.app.Activity;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Handler;
+
+import java.util.List;
+
+
+public class OpenTracksContentObserver extends ContentObserver {
+ private Context mContext;
+ private Uri tracksUri;
+ private int protocolVersion;
+ private int totalTimeMillis;
+ private float totalDistanceMeter;
+
+ private long previousTimeMillis = 0;
+ private float previousDistanceMeter = 0;
+
+ public int getTotalTimeMillis() {
+ return totalTimeMillis;
+ }
+ public float getTotalDistanceMeter() {
+ return totalDistanceMeter;
+ }
+
+ public long getTimeMillisChange() {
+ /**
+ * We don't use the timeMillis received from OpenTracks here, because those updates do not
+ * come in very regularly when GPS reception is bad
+ */
+ long timeMillisDelta = System.currentTimeMillis() - previousTimeMillis;
+ previousTimeMillis = System.currentTimeMillis();
+ return timeMillisDelta;
+ }
+
+ public float getDistanceMeterChange() {
+ float distanceMeterDelta = totalDistanceMeter - previousDistanceMeter;
+ previousDistanceMeter = totalDistanceMeter;
+ return distanceMeterDelta;
+ }
+
+
+ public OpenTracksContentObserver(Context context, final Uri tracksUri, final int protocolVersion) {
+ super(new Handler());
+ this.mContext = context;
+ this.tracksUri = tracksUri;
+ this.protocolVersion = protocolVersion;
+ this.previousTimeMillis = System.currentTimeMillis();
+ }
+
+ @Override
+ public void onChange(final boolean selfChange, final Uri uri) {
+ if (uri == null) {
+ return; // nothing can be done without an uri
+ }
+ if (tracksUri.toString().startsWith(uri.toString())) {
+ final List<Track> tracks = Track.readTracks(mContext.getContentResolver(), tracksUri, protocolVersion);
+ if (!tracks.isEmpty()) {
+ final TrackStatistics statistics = new TrackStatistics(tracks);
+ totalTimeMillis = statistics.getTotalTimeMillis();
+ totalDistanceMeter = statistics.getTotalDistanceMeter();
+ }
+ }
+ }
+
+ public void unregister() {
+ if (mContext != null) {
+ mContext.getContentResolver().unregisterContentObserver(this);
+ }
+ }
+
+ public void finish() {
+ unregister();
+ if (mContext != null) {
+ ((Activity) mContext).finish();
+ mContext = null;
+ }
+ }
+}
+
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksController.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksController.java
new file mode 100644
index 0000000..eee0ce3
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/OpenTracksController.java
@@ -0,0 +1,154 @@
+/* Copyright (C) 2022-2024 Arjan Schrijver, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+
+package nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class OpenTracksController extends Activity {
+ /*
+ * A short explanation of how this integration with OpenTracks works:
+ * Starting the recording from a device requires calling `startRecording()`
+ * on this class. For a simple example, check out the implementation in
+ * `WorkoutRequestHandler`, used by the Fossil HR series.
+ * The OpenTracks class can be set in the Gadgetbridge settings and depends
+ * on the installation source used for OpenTracks. Details can be found in
+ * their documentation here: https://github.com/OpenTracksApp/OpenTracks#api
+ * `startRecording()` sends an explicit Intent to OpenTracks signalling it
+ * to start recording. It passes along the package name and class name of
+ * our `OpenTracksController` which OpenTracks will use to send the
+ * statistics URIs to. After starting the recording service, OpenTracks
+ * uses a new explicit Intent to start our `OpenTracksController` and passes
+ * along the URIs and the read permissions for those URIs (using
+ * `Intent.FLAG_GRANT_READ_URI_PERMISSION`). So at that point
+ * `OpenTracksController` is started as a new `Activity` (or `Context`)
+ * which has the read permissions for the statistics URIs. The controller
+ * saves its `Context` into the `OpenTracksContentObserver` in the GB main
+ * process, so it can keep running and read the statistics with the correct
+ * `Context`. So, whatever class, device or activity calls the methods on
+ * the `OpenTracksContentObserver` from, it will always work.
+ */
+ private static final String EXTRAS_PROTOCOL_VERSION = "PROTOCOL_VERSION";
+ private static final String ACTION_DASHBOARD = "Intent.OpenTracks-Dashboard";
+ private static final String ACTION_DASHBOARD_PAYLOAD = ACTION_DASHBOARD + ".Payload";
+
+ private static final Logger LOG = LoggerFactory.getLogger(OpenTracksController.class);
+
+ @Override
+ public void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+ GBApplication gbApp = GBApplication.app();
+ Intent intent = getIntent();
+ int protocolVersion = intent.getIntExtra(EXTRAS_PROTOCOL_VERSION, 1);
+ final ArrayList<Uri> uris = intent.getParcelableArrayListExtra(ACTION_DASHBOARD_PAYLOAD);
+ if (uris != null) {
+ if (gbApp.getOpenTracksObserver() != null) {
+ LOG.info("Unregistering old OpenTracksContentObserver");
+ gbApp.getOpenTracksObserver().unregister();
+ }
+ Uri tracksUri = uris.get(0);
+ LOG.info("Registering OpenTracksContentObserver with tracks URI: " + tracksUri);
+ gbApp.setOpenTracksObserver(new OpenTracksContentObserver(this, tracksUri, protocolVersion));
+ try {
+ getContentResolver().registerContentObserver(tracksUri, false, gbApp.getOpenTracksObserver());
+ } catch (final SecurityException se) {
+ LOG.error("Error registering OpenTracksContentObserver", se);
+ }
+ }
+ moveTaskToBack(true);
+ }
+
+ public static void sendIntent(Context context, String className, String category, String icon) {
+ Prefs prefs = GBApplication.getPrefs();
+ String packageName = prefs.getString("opentracks_packagename", "de.dennisguse.opentracks");
+ Intent intent = new Intent();
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.setClassName(packageName, className);
+ intent.putExtra("STATS_TARGET_PACKAGE", context.getPackageName());
+ intent.putExtra("STATS_TARGET_CLASS", OpenTracksController.class.getName());
+ if (category != null) {
+ intent.putExtra("TRACK_CATEGORY", category);
+ }
+ if (icon != null) {
+ intent.putExtra("TRACK_ICON", icon);
+ }
+ try {
+ context.startActivity(intent);
+ } catch (Exception e) {
+ GB.toast(e.getMessage(), Toast.LENGTH_LONG, GB.WARN);
+ }
+ }
+
+ public static void startRecording(Context context) {
+ sendIntent(context, "de.dennisguse.opentracks.publicapi.StartRecording", null, null);
+ }
+
+ public static void startRecording(Context context, ActivityKind activityKind) {
+ final String category = activityKind.getLabel(context);
+ final String icon;
+ switch (activityKind) {
+ case CYCLING:
+ icon = "BIKE";
+ break;
+ case HIKING:
+ case WALKING:
+ icon = "WALK";
+ break;
+ case RUNNING:
+ icon = "RUN";
+ break;
+ default:
+ LOG.warn("Unmapped activity kind icon for {}", activityKind);
+ icon = null;
+ }
+
+ sendIntent(context, "de.dennisguse.opentracks.publicapi.StartRecording", category, icon);
+ }
+
+ public static void stopRecording(Context context) {
+ sendIntent(context, "de.dennisguse.opentracks.publicapi.StopRecording", null, null);
+ OpenTracksContentObserver openTracksObserver = GBApplication.app().getOpenTracksObserver();
+ if (openTracksObserver != null) {
+ openTracksObserver.finish();
+ }
+ GBApplication.app().setOpenTracksObserver(null);
+ }
+
+ public static void toggleRecording(Context context) {
+ OpenTracksContentObserver openTracksObserver = GBApplication.app().getOpenTracksObserver();
+ if (openTracksObserver == null) {
+ startRecording(context);
+ } else {
+ stopRecording(context);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/Track.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/Track.java
new file mode 100644
index 0000000..04a932e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/Track.java
@@ -0,0 +1,205 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+
+package nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks;
+
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This class was copied and modified from
+ * https://github.com/OpenTracksApp/OSMDashboard/blob/main/src/main/java/de/storchp/opentracks/osmplugin/dashboardapi/Track.java
+ */
+class Track {
+ private static final Logger LOG = LoggerFactory.getLogger(Track.class);
+
+ private static final String TAG = Track.class.getSimpleName();
+
+ public static final String _ID = "_id";
+ public static final String NAME = "name"; // track name
+ public static final String DESCRIPTION = "description"; // track description
+ public static final String CATEGORY = "category"; // track activity type
+ public static final String STARTTIME = "starttime"; // track start time
+ public static final String STOPTIME = "stoptime"; // track stop time
+ public static final String TOTALDISTANCE = "totaldistance"; // total distance
+ public static final String TOTALTIME = "totaltime"; // total time
+ public static final String MOVINGTIME = "movingtime"; // moving time
+ public static final String AVGSPEED = "avgspeed"; // average speed
+ public static final String AVGMOVINGSPEED = "avgmovingspeed"; // average moving speed
+ public static final String MAXSPEED = "maxspeed"; // maximum speed
+ public static final String MINELEVATION = "minelevation"; // minimum elevation
+ public static final String MAXELEVATION = "maxelevation"; // maximum elevation
+ public static final String ELEVATIONGAIN = "elevationgain"; // elevation gain
+
+ public static final String[] PROJECTION = {
+ _ID,
+ NAME,
+ DESCRIPTION,
+ CATEGORY,
+ STARTTIME,
+ STOPTIME,
+ TOTALDISTANCE,
+ TOTALTIME,
+ MOVINGTIME,
+ AVGSPEED,
+ AVGMOVINGSPEED,
+ MAXSPEED,
+ MINELEVATION,
+ MAXELEVATION,
+ ELEVATIONGAIN
+ };
+
+ private final long id;
+ private final String trackname;
+ private final String description;
+ private final String category;
+ private final int startTimeEpochMillis;
+ private final int stopTimeEpochMillis;
+ private final float totalDistanceMeter;
+ private final int totalTimeMillis;
+ private final int movingTimeMillis;
+ private final float avgSpeedMeterPerSecond;
+ private final float avgMovingSpeedMeterPerSecond;
+ private final float maxSpeedMeterPerSecond;
+ private final float minElevationMeter;
+ private final float maxElevationMeter;
+ private final float elevationGainMeter;
+
+ public Track(final long id, final String trackname, final String description, final String category, final int startTimeEpochMillis, final int stopTimeEpochMillis, final float totalDistanceMeter, final int totalTimeMillis, final int movingTimeMillis, final float avgSpeedMeterPerSecond, final float avgMovingSpeedMeterPerSecond, final float maxSpeedMeterPerSecond, final float minElevationMeter, final float maxElevationMeter, final float elevationGainMeter) {
+ this.id = id;
+ this.trackname = trackname;
+ this.description = description;
+ this.category = category;
+ this.startTimeEpochMillis = startTimeEpochMillis;
+ this.stopTimeEpochMillis = stopTimeEpochMillis;
+ this.totalDistanceMeter = totalDistanceMeter;
+ this.totalTimeMillis = totalTimeMillis;
+ this.movingTimeMillis = movingTimeMillis;
+ this.avgSpeedMeterPerSecond = avgSpeedMeterPerSecond;
+ this.avgMovingSpeedMeterPerSecond = avgMovingSpeedMeterPerSecond;
+ this.maxSpeedMeterPerSecond = maxSpeedMeterPerSecond;
+ this.minElevationMeter = minElevationMeter;
+ this.maxElevationMeter = maxElevationMeter;
+ this.elevationGainMeter = elevationGainMeter;
+ }
+
+ /**
+ * Reads the Tracks from the Content Uri
+ */
+ public static List<Track> readTracks(final ContentResolver resolver, final Uri data, final int protocolVersion) {
+ LOG.info("Loading track(s) from " + data);
+
+ final ArrayList<Track> tracks = new ArrayList<Track>();
+ try (final Cursor cursor = resolver.query(data, Track.PROJECTION, null, null, null)) {
+ while (cursor.moveToNext()) {
+ final long id = cursor.getLong(cursor.getColumnIndexOrThrow(Track._ID));
+ final String trackname = cursor.getString(cursor.getColumnIndexOrThrow(Track.NAME));
+ final String description = cursor.getString(cursor.getColumnIndexOrThrow(Track.DESCRIPTION));
+ final String category = cursor.getString(cursor.getColumnIndexOrThrow(Track.CATEGORY));
+ final int startTimeEpochMillis = cursor.getInt(cursor.getColumnIndexOrThrow(Track.STARTTIME));
+ final int stopTimeEpochMillis = cursor.getInt(cursor.getColumnIndexOrThrow(Track.STOPTIME));
+ final float totalDistanceMeter = cursor.getFloat(cursor.getColumnIndexOrThrow(Track.TOTALDISTANCE));
+ final int totalTimeMillis = cursor.getInt(cursor.getColumnIndexOrThrow(Track.TOTALTIME));
+ final int movingTimeMillis = cursor.getInt(cursor.getColumnIndexOrThrow(Track.MOVINGTIME));
+ final float avgSpeedMeterPerSecond = cursor.getFloat(cursor.getColumnIndexOrThrow(Track.AVGSPEED));
+ final float avgMovingSpeedMeterPerSecond = cursor.getFloat(cursor.getColumnIndexOrThrow(Track.AVGMOVINGSPEED));
+ final float maxSpeedMeterPerSecond = cursor.getFloat(cursor.getColumnIndexOrThrow(Track.MAXSPEED));
+ final float minElevationMeter = cursor.getFloat(cursor.getColumnIndexOrThrow(Track.MINELEVATION));
+ final float maxElevationMeter = cursor.getFloat(cursor.getColumnIndexOrThrow(Track.MAXELEVATION));
+ final float elevationGainMeter = cursor.getFloat(cursor.getColumnIndexOrThrow(Track.ELEVATIONGAIN));
+
+ LOG.info("New Track data received: distance=" + totalDistanceMeter + " time=" + totalTimeMillis);
+
+ tracks.add(new Track(id, trackname, description, category, startTimeEpochMillis, stopTimeEpochMillis,
+ totalDistanceMeter, totalTimeMillis, movingTimeMillis, avgSpeedMeterPerSecond, avgMovingSpeedMeterPerSecond, maxSpeedMeterPerSecond,
+ minElevationMeter, maxElevationMeter, elevationGainMeter));
+ }
+ } catch (final SecurityException e) {
+ LOG.warn("No permission to read track", e);
+ } catch (final Exception e) {
+ LOG.warn("Reading track failed", e);
+ }
+ return tracks;
+ }
+
+ public float getElevationGainMeter() {
+ return elevationGainMeter;
+ }
+
+ public float getMaxElevationMeter() {
+ return maxElevationMeter;
+ }
+
+ public float getMinElevationMeter() {
+ return minElevationMeter;
+ }
+
+ public float getMaxSpeedMeterPerSecond() {
+ return maxSpeedMeterPerSecond;
+ }
+
+ public float getAvgMovingSpeedMeterPerSecond() {
+ return avgMovingSpeedMeterPerSecond;
+ }
+
+ public float getAvgSpeedMeterPerSecond() {
+ return avgSpeedMeterPerSecond;
+ }
+
+ public int getMovingTimeMillis() {
+ return movingTimeMillis;
+ }
+
+ public int getTotalTimeMillis() {
+ return totalTimeMillis;
+ }
+
+ public float getTotalDistanceMeter() {
+ return totalDistanceMeter;
+ }
+
+ public int getStopTimeEpochMillis() {
+ return stopTimeEpochMillis;
+ }
+
+ public int getStartTimeEpochMillis() {
+ return startTimeEpochMillis;
+ }
+
+ public String getCategory() {
+ return category;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public String getTrackname() {
+ return trackname;
+ }
+
+ public long getId() {
+ return id;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/TrackStatistics.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/TrackStatistics.java
new file mode 100644
index 0000000..efd5fc2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/opentracks/TrackStatistics.java
@@ -0,0 +1,130 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+
+package nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks;
+
+import java.util.List;
+
+/**
+ * This class was copied and modified from
+ * https://github.com/OpenTracksApp/OSMDashboard/blob/main/src/main/java/de/storchp/opentracks/osmplugin/utils/TrackStatistics.java
+ */
+class TrackStatistics {
+ private String category = "unknown";
+ private int startTimeEpochMillis;
+ private int stopTimeEpochMillis;
+ private float totalDistanceMeter;
+ private int totalTimeMillis;
+ private int movingTimeMillis;
+ private float avgSpeedMeterPerSecond;
+ private float avgMovingSpeedMeterPerSecond;
+ private float maxSpeedMeterPerSecond;
+ private float minElevationMeter;
+ private float maxElevationMeter;
+ private float elevationGainMeter;
+
+ public TrackStatistics(final List<Track> tracks) {
+ if (tracks.isEmpty()) {
+ return;
+ }
+ final Track first = tracks.get(0);
+ category = first.getCategory();
+ startTimeEpochMillis = first.getStartTimeEpochMillis();
+ stopTimeEpochMillis = first.getStopTimeEpochMillis();
+ totalDistanceMeter = first.getTotalDistanceMeter();
+ totalTimeMillis = first.getTotalTimeMillis();
+ movingTimeMillis = first.getMovingTimeMillis();
+ avgSpeedMeterPerSecond = first.getAvgSpeedMeterPerSecond();
+ avgMovingSpeedMeterPerSecond = first.getAvgMovingSpeedMeterPerSecond();
+ maxSpeedMeterPerSecond = first.getMaxSpeedMeterPerSecond();
+ minElevationMeter = first.getMinElevationMeter();
+ maxElevationMeter = first.getMaxElevationMeter();
+ elevationGainMeter = first.getElevationGainMeter();
+
+ if (tracks.size() > 1) {
+ float totalAvgSpeedMeterPerSecond = avgSpeedMeterPerSecond;
+ float totalAvgMovingSpeedMeterPerSecond = avgMovingSpeedMeterPerSecond;
+ for (final Track track : tracks.subList(1, tracks.size())) {
+ if (!category.equals(track.getCategory())) {
+ category = "mixed";
+ }
+ startTimeEpochMillis = Math.min(startTimeEpochMillis, track.getStartTimeEpochMillis());
+ stopTimeEpochMillis = Math.max(stopTimeEpochMillis, track.getStopTimeEpochMillis());
+ totalDistanceMeter += track.getTotalDistanceMeter();
+ totalTimeMillis += track.getTotalTimeMillis();
+ movingTimeMillis += track.getMovingTimeMillis();
+ totalAvgSpeedMeterPerSecond += track.getAvgSpeedMeterPerSecond();
+ totalAvgMovingSpeedMeterPerSecond += track.getAvgMovingSpeedMeterPerSecond();
+ maxSpeedMeterPerSecond = Math.max(maxSpeedMeterPerSecond, track.getMaxSpeedMeterPerSecond());
+ minElevationMeter = Math.min(minElevationMeter, track.getMinElevationMeter());
+ maxElevationMeter = Math.max(maxElevationMeter, track.getMaxElevationMeter());
+ elevationGainMeter += track.getElevationGainMeter();
+ }
+
+ avgSpeedMeterPerSecond = totalAvgSpeedMeterPerSecond / tracks.size();
+ avgMovingSpeedMeterPerSecond = totalAvgMovingSpeedMeterPerSecond / tracks.size();
+ }
+ }
+
+ public String getCategory() {
+ return category;
+ }
+
+ public int getStartTimeEpochMillis() {
+ return startTimeEpochMillis;
+ }
+
+ public int getStopTimeEpochMillis() {
+ return stopTimeEpochMillis;
+ }
+
+ public float getTotalDistanceMeter() {
+ return totalDistanceMeter;
+ }
+
+ public int getTotalTimeMillis() {
+ return totalTimeMillis;
+ }
+
+ public int getMovingTimeMillis() {
+ return movingTimeMillis;
+ }
+
+ public float getAvgSpeedMeterPerSecond() {
+ return avgSpeedMeterPerSecond;
+ }
+
+ public float getAvgMovingSpeedMeterPerSecond() {
+ return avgMovingSpeedMeterPerSecond;
+ }
+
+ public float getMaxSpeedMeterPerSecond() {
+ return maxSpeedMeterPerSecond;
+ }
+
+ public float getMinElevationMeter() {
+ return minElevationMeter;
+ }
+
+ public float getMaxElevationMeter() {
+ return maxElevationMeter;
+ }
+
+ public float getElevationGainMeter() {
+ return elevationGainMeter;
+ }
+}
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 0000000..b63daf4
--- /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 0000000..61974d1
--- /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/GBDevice.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDevice.java
new file mode 100644
index 0000000..78dfba1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDevice.java
@@ -0,0 +1,754 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniel Dakhno, Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k, Taavi
+ Eom盲e, Uwe Hermann
+
+ 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.impl;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.BatteryState.UNKNOWN;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+import nodomain.freeyourgadget.gadgetbridge.model.ItemWithDetails;
+
+public class GBDevice implements Parcelable {
+ public static final String ACTION_DEVICE_CHANGED
+ = "nodomain.freeyourgadget.gadgetbridge.gbdevice.action.device_changed";
+ public static final Creator<GBDevice> CREATOR = new Creator<GBDevice>() {
+ @Override
+ public GBDevice createFromParcel(Parcel source) {
+ return new GBDevice(source);
+ }
+
+ @Override
+ public GBDevice[] newArray(int size) {
+ return new GBDevice[size];
+ }
+ };
+ private static final Logger LOG = LoggerFactory.getLogger(GBDevice.class);
+ public static final short RSSI_UNKNOWN = 0;
+ public static final short BATTERY_UNKNOWN = -1;
+ public static final short BATTERY_ICON_DEFAULT = -1;
+ public static final short BATTERY_LABEL_DEFAULT = -1;
+ public static final String EXTRA_DEVICE = "device";
+ public static final String EXTRA_UUID = "extraUUID";
+ public static final String EXTRA_UPDATE_SUBJECT = "EXTRA_UPDATE_SUBJECT";
+ private static final String DEVINFO_HW_VER = "HW: ";
+ private static final String DEVINFO_FW_VER = "FW: ";
+ private static final String DEVINFO_FW2_VER = "FW2: ";
+ private static final String DEVINFO_ADDR = "ADDR: ";
+ private static final String DEVINFO_ADDR2 = "ADDR2: ";
+ public static final String BATTERY_INDEX = "battery_index";
+ private String mName;
+ private String mAlias;
+ private String parentFolder;
+ private final String mAddress;
+ private String mVolatileAddress;
+ private final DeviceType mDeviceType;
+ private String mFirmwareVersion;
+ private String mFirmwareVersion2;
+ private String mModel;
+ private State mState = State.NOT_CONNECTED;
+
+ // multiple battery support: at this point we support up to three batteries
+ private int[] mBatteryLevel = {BATTERY_UNKNOWN, BATTERY_UNKNOWN, BATTERY_UNKNOWN};
+ private float[] mBatteryVoltage = {BATTERY_UNKNOWN, BATTERY_UNKNOWN, BATTERY_UNKNOWN};
+ private BatteryState[] mBatteryState = {UNKNOWN, UNKNOWN, UNKNOWN};
+ private int[] mBatteryIcons = {BATTERY_ICON_DEFAULT, BATTERY_ICON_DEFAULT, BATTERY_ICON_DEFAULT};
+ private int[] mBatteryLabels = {BATTERY_LABEL_DEFAULT, BATTERY_LABEL_DEFAULT, BATTERY_LABEL_DEFAULT};
+
+ private short mRssi = RSSI_UNKNOWN;
+ private String mBusyTask;
+ private List<ItemWithDetails> mDeviceInfos;
+ private HashMap<String, Object> mExtraInfos;
+
+ private int mNotificationIconConnected = R.drawable.ic_notification;
+ private int mNotificationIconDisconnected = R.drawable.ic_notification_disconnected;
+ private int mNotificationIconLowBattery = R.drawable.ic_notification_low_battery;
+
+ public static enum DeviceUpdateSubject {
+ UNKNOWN,
+ NOTHING,
+ CONNECTION_STATE,
+ DEVICE_STATE,
+ }
+
+ public GBDevice(String address, String name, String alias, String parentFolder, DeviceType deviceType) {
+ this(address, null, name, alias, parentFolder, deviceType);
+ }
+
+ public GBDevice(String address, String address2, String name, String alias, String parentFolder, DeviceType deviceType) {
+ mAddress = address;
+ mVolatileAddress = address2;
+ mName = (name != null) ? name : mAddress;
+ mAlias = alias;
+ mDeviceType = deviceType;
+ this.parentFolder = parentFolder;
+ validate();
+ }
+
+ private GBDevice(Parcel in) {
+ mName = in.readString();
+ mAlias = in.readString();
+ parentFolder = in.readString();
+ mAddress = in.readString();
+ mVolatileAddress = in.readString();
+ mDeviceType = DeviceType.values()[in.readInt()];
+ mFirmwareVersion = in.readString();
+ mFirmwareVersion2 = in.readString();
+ mModel = in.readString();
+ mState = State.values()[in.readInt()];
+ mBatteryLevel = in.createIntArray();
+ mBatteryVoltage = in.createFloatArray();
+ mBatteryState = ordinalsToEnums(in.createIntArray());
+ mBatteryIcons = in.createIntArray();
+ mBatteryLabels = in.createIntArray();
+ mRssi = (short) in.readInt();
+ mBusyTask = in.readString();
+ mDeviceInfos = in.readArrayList(getClass().getClassLoader());
+ mExtraInfos = (HashMap) in.readSerializable();
+ mNotificationIconConnected = in.readInt();
+ mNotificationIconDisconnected = in.readInt();
+ mNotificationIconLowBattery = in.readInt();
+
+ validate();
+ }
+
+ public void copyFromDevice(GBDevice device){
+ if(!device.mAddress.equals(mAddress)){
+ throw new RuntimeException("Cannot copy from device with other address");
+ }
+
+ mName = device.mName;
+ mAlias = device.mAlias;
+ parentFolder = device.parentFolder;
+ mVolatileAddress = device.mVolatileAddress;
+ mFirmwareVersion = device.mFirmwareVersion;
+ mFirmwareVersion2 = device.mFirmwareVersion2;
+ mModel = device.mModel;
+ mState = device.mState;
+ mBatteryLevel = device.mBatteryLevel;
+ mBatteryVoltage = device.mBatteryVoltage;
+ mBatteryState = device.mBatteryState;
+ mBatteryIcons = device.mBatteryIcons;
+ mBatteryLabels = device.mBatteryLabels;
+ mRssi = device.mRssi;
+ mBusyTask = device.mBusyTask;
+ mDeviceInfos = device.mDeviceInfos;
+ mExtraInfos = device.mExtraInfos;
+ mNotificationIconConnected = device.mNotificationIconConnected;
+ mNotificationIconDisconnected = device.mNotificationIconDisconnected;
+ mNotificationIconLowBattery = device.mNotificationIconLowBattery;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(mName);
+ dest.writeString(mAlias);
+ dest.writeString(parentFolder);
+ dest.writeString(mAddress);
+ dest.writeString(mVolatileAddress);
+ dest.writeInt(mDeviceType.ordinal());
+ dest.writeString(mFirmwareVersion);
+ dest.writeString(mFirmwareVersion2);
+ dest.writeString(mModel);
+ dest.writeInt(mState.ordinal());
+ dest.writeIntArray(mBatteryLevel);
+ dest.writeFloatArray(mBatteryVoltage);
+ dest.writeIntArray(enumsToOrdinals(mBatteryState));
+ dest.writeIntArray(mBatteryIcons);
+ dest.writeIntArray(mBatteryLabels);
+ dest.writeInt(mRssi);
+ dest.writeString(mBusyTask);
+ dest.writeList(mDeviceInfos);
+ dest.writeSerializable(mExtraInfos);
+ dest.writeInt(mNotificationIconConnected);
+ dest.writeInt(mNotificationIconDisconnected);
+ dest.writeInt(mNotificationIconLowBattery);
+ }
+
+ private void validate() {
+ if (getAddress() == null) {
+ throw new IllegalArgumentException("address must not be null");
+ }
+ }
+
+ private int[] enumsToOrdinals(BatteryState[] arrayEnum) {
+ int[] ordinals = new int[arrayEnum.length];
+ for (int i = 0; i < arrayEnum.length; i++) {
+ ordinals[i] = arrayEnum[i].ordinal();
+ }
+ return ordinals;
+ }
+
+ private BatteryState[] ordinalsToEnums(int[] arrayInt){
+ BatteryState[] enums = new BatteryState[arrayInt.length];
+ for(int i = 0; i<arrayInt.length; i++){
+ enums[i]=BatteryState.values()[arrayInt[i]];
+ }
+ return enums;
+ }
+
+ public String getParentFolder() {
+ return parentFolder;
+ }
+
+ public void setParentFolder(String parentFolder) {
+ this.parentFolder = parentFolder;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public String getAlias() {
+ return mAlias;
+ }
+
+ public String getAliasOrName() {
+ if (mAlias != null && !mAlias.equals("")) {
+ return mAlias;
+ }
+ return mName;
+ }
+
+ public void setName(String name) {
+ if (name == null) {
+ LOG.warn("Ignoring setting of GBDevice name to null for " + this);
+ return;
+ }
+ mName = name;
+ }
+
+ public void setAlias(String alias) {
+ mAlias = alias;
+ }
+
+ public String getAddress() {
+ return mAddress;
+ }
+
+ public String getVolatileAddress() {
+ return mVolatileAddress;
+ }
+
+ public String getFirmwareVersion() {
+ return mFirmwareVersion;
+ }
+ public String getFirmwareVersion2() {
+ return mFirmwareVersion2;
+ }
+
+ public void setFirmwareVersion(String firmwareVersion) {
+ mFirmwareVersion = firmwareVersion;
+ }
+
+ /**
+ * Sets the second firmware version (HR or GPS or other component)
+ * @param firmwareVersion2
+ */
+ public void setFirmwareVersion2(String firmwareVersion2) {
+ mFirmwareVersion2 = firmwareVersion2;
+ }
+
+ public void setVolatileAddress(String volatileAddress) {
+ mVolatileAddress = volatileAddress;
+ }
+
+ /**
+ * Returns the specific model/hardware revision of this device.
+ * This information is not always available, typically only when the device is initialized
+ * @return the model/hardware revision of this device
+ * @see #getType()
+ */
+ @Nullable
+ public String getModel() {
+ return mModel;
+ }
+
+ public void setModel(String model) {
+ mModel = model;
+ }
+
+ public boolean isConnected() {
+ return mState == State.SCANNED || mState.equalsOrHigherThan(State.CONNECTED);
+ }
+
+ public boolean isInitializing() {
+ return mState == State.INITIALIZING;
+ }
+
+ public boolean isInitialized() {
+ return mState == State.SCANNED || mState.equalsOrHigherThan(State.INITIALIZED);
+ }
+
+ public boolean isConnecting() {
+ return mState == State.CONNECTING;
+ }
+
+ public boolean isBusy() {
+ return mBusyTask != null;
+ }
+
+ public String getBusyTask() {
+ return mBusyTask;
+ }
+
+ public int getNotificationIconConnected() {
+ return mNotificationIconConnected;
+ }
+
+ public void setNotificationIconConnected(int mNotificationIconConnected) {
+ this.mNotificationIconConnected = mNotificationIconConnected;
+ }
+
+ public int getNotificationIconDisconnected() {
+ return mNotificationIconDisconnected;
+ }
+
+ public void setNotificationIconDisconnected(int notificationIconDisconnected) {
+ this.mNotificationIconDisconnected = notificationIconDisconnected;
+ }
+
+ public int getNotificationIconLowBattery() {
+ return mNotificationIconLowBattery;
+ }
+
+ public void setNotificationIconLowBattery(int mNotificationIconLowBattery) {
+ this.mNotificationIconLowBattery = mNotificationIconLowBattery;
+ }
+
+ /**
+ * Marks the device as busy, performing a certain task. While busy, no other operations will
+ * be performed on the device.
+ * <p/>
+ * Note that nested busy tasks are not supported, every single call to #setBusyTask()
+ * or unsetBusy() has an effect.
+ *
+ * @param task a textual name of the task to be performed, possibly displayed to the user
+ */
+ public void setBusyTask(String task) {
+ if (task == null) {
+ throw new IllegalArgumentException("busy task must not be null");
+ }
+ if (mBusyTask != null) {
+ LOG.warn("Attempt to mark device as busy with: " + task + ", but is already busy with: " + mBusyTask);
+ }
+ LOG.info("Mark device as busy: " + task);
+ mBusyTask = task;
+ }
+
+ /**
+ * Marks the device as not busy anymore.
+ */
+ public void unsetBusyTask() {
+ if (mBusyTask == null) {
+ LOG.error("Attempt to mark device as not busy anymore, but was not busy before.");
+ return;
+ }
+ LOG.info("Mark device as NOT busy anymore: " + mBusyTask);
+ mBusyTask = null;
+ }
+
+ public State getState() {
+ return mState;
+ }
+
+ public int getStateOrdinal() {
+ return mState.ordinal();
+ }
+
+ public void setState(State state) {
+ mState = state;
+ if (state.ordinal() <= State.CONNECTED.ordinal()) {
+ unsetDynamicState();
+ }
+ }
+
+ private void unsetDynamicState() {
+ setBatteryLevel(BATTERY_UNKNOWN, 0);
+ setBatteryLevel(BATTERY_UNKNOWN, 1);
+ setBatteryLevel(BATTERY_UNKNOWN, 2);
+ setBatteryState(UNKNOWN, 0);
+ setBatteryState(UNKNOWN, 1);
+ setBatteryState(UNKNOWN, 2);
+ setRssi(RSSI_UNKNOWN);
+ resetExtraInfos();
+ if (mBusyTask != null) {
+ unsetBusyTask();
+ }
+ }
+
+ public String getStateString(final Context context) {
+ return getStateString(context, true);
+ }
+
+ /**
+ * for simplicity the user won't see all internal states, just connecting -> connected
+ * instead of connecting->connected->initializing->initialized
+ * Set simple to true to get this behavior.
+ */
+ private String getStateString(Context context, boolean simple) {
+ try{
+ // TODO: not sure if this is really neccessary...
+ if(simple){
+ return context.getString(mState.getSimpleStringId());
+ }
+ return context.getString(mState.getStringId());
+ }catch (Exception e){}
+
+ return context.getString(R.string.unknown_state);
+ }
+
+ /**
+ * Returns the general type of this device. For more detailed information,
+ * soo #getModel()
+ * @return the general type of this device
+ */
+ @NonNull
+ public DeviceType getType() {
+ return mDeviceType;
+ }
+
+ @NonNull
+ public DeviceCoordinator getDeviceCoordinator(){
+ return mDeviceType.getDeviceCoordinator();
+ }
+
+ public void setRssi(short rssi) {
+ if (rssi < 0) {
+ LOG.warn("Illegal RSSI value " + rssi + ", setting to RSSI_UNKNOWN");
+ mRssi = RSSI_UNKNOWN;
+ } else {
+ mRssi = rssi;
+ }
+ }
+
+ /**
+ * Returns the device specific signal strength value, or #RSSI_UNKNOWN
+ */
+ public short getRssi() {
+ return mRssi;
+ }
+
+ // TODO: this doesn't really belong here
+ public void sendDeviceUpdateIntent(Context context) {
+ sendDeviceUpdateIntent(context, DeviceUpdateSubject.UNKNOWN);
+ }
+
+ // TODO: this doesn't really belong here
+ public void sendDeviceUpdateIntent(Context context, DeviceUpdateSubject subject) {
+ Intent deviceUpdateIntent = new Intent(ACTION_DEVICE_CHANGED);
+ deviceUpdateIntent.putExtra(EXTRA_DEVICE, this);
+ deviceUpdateIntent.putExtra(EXTRA_UPDATE_SUBJECT, subject);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(deviceUpdateIntent);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (!(obj instanceof GBDevice)) {
+ return false;
+ }
+ return ((GBDevice) obj).getAddress().equals(this.mAddress);
+ }
+
+ @Override
+ public int hashCode() {
+ return mAddress.hashCode() ^ 37;
+ }
+
+
+ /**
+ * Returns the extra info value if it is set, null otherwise
+ * @param key the extra info key
+ * @return the extra info value if set, null otherwise
+ */
+ public Object getExtraInfo(String key) {
+ if (mExtraInfos == null) {
+ return null;
+ }
+
+ return mExtraInfos.get(key);
+ }
+
+ /**
+ * Sets an extra info value, overwriting the current one, if any
+ * @param key the extra info key
+ * @param info the extra info value
+ */
+ public void setExtraInfo(String key, Object info) {
+ if (mExtraInfos == null) {
+ mExtraInfos = new HashMap<>();
+ }
+
+ mExtraInfos.put(key, info);
+ }
+
+ /**
+ * Deletes all the extra infos
+ */
+ public void resetExtraInfos() {
+ mExtraInfos = null;
+ }
+
+ /**
+ * Ranges from 0-100 (percent), or -1 if unknown
+ *
+ * @return the battery level in range 0-100, or -1 if unknown
+ */
+ public int getBatteryLevel() {
+ return getBatteryLevel(0);
+ }
+
+ public int getBatteryLevel(int index) {
+ return mBatteryLevel[index];
+ }
+
+
+ public void setBatteryLevel(int batteryLevel) {
+ setBatteryLevel(batteryLevel, 0);
+ }
+
+ public void setBatteryLevel(int batteryLevel, int index) {
+ if ((batteryLevel >= 0 && batteryLevel <= 100) || batteryLevel == BATTERY_UNKNOWN) {
+ mBatteryLevel[index] = batteryLevel;
+ } else {
+ LOG.error("Battery level musts be within range 0-100: " + batteryLevel);
+ }
+ }
+
+ public void setBatteryVoltage(float batteryVoltage) {
+ setBatteryVoltage(batteryVoltage, 0);
+ }
+
+
+ public void setBatteryVoltage(float batteryVoltage, int index) {
+ if (batteryVoltage >= 0 || batteryVoltage == BATTERY_UNKNOWN) {
+ mBatteryVoltage[index] = batteryVoltage;
+ } else {
+ LOG.error("Battery voltage must be > 0: " + batteryVoltage);
+ }
+ }
+
+ /**
+ * Voltage greater than zero (unit: Volt), or -1 if unknown
+ *
+ * @return the battery voltage, or -1 if unknown
+ */
+ public float getBatteryVoltage() {
+ return getBatteryVoltage(0);
+ }
+
+ public float getBatteryVoltage(int index) {
+ return mBatteryVoltage[index];
+ }
+
+ public BatteryState getBatteryState() {
+ return getBatteryState(0);
+ }
+
+ public BatteryState getBatteryState(int index) {
+ return mBatteryState[index];
+ }
+
+ public void setBatteryState(BatteryState mBatteryState) {
+ setBatteryState(mBatteryState, 0);
+ }
+
+ public void setBatteryState(BatteryState mBatteryState, int index) {
+ this.mBatteryState[index] = mBatteryState;
+ }
+
+ public int getBatteryIcon(int index) {
+ return this.mBatteryIcons[index];
+ }
+
+ public void setBatteryIcon(int icon, int index) {
+ this.mBatteryIcons[index] = icon;
+ }
+
+ public int getBatteryLabel(int index) {
+ return this.mBatteryLabels[index];
+ }
+
+ public void setBatteryLabel(int label, int index) {
+ this.mBatteryLabels[index] = label;
+ }
+
+ public int getEnabledDisabledIconResource(){
+ return isInitialized() ?
+ getDeviceCoordinator().getDefaultIconResource() :
+ getDeviceCoordinator().getDisabledIconResource();
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "Device " + getName() + ", " + getAddress() + ", " + mState;
+ }
+
+ /**
+ * Returns a shortened form of the device's address, in order to form a
+ * unique name in companion with #getName().
+ */
+ @NonNull
+ public String getShortAddress() {
+ String address = getAddress();
+ if (address != null) {
+ if (address.length() > 5) {
+ return address.substring(address.length() - 5);
+ }
+ return address;
+ }
+ return "";
+ }
+
+ public boolean hasDeviceInfos() {
+ return getDeviceInfos().size() > 0;
+ }
+
+ public ItemWithDetails getDeviceInfo(String name) {
+ for (ItemWithDetails item : getDeviceInfos()) {
+ if (name.equals(item.getName())) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ public List<ItemWithDetails> getDeviceInfos() {
+ List<ItemWithDetails> result = new ArrayList<>();
+ if (mDeviceInfos != null) {
+ result.addAll(mDeviceInfos);
+ }
+ if (mModel != null) {
+ result.add(new GenericItem(DEVINFO_HW_VER, mModel));
+ }
+ if (mFirmwareVersion != null) {
+ result.add(new GenericItem(DEVINFO_FW_VER, mFirmwareVersion));
+ }
+ if (mFirmwareVersion2 != null) {
+ result.add(new GenericItem(DEVINFO_FW2_VER, mFirmwareVersion2));
+ }
+ if (mAddress != null) {
+ result.add(new GenericItem(DEVINFO_ADDR, mAddress));
+ }
+ if (mVolatileAddress != null) {
+ result.add(new GenericItem(DEVINFO_ADDR2, mVolatileAddress));
+ }
+ Collections.sort(result);
+ return result;
+ }
+
+ public void setDeviceInfos(List<ItemWithDetails> deviceInfos) {
+ this.mDeviceInfos = deviceInfos;
+ }
+
+ public void addDeviceInfo(ItemWithDetails info) {
+ if (mDeviceInfos == null) {
+ mDeviceInfos = new ArrayList<>();
+ } else {
+ int index = mDeviceInfos.indexOf(info);
+ if (index >= 0) {
+ mDeviceInfos.set(index, info); // replace item with new one
+ return;
+ }
+ }
+ mDeviceInfos.add(info);
+ }
+
+ public boolean removeDeviceInfo(ItemWithDetails info) {
+ if (mDeviceInfos == null) {
+ return false;
+ }
+ return mDeviceInfos.remove(info);
+ }
+
+ public enum State {
+ NOT_CONNECTED(R.string.not_connected),
+ WAITING_FOR_RECONNECT(R.string.waiting_for_reconnect),
+ WAITING_FOR_SCAN(R.string.device_state_waiting_scan),
+ SCANNED(R.string.state_scanned),
+ CONNECTING(R.string.connecting),
+ CONNECTED(R.string.connected, R.string.connecting),
+ INITIALIZING(R.string.initializing, R.string.connecting),
+ AUTHENTICATION_REQUIRED(R.string.authentication_required), // some kind of pairing is required by the device
+ AUTHENTICATING(R.string.authenticating), // some kind of pairing is requested by the device
+ /**
+ * Means that the device is connected AND all the necessary initialization steps
+ * have been performed. At the very least, this means that basic information like
+ * device name, firmware version, hardware revision (as applicable) is available
+ * in the GBDevice.
+ */
+ INITIALIZED(R.string.initialized, R.string.connected);
+
+
+ private int stringId, simpleStringId;
+
+ State(int stringId, int simpleStringId) {
+ this.stringId = stringId;
+ this.simpleStringId = simpleStringId;
+ }
+
+ State(int stringId) {
+ this(stringId, stringId);
+ }
+
+ public int getStringId() {
+ return stringId;
+ }
+
+ public int getSimpleStringId() {
+ return simpleStringId;
+ }
+
+ public boolean equalsOrHigherThan(State otherState){
+ return compareTo(otherState) >= 0;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceApp.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceApp.java
new file mode 100644
index 0000000..45d66d7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceApp.java
@@ -0,0 +1,163 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniele Gobbetti
+
+ 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.impl;
+
+import android.graphics.Bitmap;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.UUID;
+
+public class GBDeviceApp {
+ private final String name;
+ private final String creator;
+ private final String version;
+ private final UUID uuid;
+ private final Type type;
+ private final boolean inCache;
+ private boolean isOnDevice;
+ private final boolean configurable;
+ private final Bitmap previewImage;
+ private boolean isUpToDate = true;
+
+ public GBDeviceApp(UUID uuid, String name, String creator, String version, Type type, Bitmap previewImage) {
+ this.uuid = uuid;
+ this.name = name;
+ this.creator = creator;
+ this.version = version;
+ this.type = type;
+ this.previewImage = previewImage;
+ //FIXME: do not assume
+ this.inCache = false;
+ this.configurable = false;
+ this.isOnDevice = false;
+ }
+
+ public GBDeviceApp(UUID uuid, String name, String creator, String version, Type type) {
+ this.uuid = uuid;
+ this.name = name;
+ this.creator = creator;
+ this.version = version;
+ this.type = type;
+ this.previewImage = null;
+ //FIXME: do not assume
+ this.inCache = false;
+ this.configurable = false;
+ this.isOnDevice = false;
+ }
+
+ public GBDeviceApp(JSONObject json, boolean configurable, Bitmap previewImage) {
+ UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000000");
+ String name = "";
+ String creator = "";
+ String version = "";
+ Type type = Type.UNKNOWN;
+
+ try {
+ uuid = UUID.fromString(json.getString("uuid"));
+ name = json.getString("name");
+ creator = json.getString("creator");
+ version = json.getString("version");
+ type = Type.valueOf(json.getString("type"));
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+
+ this.uuid = uuid;
+ this.name = name;
+ this.creator = creator;
+ this.version = version;
+ this.type = type;
+ this.previewImage = previewImage;
+ //FIXME: do not assume
+ this.inCache = true;
+ this.configurable = configurable;
+ }
+
+ public void setOnDevice(boolean isOnDevice) {
+ this.isOnDevice = isOnDevice;
+ }
+
+ public boolean isInCache() {
+ return inCache;
+ }
+
+ public boolean isOnDevice() {
+ return isOnDevice;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getCreator() {
+ return creator;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public UUID getUUID() {
+ return uuid;
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ public Bitmap getPreviewImage() {
+ return previewImage;
+ }
+
+ public enum Type {
+ UNKNOWN,
+ WATCHFACE,
+ WATCHFACE_SYSTEM,
+ APP_GENERIC,
+ APP_ACTIVITYTRACKER,
+ APP_SYSTEM,
+ }
+
+ public JSONObject getJSON() {
+ JSONObject json = new JSONObject();
+ try {
+ json.put("uuid", uuid.toString());
+ json.put("name", name);
+ json.put("creator", creator);
+ json.put("version", version);
+ json.put("type", type.name());
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ return json;
+ }
+
+ public boolean isConfigurable() {
+ return configurable;
+ }
+
+ public void setUpToDate(boolean isUpToDate) {
+ this.isUpToDate = isUpToDate;
+ }
+
+ public boolean isUpToDate() {
+ return isUpToDate;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceCandidate.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceCandidate.java
new file mode 100644
index 0000000..4975a5b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceCandidate.java
@@ -0,0 +1,248 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
+ Dakhno, Daniele Gobbetti, Jos茅 Rebelo, Taavi Eom盲e
+
+ 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.impl;
+
+import android.bluetooth.BluetoothDevice;
+import android.os.Parcel;
+import android.os.ParcelUuid;
+import android.os.Parcelable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.UUID;
+
+import androidx.annotation.NonNull;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+
+/**
+ * A device candidate is a Bluetooth device that is not yet managed by
+ * Gadgetbridge. Only if a DeviceCoordinator steps up and confirms to
+ * support this candidate, will the candidate be promoted to a GBDevice.
+ */
+public class GBDeviceCandidate implements Parcelable, Cloneable {
+ private static final Logger LOG = LoggerFactory.getLogger(GBDeviceCandidate.class);
+
+ private BluetoothDevice device;
+ private short rssi;
+ private ParcelUuid[] serviceUuids;
+ // Cached values for device name and bond status, to avoid querying the remote bt device
+ private String deviceName;
+ private Boolean isBonded = null;
+
+ public GBDeviceCandidate(BluetoothDevice device, short rssi, ParcelUuid[] serviceUuids) {
+ this.device = device;
+ this.rssi = rssi;
+ this.serviceUuids = serviceUuids != null ? serviceUuids : new ParcelUuid[0];
+ }
+
+ private GBDeviceCandidate(Parcel in) {
+ device = in.readParcelable(getClass().getClassLoader());
+ if (device == null) {
+ throw new IllegalStateException("Unable to read state from Parcel");
+ }
+ rssi = (short) in.readInt();
+
+ serviceUuids = AndroidUtils.toParcelUuids(in.readParcelableArray(getClass().getClassLoader()));
+
+ deviceName = in.readString();
+ final int isBondedInt = in.readInt();
+ if (isBondedInt != -1) {
+ isBonded = (isBondedInt == 1);
+ }
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeParcelable(device, 0);
+ dest.writeInt(rssi);
+ dest.writeParcelableArray(serviceUuids, 0);
+ dest.writeString(deviceName);
+ if (isBonded == null) {
+ dest.writeInt(-1);
+ } else {
+ dest.writeInt(isBonded ? 1 : 0);
+ }
+ }
+
+ public static final Creator<GBDeviceCandidate> CREATOR = new Creator<GBDeviceCandidate>() {
+ @Override
+ public GBDeviceCandidate createFromParcel(Parcel in) {
+ return new GBDeviceCandidate(in);
+ }
+
+ @Override
+ public GBDeviceCandidate[] newArray(int size) {
+ return new GBDeviceCandidate[size];
+ }
+ };
+
+ public BluetoothDevice getDevice() {
+ return device;
+ }
+
+ public String getMacAddress() {
+ return device != null ? device.getAddress() : GBApplication.getContext().getString(R.string._unknown_);
+ }
+
+ private ParcelUuid[] mergeServiceUuids(ParcelUuid[] serviceUuids, ParcelUuid[] deviceUuids) {
+ Set<ParcelUuid> uuids = new LinkedHashSet<>();
+ if (serviceUuids != null) {
+ uuids.addAll(Arrays.asList(serviceUuids));
+ }
+ if (deviceUuids != null) {
+ uuids.addAll(Arrays.asList(deviceUuids));
+ }
+ return uuids.toArray(new ParcelUuid[0]);
+ }
+
+ public void addUuids(ParcelUuid[] newUuids) {
+ this.serviceUuids = mergeServiceUuids(serviceUuids, newUuids);
+ }
+
+ public void setRssi(short rssi) {
+ this.rssi = rssi;
+ }
+
+ public boolean isBonded() {
+ if (isBonded == null) {
+ try {
+ isBonded = device.getBondState() == BluetoothDevice.BOND_BONDED;
+ } catch (final SecurityException e) {
+ /* This should never happen because we need all the permissions
+ to get to the point where we can even scan, but 'SecurityException' check
+ is added to stop Android Studio errors */
+ LOG.error("SecurityException on getBonded");
+ isBonded = false;
+ }
+ }
+
+ return isBonded;
+ }
+
+ @NonNull
+ public ParcelUuid[] getServiceUuids() {
+ return serviceUuids;
+ }
+
+ public boolean supportsService(UUID aService) {
+ ParcelUuid[] uuids = getServiceUuids();
+ if (uuids == null || uuids.length == 0) {
+ LOG.warn("no cached services available for " + this);
+ return false;
+ }
+
+ for (ParcelUuid uuid : uuids) {
+ if (uuid != null && aService.equals(uuid.getUuid())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public String getName() {
+ if (isNameKnown()) {
+ return deviceName;
+ }
+ return "(unknown)";
+ }
+
+ public void refreshNameIfUnknown() {
+ if (isNameKnown()) {
+ return;
+ }
+
+ try {
+ final Method method = device.getClass().getMethod("getAliasName");
+ deviceName = (String) method.invoke(device);
+ } catch (final NoSuchMethodException ignore) {
+ // ignored
+ } catch (final IllegalAccessException | InvocationTargetException ignore) {
+ LOG.warn("Could not get device alias for {}", device.getAddress());
+ }
+ if (deviceName == null || deviceName.isEmpty()) {
+ try {
+ deviceName = device.getName();
+ } catch (final SecurityException e) {
+ // Should never happen
+ LOG.error("SecurityException on device.getName");
+ }
+ }
+ }
+
+ public boolean isNameKnown() {
+ return deviceName != null && !deviceName.isEmpty();
+ }
+
+ public short getRssi() {
+ return rssi;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ GBDeviceCandidate that = (GBDeviceCandidate) o;
+ return device.getAddress().equals(that.device.getAddress());
+ }
+
+ @Override
+ public int hashCode() {
+ return device.getAddress().hashCode() ^ 37;
+ }
+
+ @Override
+ public String toString() {
+ return getName() + ": " + getMacAddress();
+ }
+
+ @NonNull
+ @Override
+ public GBDeviceCandidate clone() {
+ try {
+ final GBDeviceCandidate clone = (GBDeviceCandidate) super.clone();
+ clone.device = this.device;
+ clone.rssi = this.rssi;
+ clone.serviceUuids = this.serviceUuids;
+ clone.deviceName = this.deviceName;
+ clone.isBonded = this.isBonded;
+ return clone;
+ } catch (final CloneNotSupportedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceFolder.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceFolder.java
new file mode 100644
index 0000000..36c2480
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceFolder.java
@@ -0,0 +1,25 @@
+/* Copyright (C) 2022-2024 Daniel Dakhno
+
+ 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.impl;
+
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+
+public class GBDeviceFolder extends GBDevice{
+ public GBDeviceFolder(String name) {
+ super("", name, null, null, null);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceMusic.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceMusic.java
new file mode 100644
index 0000000..b15ce88
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceMusic.java
@@ -0,0 +1,33 @@
+package nodomain.freeyourgadget.gadgetbridge.impl;
+
+import java.io.Serializable;
+
+public class GBDeviceMusic implements Serializable {
+ private final int id;
+ private final String title;
+ private final String artist;
+ private final String fileName;
+
+ public GBDeviceMusic(int id, String title, String artist, String fileName) {
+ this.id = id;
+ this.title = title;
+ this.artist = artist;
+ this.fileName = fileName;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getArtist() {
+ return artist;
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceMusicPlaylist.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceMusicPlaylist.java
new file mode 100644
index 0000000..a6e2f3d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceMusicPlaylist.java
@@ -0,0 +1,41 @@
+package nodomain.freeyourgadget.gadgetbridge.impl;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+
+public class GBDeviceMusicPlaylist implements Serializable {
+ private final int id;
+ private String name;
+ private ArrayList<Integer> musicIds;
+
+ public GBDeviceMusicPlaylist(int id, String name, ArrayList<Integer> musicIds) {
+ this.id = id;
+ this.name = name;
+ this.musicIds = musicIds;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public ArrayList<Integer> getMusicIds() {
+ return musicIds;
+ }
+
+ public void setMusicIds(ArrayList<Integer> musicIds) {
+ this.musicIds = musicIds;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java
new file mode 100644
index 0000000..b88bd98
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java
@@ -0,0 +1,588 @@
+/* Copyright (C) 2015-2024 Alberto, Andreas B枚hler, Andreas Shimokawa,
+ Arjan Schrijver, Carsten Pfeiffer, criogenic, Daniel Dakhno, Daniele Gobbetti,
+ Davis Mosenkovs, Frank Slezak, Gabriele Monaco, Gordon Williams, ivanovlev,
+ Jos茅 Rebelo, Julien Pivotto, Kasha, mvn23, Petr Van臎k, Roi Greenberg,
+ Sebastian Kranz, Steffen Liebergeld
+
+ 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.impl;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.location.Location;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.ContactsContract;
+
+import java.util.ArrayList;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCameraRemote;
+import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.Contact;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NavigationInfoSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.Reminder;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.WorldClock;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceCommunicationService;
+import nodomain.freeyourgadget.gadgetbridge.util.RtlUtils;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.JavaExtensions.coalesce;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+public class GBDeviceService implements DeviceService {
+ protected final Context mContext;
+ private final GBDevice mDevice;
+ private final Class<? extends Service> mServiceClass;
+ public static final String[] transliterationExtras = new String[]{
+ EXTRA_NOTIFICATION_SENDER,
+ EXTRA_NOTIFICATION_SUBJECT,
+ EXTRA_NOTIFICATION_TITLE,
+ EXTRA_NOTIFICATION_BODY,
+ EXTRA_NOTIFICATION_SOURCENAME,
+ EXTRA_CALL_DISPLAYNAME,
+ EXTRA_CALL_SOURCENAME,
+ EXTRA_MUSIC_ARTIST,
+ EXTRA_MUSIC_ALBUM,
+ EXTRA_MUSIC_TRACK,
+ EXTRA_CALENDAREVENT_TITLE,
+ EXTRA_CALENDAREVENT_DESCRIPTION,
+ EXTRA_CALENDAREVENT_LOCATION,
+ EXTRA_CALENDAREVENT_CALNAME,
+ };
+ private static final Logger LOG = LoggerFactory.getLogger(GBDeviceService.class);
+
+ public GBDeviceService(Context context) {
+ this(context, null);
+ }
+
+ public GBDeviceService(Context context, GBDevice device) {
+ mContext = context;
+ mDevice = device;
+ mServiceClass = DeviceCommunicationService.class;
+ }
+
+ @Override
+ public DeviceService forDevice(final GBDevice device) {
+ return new GBDeviceService(mContext, device);
+ }
+
+ public GBDevice getDevice() {
+ return mDevice;
+ }
+
+ protected Intent createIntent() {
+ return new Intent(mContext, mServiceClass);
+ }
+
+ protected void invokeService(Intent intent) {
+
+ if (RtlUtils.rtlSupport()) {
+ for (String extra : transliterationExtras) {
+ if (intent.hasExtra(extra)) {
+ intent.putExtra(extra, RtlUtils.fixRtl(intent.getStringExtra(extra)));
+ }
+ }
+ }
+
+ if (mDevice != null) {
+ intent.putExtra(GBDevice.EXTRA_DEVICE, mDevice);
+ }
+ try {
+ mContext.startService(intent);
+ } catch (IllegalStateException e) {
+ LOG.error("IllegalStateException during startService ("+intent.getAction()+")");
+ }
+ }
+
+ protected void stopService(Intent intent) {
+ mContext.stopService(intent);
+ }
+
+ @Override
+ public void connect() {
+ connect(false);
+ }
+
+ @Override
+ public void connect(boolean firstTime) {
+ Intent intent = createIntent().setAction(ACTION_CONNECT)
+ .putExtra(EXTRA_CONNECT_FIRST_TIME, firstTime);
+ invokeService(intent);
+ }
+
+ @Override
+ public void disconnect() {
+ Intent intent = createIntent().setAction(ACTION_DISCONNECT);
+ invokeService(intent);
+ }
+
+ @Override
+ public void quit() {
+ Intent intent = createIntent();
+ stopService(intent);
+ }
+
+ @Override
+ public void requestDeviceInfo() {
+ Intent intent = createIntent().setAction(ACTION_REQUEST_DEVICEINFO);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onNotification(NotificationSpec notificationSpec) {
+ String messagePrivacyMode = GBApplication.getPrefs().getString("pref_message_privacy_mode",
+ GBApplication.getContext().getString(R.string.p_message_privacy_mode_off));
+ boolean hideMessageDetails = messagePrivacyMode.equals(GBApplication.getContext().getString(R.string.p_message_privacy_mode_complete));
+ boolean hideMessageBodyOnly = messagePrivacyMode.equals(GBApplication.getContext().getString(R.string.p_message_privacy_mode_bodyonly));
+
+ Intent intent = createIntent().setAction(ACTION_NOTIFICATION)
+ .putExtra(EXTRA_NOTIFICATION_FLAGS, notificationSpec.flags)
+ .putExtra(EXTRA_NOTIFICATION_PHONENUMBER, hideMessageDetails ? null : notificationSpec.phoneNumber)
+ .putExtra(EXTRA_NOTIFICATION_SENDER, hideMessageDetails ? null : coalesce(notificationSpec.sender, getContactDisplayNameByNumber(notificationSpec.phoneNumber)))
+ .putExtra(EXTRA_NOTIFICATION_SUBJECT, hideMessageDetails ? null : notificationSpec.subject)
+ .putExtra(EXTRA_NOTIFICATION_TITLE, hideMessageDetails ? null : notificationSpec.title)
+ .putExtra(EXTRA_NOTIFICATION_BODY, hideMessageDetails || hideMessageBodyOnly ? null : notificationSpec.body)
+ .putExtra(EXTRA_NOTIFICATION_ID, notificationSpec.getId())
+ .putExtra(EXTRA_NOTIFICATION_KEY, notificationSpec.key)
+ .putExtra(EXTRA_NOTIFICATION_TYPE, notificationSpec.type)
+ .putExtra(EXTRA_NOTIFICATION_ACTIONS, notificationSpec.attachedActions)
+ .putExtra(EXTRA_NOTIFICATION_SOURCENAME, notificationSpec.sourceName)
+ .putExtra(EXTRA_NOTIFICATION_PEBBLE_COLOR, notificationSpec.pebbleColor)
+ .putExtra(EXTRA_NOTIFICATION_SOURCEAPPID, notificationSpec.sourceAppId)
+ .putExtra(EXTRA_NOTIFICATION_ICONID, notificationSpec.iconId)
+ .putExtra(NOTIFICATION_PICTURE_PATH, notificationSpec.picturePath)
+ .putExtra(EXTRA_NOTIFICATION_DNDSUPPRESSED, notificationSpec.dndSuppressed);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onDeleteNotification(int id) {
+ Intent intent = createIntent().setAction(ACTION_DELETE_NOTIFICATION)
+ .putExtra(EXTRA_NOTIFICATION_ID, id);
+ invokeService(intent);
+
+ }
+
+ @Override
+ public void onSetTime() {
+ Intent intent = createIntent().setAction(ACTION_SETTIME);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetAlarms(ArrayList<? extends Alarm> alarms) {
+ Intent intent = createIntent().setAction(ACTION_SET_ALARMS)
+ .putExtra(EXTRA_ALARMS, alarms);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetCallState(CallSpec callSpec) {
+ Context context = GBApplication.getContext();
+ String currentPrivacyMode = GBApplication.getPrefs().getString("pref_call_privacy_mode", GBApplication.getContext().getString(R.string.p_call_privacy_mode_off));
+ if (currentPrivacyMode.equals(context.getString(R.string.p_call_privacy_mode_name))) {
+ callSpec.name = callSpec.number;
+ } else if (currentPrivacyMode.equals(context.getString(R.string.p_call_privacy_mode_complete))) {
+ callSpec.number = null;
+ callSpec.name = null;
+ } else if (currentPrivacyMode.equals(context.getString(R.string.p_call_privacy_mode_number))){
+ callSpec.name = coalesce(callSpec.name, getContactDisplayNameByNumber(callSpec.number));
+ if (callSpec.name != null && !callSpec.name.equals(callSpec.number)) {
+ callSpec.number = null;
+ }
+ } else {
+ callSpec.name = coalesce(callSpec.name, getContactDisplayNameByNumber(callSpec.number));
+ }
+
+ Intent intent = createIntent().setAction(ACTION_CALLSTATE)
+ .putExtra(EXTRA_CALL_PHONENUMBER, callSpec.number)
+ .putExtra(EXTRA_CALL_DISPLAYNAME, callSpec.name)
+ .putExtra(EXTRA_CALL_SOURCENAME, callSpec.sourceName)
+ .putExtra(EXTRA_CALL_SOURCEAPPID, callSpec.sourceAppId)
+ .putExtra(EXTRA_CALL_COMMAND, callSpec.command)
+ .putExtra(EXTRA_CALL_DNDSUPPRESSED, callSpec.dndSuppressed);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) {
+ Intent intent = createIntent().setAction(ACTION_SETCANNEDMESSAGES)
+ .putExtra(EXTRA_CANNEDMESSAGES_TYPE, cannedMessagesSpec.type)
+ .putExtra(EXTRA_CANNEDMESSAGES, cannedMessagesSpec.cannedMessages);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetMusicState(MusicStateSpec stateSpec) {
+ Intent intent = createIntent().setAction(ACTION_SETMUSICSTATE)
+ .putExtra(EXTRA_MUSIC_REPEAT, stateSpec.repeat)
+ .putExtra(EXTRA_MUSIC_RATE, stateSpec.playRate)
+ .putExtra(EXTRA_MUSIC_STATE, stateSpec.state)
+ .putExtra(EXTRA_MUSIC_SHUFFLE, stateSpec.shuffle)
+ .putExtra(EXTRA_MUSIC_POSITION, stateSpec.position);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetPhoneVolume(final float volume) {
+ Intent intent = createIntent().setAction(ACTION_SET_PHONE_VOLUME)
+ .putExtra(EXTRA_PHONE_VOLUME, volume);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onChangePhoneSilentMode(int ringerMode) {
+ Intent intent = createIntent().setAction(ACTION_SET_PHONE_SILENT_MODE)
+ .putExtra(EXTRA_PHONE_RINGER_MODE, ringerMode);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetReminders(ArrayList<? extends Reminder> reminders) {
+ Intent intent = createIntent().setAction(ACTION_SET_REMINDERS)
+ .putExtra(EXTRA_REMINDERS, reminders);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetLoyaltyCards(final ArrayList<LoyaltyCard> cards) {
+ final Intent intent = createIntent().setAction(ACTION_SET_LOYALTY_CARDS)
+ .putExtra(EXTRA_LOYALTY_CARDS, cards);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetWorldClocks(ArrayList<? extends WorldClock> clocks) {
+ Intent intent = createIntent().setAction(ACTION_SET_WORLD_CLOCKS)
+ .putExtra(EXTRA_WORLD_CLOCKS, clocks);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetContacts(ArrayList<? extends Contact> contacts) {
+ Intent intent = createIntent().setAction(ACTION_SET_CONTACTS)
+ .putExtra(EXTRA_CONTACTS, contacts);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetMusicInfo(MusicSpec musicSpec) {
+ Intent intent = createIntent().setAction(ACTION_SETMUSICINFO)
+ .putExtra(EXTRA_MUSIC_ARTIST, musicSpec.artist)
+ .putExtra(EXTRA_MUSIC_ALBUM, musicSpec.album)
+ .putExtra(EXTRA_MUSIC_TRACK, musicSpec.track)
+ .putExtra(EXTRA_MUSIC_DURATION, musicSpec.duration)
+ .putExtra(EXTRA_MUSIC_TRACKCOUNT, musicSpec.trackCount)
+ .putExtra(EXTRA_MUSIC_TRACKNR, musicSpec.trackNr);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetNavigationInfo(NavigationInfoSpec navigationInfoSpec) {
+ Intent intent = createIntent().setAction(ACTION_SETNAVIGATIONINFO)
+ .putExtra(EXTRA_NAVIGATION_INSTRUCTION, navigationInfoSpec.instruction)
+ .putExtra(EXTRA_NAVIGATION_NEXT_ACTION, navigationInfoSpec.nextAction)
+ .putExtra(EXTRA_NAVIGATION_DISTANCE_TO_TURN, navigationInfoSpec.distanceToTurn)
+ .putExtra(EXTRA_NAVIGATION_ETA, navigationInfoSpec.ETA);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onInstallApp(Uri uri) {
+ Intent intent = createIntent().setAction(ACTION_INSTALL)
+ .putExtra(EXTRA_URI, uri);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onAppInfoReq() {
+ Intent intent = createIntent().setAction(ACTION_REQUEST_APPINFO);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onAppStart(UUID uuid, boolean start) {
+ Intent intent = createIntent().setAction(ACTION_STARTAPP)
+ .putExtra(EXTRA_APP_UUID, uuid)
+ .putExtra(EXTRA_APP_START, start);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onAppDownload(UUID uuid) {
+ Intent intent = createIntent().setAction(ACTION_DOWNLOADAPP)
+ .putExtra(EXTRA_APP_UUID, uuid);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onAppDelete(UUID uuid) {
+ Intent intent = createIntent().setAction(ACTION_DELETEAPP)
+ .putExtra(EXTRA_APP_UUID, uuid);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onAppConfiguration(UUID uuid, String config, Integer id) {
+ Intent intent = createIntent().setAction(ACTION_APP_CONFIGURE)
+ .putExtra(EXTRA_APP_UUID, uuid)
+ .putExtra(EXTRA_APP_CONFIG, config);
+
+ if (id != null) {
+ intent.putExtra(EXTRA_APP_CONFIG_ID, id);
+ }
+ invokeService(intent);
+ }
+
+ @Override
+ public void onAppReorder(UUID[] uuids) {
+ Intent intent = createIntent().setAction(ACTION_APP_REORDER)
+ .putExtra(EXTRA_APP_UUID, uuids);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onFetchRecordedData(int dataTypes) {
+ Intent intent = createIntent().setAction(ACTION_FETCH_RECORDED_DATA)
+ .putExtra(EXTRA_RECORDED_DATA_TYPES, dataTypes);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onReset(int flags) {
+ Intent intent = createIntent().setAction(ACTION_RESET)
+ .putExtra(EXTRA_RESET_FLAGS, flags);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onHeartRateTest() {
+ Intent intent = createIntent().setAction(ACTION_HEARTRATE_TEST);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onFindDevice(boolean start) {
+ Intent intent = createIntent().setAction(ACTION_FIND_DEVICE)
+ .putExtra(EXTRA_FIND_START, start);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onFindPhone(final boolean start) {
+ Intent intent = createIntent().setAction(ACTION_PHONE_FOUND)
+ .putExtra(EXTRA_FIND_START, start);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetConstantVibration(int intensity) {
+ Intent intent = createIntent().setAction(ACTION_SET_CONSTANT_VIBRATION)
+ .putExtra(EXTRA_VIBRATION_INTENSITY, intensity);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onScreenshotReq() {
+ Intent intent = createIntent().setAction(ACTION_REQUEST_SCREENSHOT);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onEnableRealtimeSteps(boolean enable) {
+ Intent intent = createIntent().setAction(ACTION_ENABLE_REALTIME_STEPS)
+ .putExtra(EXTRA_BOOLEAN_ENABLE, enable);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onEnableHeartRateSleepSupport(boolean enable) {
+ Intent intent = createIntent().setAction(ACTION_ENABLE_HEARTRATE_SLEEP_SUPPORT)
+ .putExtra(EXTRA_BOOLEAN_ENABLE, enable);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetHeartRateMeasurementInterval(int seconds) {
+ Intent intent = createIntent().setAction(ACTION_SET_HEARTRATE_MEASUREMENT_INTERVAL)
+ .putExtra(EXTRA_INTERVAL_SECONDS, seconds);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onEnableRealtimeHeartRateMeasurement(boolean enable) {
+ Intent intent = createIntent().setAction(ACTION_ENABLE_REALTIME_HEARTRATE_MEASUREMENT)
+ .putExtra(EXTRA_BOOLEAN_ENABLE, enable);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onAddCalendarEvent(CalendarEventSpec calendarEventSpec) {
+ Intent intent = createIntent().setAction(ACTION_ADD_CALENDAREVENT)
+ .putExtra(EXTRA_CALENDAREVENT_ID, calendarEventSpec.id)
+ .putExtra(EXTRA_CALENDAREVENT_TYPE, calendarEventSpec.type)
+ .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)
+ .putExtra(EXTRA_CALENDAREVENT_COLOR, calendarEventSpec.color)
+ .putExtra(EXTRA_CALENDAREVENT_LOCATION, calendarEventSpec.location);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onDeleteCalendarEvent(byte type, long id) {
+ Intent intent = createIntent().setAction(ACTION_DELETE_CALENDAREVENT)
+ .putExtra(EXTRA_CALENDAREVENT_TYPE, type)
+ .putExtra(EXTRA_CALENDAREVENT_ID, id);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSendConfiguration(String config) {
+ Intent intent = createIntent().setAction(ACTION_SEND_CONFIGURATION)
+ .putExtra(EXTRA_CONFIG, config);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onReadConfiguration(String config) {
+ Intent intent = createIntent().setAction(ACTION_READ_CONFIGURATION)
+ .putExtra(EXTRA_CONFIG, config);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onTestNewFunction() {
+ Intent intent = createIntent().setAction(ACTION_TEST_NEW_FUNCTION);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSendWeather(ArrayList<WeatherSpec> weatherSpecs) {
+ Intent intent = createIntent().setAction(ACTION_SEND_WEATHER)
+ .putExtra(EXTRA_WEATHER, weatherSpecs);
+ invokeService(intent);
+ }
+
+ /**
+ * Returns contact DisplayName by call number
+ *
+ * @param number contact number
+ * @return contact DisplayName, if found it
+ */
+ private String getContactDisplayNameByNumber(String number) {
+ Uri uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI, Uri.encode(number));
+
+ String name = number;
+
+ if (number == null || number.equals("")) {
+ return name;
+ }
+
+ try (Cursor contactLookup = mContext.getContentResolver().query(uri, null, null, null, null)) {
+ if (contactLookup != null && contactLookup.getCount() > 0) {
+ contactLookup.moveToNext();
+ name = contactLookup.getString(contactLookup.getColumnIndex(ContactsContract.Data.DISPLAY_NAME));
+ }
+ } catch (SecurityException e) {
+ // ignore, just return name below
+ }
+
+ return name;
+ }
+
+ @Override
+ public void onSetFmFrequency(float frequency) {
+ Intent intent = createIntent().setAction(ACTION_SET_FM_FREQUENCY)
+ .putExtra(EXTRA_FM_FREQUENCY, frequency);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetLedColor(int color) {
+ Intent intent = createIntent().setAction(ACTION_SET_LED_COLOR)
+ .putExtra(EXTRA_LED_COLOR, color);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onPowerOff() {
+ Intent intent = createIntent().setAction(ACTION_POWER_OFF);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onSetGpsLocation(Location location) {
+ Intent intent = createIntent().setAction(ACTION_SET_GPS_LOCATION);
+ 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);
+ }
+
+ @Override
+ public void onCameraStatusChange(GBDeviceEventCameraRemote.Event event, String filename) {
+ Intent intent = createIntent().setAction(ACTION_CAMERA_STATUS_CHANGE);
+ intent.putExtra(EXTRA_CAMERA_EVENT, GBDeviceEventCameraRemote.eventToInt(event));
+ if (event == GBDeviceEventCameraRemote.Event.TAKE_PICTURE)
+ intent.putExtra(EXTRA_CAMERA_FILENAME, filename);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onMusicListReq() {
+ Intent intent = createIntent().setAction(ACTION_REQUEST_MUSIC_LIST);
+ invokeService(intent);
+ }
+
+ @Override
+ public void onMusicOperation(int operation, int playlistIndex, String playlistName, ArrayList<Integer> musicIds) {
+ Intent intent = createIntent().setAction(ACTION_REQUEST_MUSIC_OPERATION);
+ intent.putExtra("operation", operation);
+ intent.putExtra("playlistIndex", playlistIndex);
+ intent.putExtra("playlistName", playlistName);
+ intent.putExtra("musicIds", musicIds);
+ invokeService(intent);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBSummaryOfDay.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBSummaryOfDay.java
new file mode 100644
index 0000000..eeb3397
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBSummaryOfDay.java
@@ -0,0 +1,44 @@
+/* Copyright (C) 2015-2024 Carsten Pfeiffer
+
+ 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.impl;
+
+import nodomain.freeyourgadget.gadgetbridge.model.SummaryOfDay;
+
+public class GBSummaryOfDay implements SummaryOfDay {
+ private byte provider;
+ private int steps;
+ private int dayStartWakeupTime;
+ private int dayEndFallAsleepTime;
+
+ public byte getProvider() {
+ return provider;
+ }
+
+ public int getSteps() {
+ return steps;
+ }
+
+ public int getDayStartWakeupTime() {
+ return dayStartWakeupTime;
+ }
+
+ public int getDayEndFallAsleepTime() {
+ return dayEndFallAsleepTime;
+ }
+
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AbstractNotificationPattern.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AbstractNotificationPattern.java
new file mode 100644
index 0000000..56a26bf
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AbstractNotificationPattern.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2023-2024 akasaka / Genjitsu Labs
+
+ 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.model;
+
+import android.content.Context;
+
+public interface AbstractNotificationPattern {
+ String getUserReadableName(Context context);
+ String getValue();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityAmount.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityAmount.java
new file mode 100644
index 0000000..e89909f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityAmount.java
@@ -0,0 +1,95 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Pavel Elagin
+
+ 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.model;
+
+import java.util.Date;
+
+public class ActivityAmount {
+ private final ActivityKind activityKind;
+ private short percent;
+ private long totalSeconds;
+ private long totalSteps;
+ private long totalDistance;
+ private long totalActiveCalories;
+ private Date startDate = null;
+ private Date endDate = null;
+
+ public ActivityAmount(ActivityKind activityKind) {
+ this.activityKind = activityKind;
+ }
+
+ public void addSeconds(long seconds) {
+ totalSeconds += seconds;
+ }
+
+ public void addSteps(long steps) {
+ totalSteps += steps;
+ }
+
+ public void addDistance(long distance) {
+ totalDistance += distance;
+ }
+
+ public void addActiveCalories(long activeCalories) {
+ totalActiveCalories += activeCalories;
+ }
+
+ public long getTotalSeconds() {
+ return totalSeconds;
+ }
+
+ public long getTotalSteps() {
+ return totalSteps;
+ }
+
+ public long getTotalDistance() {
+ return totalDistance;
+ }
+
+ public long getTotalActiveCalories() {
+ return totalActiveCalories;
+ }
+
+ public ActivityKind getActivityKind() {
+ return activityKind;
+ }
+
+ public short getPercent() {
+ return percent;
+ }
+
+ public void setPercent(short percent) {
+ this.percent = percent;
+ }
+
+ public Date getStartDate() {
+ return startDate;
+ }
+
+ public void setStartDate(int seconds) {
+ if(startDate == null)
+ this.startDate = new Date((long)seconds * 1000);
+ }
+
+ public Date getEndDate() {
+ return endDate;
+ }
+
+ public void setEndDate(int seconds) {
+ this.endDate = new Date((long)seconds * 1000);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityAmounts.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityAmounts.java
new file mode 100644
index 0000000..812983e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityAmounts.java
@@ -0,0 +1,45 @@
+/* Copyright (C) 2015-2024 Carsten Pfeiffer
+
+ 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.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ActivityAmounts {
+ private final List<ActivityAmount> amounts = new ArrayList<>(4);
+ private long totalSeconds;
+
+ public void addAmount(ActivityAmount amount) {
+ amounts.add(amount);
+ totalSeconds += amount.getTotalSeconds();
+ }
+
+ public List<ActivityAmount> getAmounts() {
+ return amounts;
+ }
+
+ public long getTotalSeconds() {
+ return totalSeconds;
+ }
+
+ public void calculatePercentages() {
+ for (ActivityAmount amount : amounts) {
+ float fraction = amount.getTotalSeconds() / (float) totalSeconds;
+ amount.setPercent((short) (fraction * 100));
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityKind.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityKind.java
new file mode 100644
index 0000000..89e5f4a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityKind.java
@@ -0,0 +1,360 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Jos茅 Rebelo, Petr Van臎k, Sebastian Krey, Your Name
+
+ 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.model;
+
+import android.content.Context;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.StringRes;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public enum ActivityKind {
+ NOT_MEASURED(-1, R.string.activity_type_not_measured, R.drawable.ic_activity_not_measured),
+ UNKNOWN(0x00000000, R.string.activity_type_unknown),
+ ACTIVITY(0x00000001, R.string.activity_type_activity),
+ LIGHT_SLEEP(0x00000002, R.string.activity_type_light_sleep, R.drawable.ic_activity_sleep),
+ DEEP_SLEEP(0x00000004, R.string.activity_type_deep_sleep, R.drawable.ic_activity_sleep),
+ NOT_WORN(0x00000008, R.string.activity_type_not_worn),
+ RUNNING(0x00000010, R.string.activity_type_running, R.drawable.ic_activity_running),
+ WALKING(0x00000020, R.string.activity_type_walking, R.drawable.ic_activity_walking),
+ SWIMMING(0x00000040, R.string.activity_type_swimming, R.drawable.ic_activity_swimming),
+ CYCLING(0x00000080, R.string.activity_type_biking, R.drawable.ic_activity_biking),
+ TREADMILL(0x00000100, R.string.activity_type_treadmill, R.drawable.ic_activity_threadmill),
+ EXERCISE(0x00000200, R.string.activity_type_exercise, R.drawable.ic_activity_exercise),
+ SWIMMING_OPENWATER(0x00000400, R.string.activity_type_swimming_openwater, R.drawable.ic_activity_swimming),
+ INDOOR_CYCLING(0x00000800, R.string.activity_type_indoor_cycling, R.drawable.ic_activity_bike_trainer),
+ ELLIPTICAL_TRAINER(0x00001000, R.string.activity_type_elliptical_trainer, R.drawable.ic_activity_eliptical),
+ JUMP_ROPING(0x00002000, R.string.activity_type_jump_roping, R.drawable.ic_activity_rope_jump),
+ YOGA(0x00004000, R.string.activity_type_yoga, R.drawable.ic_activity_yoga),
+ SOCCER(0x00008000, R.string.activity_type_soccer, R.drawable.ic_activity_soccer),
+ ROWING_MACHINE(0x00010000, R.string.activity_type_rowing_machine, R.drawable.ic_activity_rowing),
+ CRICKET(0x00020000, R.string.activity_type_cricket, R.drawable.ic_activity_cricket),
+ BASKETBALL(0x00040000, R.string.activity_type_basketball, R.drawable.ic_activity_basketball),
+ PINGPONG(0x00080000, R.string.activity_type_pingpong, R.drawable.ic_activity_pingpong),
+ BADMINTON(0x00100000, R.string.activity_type_badminton, R.drawable.ic_activity_badmington),
+ STRENGTH_TRAINING(0x00200000, R.string.activity_type_strength_training, R.drawable.ic_activity_dumbbell),
+ HIKING(0x00400000, R.string.activity_type_hiking, R.drawable.ic_activity_hiking),
+ CLIMBING(0x00800000, R.string.activity_type_climbing, R.drawable.ic_activity_climbing),
+ REM_SLEEP(0x01000000, R.string.abstract_chart_fragment_kind_rem_sleep, R.drawable.ic_activity_sleep),
+ SLEEP_ANY(0x00000002 | 0x00000004 | 0x01000000 | 0x02000000, R.string.menuitem_sleep, R.drawable.ic_activity_sleep),
+ AWAKE_SLEEP(0x02000000, R.string.abstract_chart_fragment_kind_awake_sleep, R.drawable.ic_activity_sleep),
+
+ // FIXME: Deprecate these - they're just kept around while we do not support reading from the old db
+ VIVOMOVE_HR_TRANSITION(0x00000001 | 0x00000010 | 0x00000020 | 0x00000200 | 0x00000040, R.string.transition),
+
+ // Non-legacy activity kinds after 0x04000000
+ NAVIGATE(0x04000000, R.string.activity_type_navigate, R.drawable.ic_navigation),
+ INDOOR_TRACK(0x04000001, R.string.activity_type_indoor_track),
+ HANDCYCLING(0x04000002, R.string.activity_type_handcycling),
+ E_BIKE(0x04000003, R.string.activity_type_e_bike, R.drawable.ic_activity_biking),
+ BIKE_COMMUTE(0x04000004, R.string.activity_type_bike_commute, R.drawable.ic_activity_biking),
+ HANDCYCLING_INDOOR(0x04000005, R.string.activity_type_handcycling_indoor),
+ TRANSITION(0x04000006, R.string.activity_type_transition),
+ FITNESS_EQUIPMENT(0x04000007, R.string.activity_type_fitness_equipment),
+ STAIR_STEPPER(0x04000008, R.string.activity_type_stair_stepper, R.drawable.ic_activity_stair_stepper),
+ PILATES(0x04000009, R.string.activity_type_pilates, R.drawable.ic_activity_pilates),
+ POOL_SWIM(0x0400000a, R.string.activity_type_pool_swimming, R.drawable.ic_activity_swimming),
+ TENNIS(0x0400000b, R.string.activity_type_tennis),
+ PLATFORM_TENNIS(0x0400000c, R.string.activity_type_platform_tennis),
+ TABLE_TENNIS(0x0400000d, R.string.activity_type_table_tennis, R.drawable.ic_activity_pingpong),
+ AMERICAN_FOOTBALL(0x0400000e, R.string.activity_type_american_football),
+ TRAINING(0x0400000f, R.string.activity_type_training),
+ CARDIO(0x04000010, R.string.activity_type_cardio, R.drawable.ic_heartrate),
+ BREATHWORK(0x04000011, R.string.activity_type_breathwork),
+ INDOOR_WALKING(0x04000012, R.string.activity_type_indoor_walking, R.drawable.ic_activity_walking),
+ XC_CLASSIC_SKI(0x04000013, R.string.activity_type_xc_classic_ski),
+ SKIING(0x04000014, R.string.activity_type_skiing),
+ SNOWBOARDING(0x04000015, R.string.activity_type_snowboarding),
+ ROWING(0x04000016, R.string.activity_type_rowing, R.drawable.ic_activity_rowing),
+ MOUNTAINEERING(0x04000017, R.string.activity_type_mountaineering, R.drawable.ic_activity_climbing),
+ MULTISPORT(0x04000019, R.string.activity_type_multisport),
+ PADDLING(0x0400001a, R.string.activity_type_paddling, R.drawable.ic_activity_rowing),
+ FLYING(0x0400001b, R.string.activity_type_flying),
+ MOTORCYCLING(0x0400001d, R.string.activity_type_motorcycling),
+ BOATING(0x0400001e, R.string.activity_type_boating, R.drawable.ic_activity_boating),
+ DRIVING(0x0400001f, R.string.activity_type_driving),
+ GOLF(0x04000020, R.string.activity_type_golf),
+ HANG_GLIDING(0x04000021, R.string.activity_type_hang_gliding),
+ HUNTING(0x04000023, R.string.activity_type_hunting),
+ FISHING(0x04000024, R.string.activity_type_fishing),
+ INLINE_SKATING(0x04000025, R.string.activity_type_inline_skating),
+ ROCK_CLIMBING(0x04000026, R.string.activity_type_rock_climbing, R.drawable.ic_activity_rock_climbing),
+ CLIMB_INDOOR(0x04000027, R.string.activity_type_climb_indoor),
+ BOULDERING(0x04000028, R.string.activity_type_bouldering),
+ SAIL_RACE(0x0400002a, R.string.activity_type_sail_race, R.drawable.ic_activity_sailing),
+ SAIL_EXPEDITION(0x0400002b, R.string.activity_type_sail_expedition, R.drawable.ic_activity_sailing),
+ ICE_SKATING(0x0400002c, R.string.activity_type_ice_skating, R.drawable.ic_activity_ice_skating),
+ SKY_DIVING(0x0400002d, R.string.activity_type_sky_diving),
+ SNOWSHOE(0x0400002e, R.string.activity_type_snowshoe),
+ SNOWMOBILING(0x0400002f, R.string.activity_type_snowmobiling),
+ STAND_UP_PADDLEBOARDING(0x04000030, R.string.activity_type_stand_up_paddleboarding, R.drawable.ic_activity_sup),
+ SURFING(0x04000031, R.string.activity_type_surfing, R.drawable.ic_activity_surfing),
+ WAKEBOARDING(0x04000032, R.string.activity_type_wakeboarding, R.drawable.ic_activity_wakeboarding),
+ WATER_SKIING(0x04000033, R.string.activity_type_water_skiing, R.drawable.ic_activity_waterskiing),
+ KAYAKING(0x04000034, R.string.activity_type_kayaking, R.drawable.ic_activity_rowing),
+ RAFTING(0x04000035, R.string.activity_type_rafting, R.drawable.ic_activity_rowing),
+ WINDSURFING(0x04000036, R.string.activity_type_windsurfing, R.drawable.ic_activity_windsurfing),
+ KITESURFING(0x04000037, R.string.activity_type_kitesurfing, R.drawable.ic_activity_kitesurfing),
+ TACTICAL(0x04000038, R.string.activity_type_tactical),
+ JUMPMASTER(0x04000039, R.string.activity_type_jumpmaster),
+ BOXING(0x0400003a, R.string.activity_type_boxing),
+ FLOOR_CLIMBING(0x0400003b, R.string.activity_type_floor_climbing),
+ BASEBALL(0x0400003c, R.string.activity_type_baseball),
+ SOFTBALL(0x0400003d, R.string.activity_type_softball),
+ SOFTBALL_SLOW_PITCH(0x0400003e, R.string.activity_type_softball_slow_pitch),
+ SHOOTING(0x0400003f, R.string.activity_type_shooting),
+ AUTO_RACING(0x04000040, R.string.activity_type_auto_racing),
+ WINTER_SPORT(0x04000041, R.string.activity_type_winter_sport),
+ GRINDING(0x04000042, R.string.activity_type_grinding),
+ HEALTH_SNAPSHOT(0x04000043, R.string.activity_type_health_snapshot),
+ MARINE(0x04000044, R.string.activity_type_marine),
+ HIIT(0x04000045, R.string.activity_type_hiit),
+ VIDEO_GAMING(0x04000046, R.string.activity_type_video_gaming, R.drawable.ic_videogame),
+ RACKET(0x04000047, R.string.activity_type_racket),
+ PICKLEBALL(0x04000048, R.string.activity_type_pickleball),
+ PADEL(0x04000049, R.string.activity_type_padel),
+ SQUASH(0x0400004a, R.string.activity_type_squash),
+ RACQUETBALL(0x0400004b, R.string.activity_type_racquetball),
+ PUSH_WALK_SPEED(0x0400004c, R.string.activity_type_push_walk_speed),
+ INDOOR_PUSH_WALK_SPEED(0x0400004d, R.string.activity_type_indoor_push_walk_speed),
+ PUSH_RUN_SPEED(0x0400004e, R.string.activity_type_push_run_speed),
+ INDOOR_PUSH_RUN_SPEED(0x0400004f, R.string.activity_type_indoor_push_run_speed),
+ MEDITATION(0x04000050, R.string.activity_type_meditation),
+ PARA_SPORT(0x04000051, R.string.activity_type_para_sport),
+ DISC_GOLF(0x04000052, R.string.activity_type_disc_golf),
+ ULTIMATE_DISC(0x04000053, R.string.activity_type_ultimate_disc),
+ TEAM_SPORT(0x04000054, R.string.activity_type_team_sport),
+ RUGBY(0x04000055, R.string.activity_type_rugby),
+ HOCKEY(0x04000056, R.string.activity_type_hockey),
+ LACROSSE(0x04000057, R.string.activity_type_lacrosse),
+ VOLLEYBALL(0x04000058, R.string.activity_type_volleyball),
+ WATER_TUBING(0x04000059, R.string.activity_type_water_tubing, R.drawable.ic_activity_watertubing),
+ WAKESURFING(0x0400005a, R.string.activity_type_wakesurfing),
+ MIXED_MARTIAL_ARTS(0x0400005b, R.string.activity_type_mixed_martial_arts), // aka MMA
+ DANCE(0x0400005c, R.string.activity_type_dance),
+ MOUNTAIN_HIKE(0x040000e2, R.string.activity_type_mountain_hike, R.drawable.ic_activity_climbing),
+ CROSS_TRAINER(0x0400005d, R.string.activity_type_cross_trainer),
+ FREE_TRAINING(0x0400005e, R.string.activity_type_free_training, R.drawable.ic_activity_free_training),
+ DYNAMIC_CYCLE(0x0400005f, R.string.activity_type_dynamic_cycle),
+ KICKBOXING(0x04000060, R.string.activity_type_kickboxing),
+ FITNESS_EXERCISES(0x04000061, R.string.activity_type_fitness_exercises),
+ CROSSFIT(0x04000062, R.string.activity_type_crossfit),
+ FUNCTIONAL_TRAINING(0x04000063, R.string.activity_type_functional_training),
+ PHYSICAL_TRAINING(0x04000064, R.string.activity_type_physical_training),
+ TAEKWONDO(0x04000065, R.string.activity_type_taekwondo),
+ TAE_BO(0x04000066, R.string.activity_type_tae_bo),
+ CROSS_COUNTRY_RUNNING(0x04000067, R.string.activity_type_cross_country_running),
+ KARATE(0x04000068, R.string.activity_type_karate),
+ FENCING(0x04000069, R.string.activity_type_fencing),
+ CORE_TRAINING(0x0400006a, R.string.activity_type_core_training),
+ KENDO(0x0400006b, R.string.activity_type_kendo),
+ HORIZONTAL_BAR(0x0400006c, R.string.activity_type_horizontal_bar),
+ PARALLEL_BAR(0x0400006d, R.string.activity_type_parallel_bar),
+ COOLDOWN(0x0400006e, R.string.activity_type_cooldown),
+ CROSS_TRAINING(0x0400006f, R.string.activity_type_cross_training),
+ SIT_UPS(0x04000070, R.string.activity_type_sit_ups),
+ FITNESS_GAMING(0x04000071, R.string.activity_type_fitness_gaming),
+ AEROBIC_EXERCISE(0x04000072, R.string.activity_type_aerobic_exercise),
+ ROLLING(0x04000073, R.string.activity_type_rolling),
+ FLEXIBILITY(0x04000074, R.string.activity_type_flexibility),
+ GYMNASTICS(0x04000075, R.string.activity_type_gymnastics),
+ TRACK_AND_FIELD(0x04000076, R.string.activity_type_track_and_field),
+ PUSH_UPS(0x04000077, R.string.activity_type_push_ups),
+ BATTLE_ROPE(0x04000078, R.string.activity_type_battle_rope),
+ SMITH_MACHINE(0x04000079, R.string.activity_type_smith_machine),
+ PULL_UPS(0x0400007a, R.string.activity_type_pull_ups),
+ PLANK(0x0400007b, R.string.activity_type_plank),
+ JAVELIN(0x0400007c, R.string.activity_type_javelin),
+ LONG_JUMP(0x0400007d, R.string.activity_type_long_jump),
+ HIGH_JUMP(0x0400007e, R.string.activity_type_high_jump),
+ TRAMPOLINE(0x0400007f, R.string.activity_type_trampoline),
+ DUMBBELL(0x04000080, R.string.activity_type_dumbbell),
+ BELLY_DANCE(0x04000081, R.string.activity_type_belly_dance),
+ JAZZ_DANCE(0x04000082, R.string.activity_type_jazz_dance),
+ LATIN_DANCE(0x04000083, R.string.activity_type_latin_dance),
+ BALLET(0x04000084, R.string.activity_type_ballet),
+ STREET_DANCE(0x04000085, R.string.activity_type_street_dance),
+ ZUMBA(0x04000086, R.string.activity_type_zumba),
+ ROLLER_SKATING(0x04000087, R.string.activity_type_roller_skating),
+ MARTIAL_ARTS(0x04000088, R.string.activity_type_martial_arts),
+ TAI_CHI(0x04000089, R.string.activity_type_tai_chi),
+ HULA_HOOPING(0x0400008a, R.string.activity_type_hula_hooping, R.drawable.ic_activity_hula_hoop),
+ DISC_SPORTS(0x0400008b, R.string.activity_type_disc_sports),
+ DARTS(0x0400008c, R.string.activity_type_darts),
+ ARCHERY(0x0400008d, R.string.activity_type_archery, R.drawable.ic_activity_archery),
+ HORSE_RIDING(0x0400008e, R.string.activity_type_horse_riding),
+ KITE_FLYING(0x0400008f, R.string.activity_type_kite_flying),
+ SWING(0x04000090, R.string.activity_type_swing),
+ STAIRS(0x04000091, R.string.activity_type_stairs, R.drawable.ic_activity_stairs),
+ MIND_AND_BODY(0x04000092, R.string.activity_type_mind_and_body),
+ WRESTLING(0x04000093, R.string.activity_type_wrestling),
+ KABADDI(0x04000094, R.string.activity_type_kabaddi),
+ KARTING(0x04000095, R.string.activity_type_karting),
+ BILLIARDS(0x04000096, R.string.activity_type_billiards),
+ BOWLING(0x04000097, R.string.activity_type_bowling, R.drawable.ic_activity_bowling),
+ SHUTTLECOCK(0x04000098, R.string.activity_type_shuttlecock),
+ HANDBALL(0x04000099, R.string.activity_type_handball),
+ DODGEBALL(0x0400009a, R.string.activity_type_dodgeball),
+ AUSTRALIAN_FOOTBALL(0x0400009b, R.string.activity_type_australian_football),
+ LACROSS(0x0400009c, R.string.activity_type_lacross),
+ SHOT(0x0400009d, R.string.activity_type_shot),
+ BEACH_SOCCER(0x0400009e, R.string.activity_type_beach_soccer),
+ BEACH_VOLLEYBALL(0x0400009f, R.string.activity_type_beach_volleyball),
+ GATEBALL(0x040000a0, R.string.activity_type_gateball),
+ SEPAK_TAKRAW(0x040000a1, R.string.activity_type_sepak_takraw),
+ SAILING(0x040000a2, R.string.activity_type_sailing, R.drawable.ic_activity_sailing),
+ JET_SKIING(0x040000a3, R.string.activity_type_jet_skiing),
+ SKATING(0x040000a4, R.string.activity_type_skating),
+ ICE_HOCKEY(0x040000a5, R.string.activity_type_ice_hockey),
+ CURLING(0x040000a6, R.string.activity_type_curling, R.drawable.ic_activity_curling),
+ CROSS_COUNTRY_SKIING(0x040000a8, R.string.activity_type_cross_country_skiing),
+ SNOW_SPORTS(0x040000a9, R.string.activity_type_snow_sports),
+ LUGE(0x040000ab, R.string.activity_type_luge),
+ SKATEBOARDING(0x040000ac, R.string.activity_type_skateboarding),
+ PARACHUTING(0x040000ae, R.string.activity_type_parachuting),
+ PARKOUR(0x040000af, R.string.activity_type_parkour),
+ INDOOR_RUNNING(0x040000b0, R.string.activity_type_indoor_running, R.drawable.ic_activity_indoor_running),
+ OUTDOOR_RUNNING(0x040000b1, R.string.activity_type_outdoor_running, R.drawable.ic_activity_running),
+ OUTDOOR_WALKING(0x040000b2, R.string.activity_type_outdoor_walking, R.drawable.ic_activity_outdoor_walking),
+ OUTDOOR_CYCLING(0x040000b3, R.string.activity_type_outdoor_cycling, R.drawable.ic_activity_biking),
+ AEROBIC_COMBO(0x040000b4, R.string.activity_type_aerobic_combo),
+ AEROBICS(0x040000b5, R.string.activity_type_aerobics),
+ AIR_WALKER(0x040000b6, R.string.activity_type_air_walker),
+ ARTISTIC_SWIMMING(0x040000b7, R.string.activity_type_artistic_swimming),
+ BALLROOM_DANCE(0x040000b8, R.string.activity_type_ballroom_dance),
+ BMX(0x040000b9, R.string.activity_type_bmx),
+ BOARD_GAME(0x040000ba, R.string.activity_type_board_game),
+ BOCCE(0x040000bb, R.string.activity_type_bocce),
+ BREAKING(0x040000bc, R.string.activity_type_breaking),
+ BRIDGE(0x040000bd, R.string.activity_type_bridge),
+ CARDIO_COMBAT(0x040000be, R.string.activity_type_cardio_combat),
+ CHECKERS(0x040000bf, R.string.activity_type_checkers),
+ CHESS(0x040000c0, R.string.activity_type_chess),
+ DRAGON_BOAT(0x040000c1, R.string.activity_type_dragon_boat),
+ ESPORTS(0x040000c2, R.string.activity_type_esports),
+ FINSWIMMING(0x040000c3, R.string.activity_type_finswimming),
+ FLOWRIDING(0x040000c4, R.string.activity_type_flowriding),
+ FOLK_DANCE(0x040000c5, R.string.activity_type_folk_dance),
+ FRISBEE(0x040000c6, R.string.activity_type_frisbee, R.drawable.ic_activity_frisbee),
+ FUTSAL(0x040000c7, R.string.activity_type_futsal),
+ HACKY_SACK(0x040000c8, R.string.activity_type_hacky_sack),
+ HIP_HOP(0x040000c9, R.string.activity_type_hip_hop),
+ HULA_HOOP(0x040000ca, R.string.activity_type_hula_hoop, R.drawable.ic_activity_hula_hoop),
+ INDOOR_FITNESS(0x040000cb, R.string.activity_type_indoor_fitness),
+ INDOOR_ICE_SKATING(0x040000cc, R.string.activity_type_indoor_ice_skating, R.drawable.ic_activity_ice_skating),
+ JAI_ALAI(0x040000cd, R.string.activity_type_jai_alai),
+ JUDO(0x040000ce, R.string.activity_type_judo),
+ JUJITSU(0x040000cf, R.string.activity_type_jujitsu),
+ MASS_GYMNASTICS(0x040000d0, R.string.activity_type_mass_gymnastics),
+ MODERN_DANCE(0x040000d1, R.string.activity_type_modern_dance),
+ MUAY_THAI(0x040000d2, R.string.activity_type_muay_thai),
+ PARALLEL_BARS(0x040000d3, R.string.activity_type_parallel_bars),
+ POLE_DANCE(0x040000d4, R.string.activity_type_pole_dance),
+ RACE_WALKING(0x040000d5, R.string.activity_type_race_walking),
+ SHUFFLEBOARD(0x040000d6, R.string.activity_type_shuffleboard),
+ SNORKELING(0x040000d7, R.string.activity_type_snorkeling, R.drawable.ic_activity_snorkeling),
+ SOMATOSENSORY_GAME(0x040000d8, R.string.activity_type_somatosensory_game),
+ SPINNING(0x040000d9, R.string.activity_type_spinning),
+ SQUARE_DANCE(0x040000da, R.string.activity_type_square_dance),
+ STAIR_CLIMBER(0x040000db, R.string.activity_type_stair_climber),
+ STEPPER(0x040000dc, R.string.activity_type_stepper),
+ STRETCHING(0x040000dd, R.string.activity_type_stretching, R.drawable.ic_activity_stretching),
+ TABLE_FOOTBALL(0x040000de, R.string.activity_type_table_football),
+ TUG_OF_WAR(0x040000df, R.string.activity_type_tug_of_war),
+ WALL_BALL(0x040000e0, R.string.activity_type_wall_ball),
+ WATER_POLO(0x040000e1, R.string.activity_type_water_polo),
+ WEIQI(0x040000e3, R.string.activity_type_weiqi),
+ FREE_SPARRING(0x040000e4, R.string.activity_type_free_sparring),
+ BODY_COMBAT(0x040000e5, R.string.activity_type_body_combat),
+ PLAZA_DANCING(0x040000e6, R.string.activity_type_plaza_dancing),
+ LASER_TAG(0x040000e7, R.string.activity_type_laser_tag),
+ OBSTACLE_RACE(0x040000e8, R.string.activity_type_obstacle_race, R.drawable.ic_activity_obstacle_race),
+ BILLIARD_POOL(0x040000e9, R.string.activity_type_billiard_pool, R.drawable.ic_activity_billiard_pool),
+ CANOEING(0x040000ea, R.string.activity_type_canoeing),
+ WATER_SCOOTER(0x040000eb, R.string.activity_type_water_scooter),
+ BOBSLEIGH(0x040000ec, R.string.activity_type_bobsleigh),
+ SLEDDING(0x040000ed, R.string.activity_type_sledding),
+ BIATHLON(0x040000ee, R.string.activity_type_biathlon),
+ BUNGEE_JUMPING(0x040000ef, R.string.activity_type_bungee_jumping),
+ ORIENTEERING(0x040000f0, R.string.activity_type_orienteering),
+ TREKKING(0x040000f1, R.string.activity_type_trekking, R.drawable.ic_activity_hiking),
+ TRAIL_RUN(0x040000f2, R.string.activity_type_trail_run, R.drawable.ic_activity_trail_run),
+ ;
+
+ private final int code;
+ private final int label;
+ private final int icon;
+
+ ActivityKind(final int code) {
+ this(code, R.string.activity_type_unknown);
+ }
+
+ ActivityKind(final int code, @StringRes final int label) {
+ this(code, label, R.drawable.ic_activity_unknown_small);
+ }
+
+ ActivityKind(final int code, @StringRes final int label, @DrawableRes final int icon) {
+ this.code = code;
+ this.label = label;
+ this.icon = icon;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ @StringRes
+ public int getLabel() {
+ return label;
+ }
+
+ public String getLabel(final Context context) {
+ return context.getString(label);
+ }
+
+ @DrawableRes
+ public int getIcon() {
+ return icon;
+ }
+
+ public static ActivityKind fromCode(final int code) {
+ for (final ActivityKind kind : ActivityKind.values()) {
+ if (kind.code == code) {
+ return kind;
+ }
+ }
+
+ //throw new IllegalArgumentException("Unknown ActivityKind code " + code);
+ return UNKNOWN;
+ }
+
+ public static boolean isSleep(final ActivityKind activityKind) {
+ return activityKind == ActivityKind.SLEEP_ANY
+ || activityKind == ActivityKind.LIGHT_SLEEP
+ || activityKind == ActivityKind.DEEP_SLEEP
+ || activityKind == ActivityKind.REM_SLEEP
+ || activityKind == ActivityKind.AWAKE_SLEEP;
+ }
+
+ public static boolean isPaceActivity(final ActivityKind activityKind) {
+ return activityKind.name().contains("RUN") || activityKind.name().contains("SWIM") ||
+ activityKind.name().contains("TREADMILL") || activityKind.name().contains("WALK");
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityListItem.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityListItem.java
new file mode 100644
index 0000000..0c6c16d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityListItem.java
@@ -0,0 +1,171 @@
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.text.format.DateUtils;
+import android.util.TypedValue;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.text.DecimalFormat;
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FormatUtils;
+
+public class ActivityListItem {
+ private final View rootView;
+ private final TextView timeFromView;
+ private final TextView timeToView;
+ private final TextView activityName;
+ private final TextView activityLabelText;
+ private final TextView stepLabel;
+ private final TextView distanceLabel;
+ private final TextView hrLabel;
+ private final TextView intensityLabel;
+ private final TextView durationLabel;
+ private final TextView dateLabel;
+ private final LinearLayout timeLayout;
+ private final LinearLayout hrLayout;
+ private final LinearLayout stepsLayout;
+ private final LinearLayout distanceLayout;
+ private final LinearLayout intensityLayout;
+ private final RelativeLayout parentLayout;
+ private final ImageView activityIcon;
+ private final ImageView gpsIcon;
+
+ private final int backgroundColor;
+ private final int alternateColor;
+ private final int selectedColor;
+
+ public ActivityListItem(final View itemView) {
+ this.rootView = itemView;
+
+ this.timeFromView = itemView.findViewById(R.id.line_layout_time_from);
+ this.timeToView = itemView.findViewById(R.id.line_layout_time_to);
+ this.activityName = itemView.findViewById(R.id.line_layout_activity_name);
+ this.activityLabelText = itemView.findViewById(R.id.line_layout_activity_label);
+ this.stepLabel = itemView.findViewById(R.id.line_layout_step_label);
+ this.distanceLabel = itemView.findViewById(R.id.line_layout_distance_label);
+ this.hrLabel = itemView.findViewById(R.id.line_layout_hr_label);
+ this.intensityLabel = itemView.findViewById(R.id.line_layout_intensity_label);
+ this.durationLabel = itemView.findViewById(R.id.line_layout_duration_label);
+ this.dateLabel = itemView.findViewById(R.id.line_layout_date_label);
+
+ this.timeLayout = itemView.findViewById(R.id.line_layout_time);
+ this.hrLayout = itemView.findViewById(R.id.line_layout_hr);
+ this.stepsLayout = itemView.findViewById(R.id.line_layout_step);
+ this.distanceLayout = itemView.findViewById(R.id.line_layout_distance);
+ this.intensityLayout = itemView.findViewById(R.id.line_layout_intensity);
+
+ this.parentLayout = itemView.findViewById(R.id.list_item_parent_layout);
+
+ this.activityIcon = itemView.findViewById(R.id.line_layout_activity_icon);
+ this.gpsIcon = itemView.findViewById(R.id.line_layout_gps_icon);
+
+ this.backgroundColor = 0;
+ this.alternateColor = getThemedColor(itemView.getContext(), R.attr.alternate_row_background);
+ this.selectedColor = ContextCompat.getColor(itemView.getContext(), R.color.accent);
+ }
+
+ public void update(@Nullable final Date timeFrom,
+ @Nullable final Date timeTo,
+ final ActivityKind activityKind,
+ @Nullable final String activityLabel,
+ final int steps,
+ final float distance,
+ final int heartRate,
+ final float intensity,
+ final long duration,
+ final boolean hasGps,
+ @Nullable final Date date,
+ final boolean zebraStripe,
+ final boolean selected) {
+ final String activityKindLabel = activityKind.getLabel(activityName.getContext());
+ if (StringUtils.isNotBlank(activityLabel)) {
+ activityLabelText.setText(String.format("%s", activityKindLabel));
+ activityName.setText(String.format("%s", activityLabel));
+ } else {
+ activityLabelText.setVisibility(View.GONE);
+ activityName.setText(String.format("%s", activityKindLabel));
+ }
+ durationLabel.setText(DateTimeUtils.formatDurationHoursMinutes(duration, TimeUnit.MILLISECONDS));
+
+ if (heartRate > 0) {
+ hrLabel.setText(String.valueOf(heartRate));
+ hrLayout.setVisibility(View.VISIBLE);
+ } else {
+ hrLayout.setVisibility(View.GONE);
+ }
+
+ if (intensity >= 0) {
+ final DecimalFormat df = new DecimalFormat("###");
+ intensityLabel.setText(df.format(intensity));
+ intensityLayout.setVisibility(View.VISIBLE);
+ } else {
+ intensityLayout.setVisibility(View.GONE);
+ }
+
+ if (distance > 0) {
+ distanceLabel.setText(FormatUtils.getFormattedDistanceLabel(distance));
+ distanceLayout.setVisibility(View.VISIBLE);
+ } else {
+ distanceLayout.setVisibility(View.GONE);
+ }
+
+ if (steps > 0) {
+ stepLabel.setText(String.valueOf(steps));
+ stepsLayout.setVisibility(View.VISIBLE);
+ } else {
+ stepsLayout.setVisibility(View.GONE);
+ }
+
+ if (date != null) {
+ dateLabel.setText(DateTimeUtils.formatDateTimeRelative(rootView.getContext(), date));
+ dateLabel.setVisibility(View.VISIBLE);
+ } else {
+ dateLabel.setVisibility(View.GONE);
+ }
+
+ if (timeFrom != null && timeTo != null) {
+ timeFromView.setText(DateTimeUtils.formatTime(timeFrom.getHours(), timeFrom.getMinutes()));
+ timeToView.setText(DateTimeUtils.formatTime(timeTo.getHours(), timeTo.getMinutes()));
+ timeLayout.setVisibility(View.VISIBLE);
+ } else {
+ timeLayout.setVisibility(View.GONE);
+ }
+
+ if (hasGps) {
+ gpsIcon.setVisibility(View.VISIBLE);
+ } else {
+ gpsIcon.setVisibility(View.GONE);
+ }
+
+ activityIcon.setImageResource(activityKind.getIcon());
+
+ if (parentLayout != null) {
+ if (selected) {
+ parentLayout.setBackgroundColor(selectedColor);
+ } else {
+ parentLayout.setBackgroundColor(backgroundColor);
+ }
+ }
+ }
+
+ public static int getThemedColor(Context context, int resid) {
+ TypedValue typedValue = new TypedValue();
+ Resources.Theme theme = context.getTheme();
+ theme.resolveAttribute(resid, typedValue, true);
+ return typedValue.data;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java
new file mode 100644
index 0000000..3767e9f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityPoint.java
@@ -0,0 +1,103 @@
+/* Copyright (C) 2017-2024 Carsten Pfeiffer, Daniele Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+import java.util.Date;
+
+import androidx.annotation.Nullable;
+
+// https://www8.garmin.com/xmlschemas/TrackPointExtensionv1.xsd
+/*
+<trkpt lat="54.8591470" lon="-1.5754310">
+ <ele>29.2</ele>
+ <time>2015-07-26T07:43:42Z</time>
+ <extensions>
+ <gpxtpx:TrackPointExtension>
+ <gpxtpx:atemp>11</gpxtpx:atemp>
+ <gpxtpx:hr>92</gpxtpx:hr>
+ <gpxtpx:cad>0</gpxtpx:cad>
+ <gpxtpx:speed>0.5</gpxtpx:speed>
+ </gpxtpx:TrackPointExtension>
+ </extensions>
+ </trkpt>
+*/
+public class ActivityPoint {
+ private Date time;
+ private GPSCoordinate location;
+ private int heartRate;
+ private float speed = -1;
+ private int cadence = -1;
+
+ // e.g. to describe a pause during the activity
+ private @Nullable String description;
+
+ public ActivityPoint() {
+ }
+
+ public ActivityPoint(Date time) {
+ this.time = time;
+ }
+
+ public Date getTime() {
+ return time;
+ }
+
+ public void setTime(Date time) {
+ this.time = time;
+ }
+
+ @Nullable
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(@Nullable String description) {
+ this.description = description;
+ }
+
+ public GPSCoordinate getLocation() {
+ return location;
+ }
+
+ public void setLocation(GPSCoordinate location) {
+ this.location = location;
+ }
+
+ public int getHeartRate() {
+ return heartRate;
+ }
+
+ public void setHeartRate(int heartRate) {
+ this.heartRate = heartRate;
+ }
+
+ public float getSpeed() {
+ return speed;
+ }
+
+ public void setSpeed(float speed) {
+ this.speed = speed;
+ }
+
+ public int getCadence() {
+ return cadence;
+ }
+
+ public void setCadence(final int cadence) {
+ this.cadence = cadence;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySample.java
new file mode 100644
index 0000000..df8e94e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySample.java
@@ -0,0 +1,101 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer
+
+ 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.model;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+
+/**
+ * The all-in-one interface for a sample measured by a device with
+ * one or more sensors.
+ *
+ * Each sample is the result of one or more measurements. The values are set for
+ * a specific point in time (see @{link #getTimestamp()}).
+ *
+ * If the sample relates to a user activity (e.g. sleeping, walking, running, ...)
+ * then the activity is provided through @{link #getKind()}.
+ *
+ * Methods will return @{link #NOT_MEASURED} in case no value is available for this
+ * sample.
+ *
+ * The frequency of samples, i.e. the how many samples are recorded per minute, is not specified
+ * and may vary.
+ */
+public interface ActivitySample extends TimeStamped {
+
+ int NOT_MEASURED = -1;
+
+ /**
+ * Returns the provider of the data.
+ *
+ * @return who created the sample data
+ */
+ SampleProvider<?> getProvider();
+
+ /**
+ * Returns the raw activity kind value as recorded by the SampleProvider
+ */
+ int getRawKind();
+
+ /**
+ * Returns the activity kind value as recorded by the SampleProvider
+ *
+ * @see ActivityKind
+ */
+ ActivityKind getKind();
+
+ /**
+ * Returns the raw intensity value as recorded by the SampleProvider
+ */
+ int getRawIntensity();
+
+ /**
+ * Returns the normalized intensity value between 0 and 1
+ */
+ float getIntensity();
+
+ /**
+ * Returns the number of steps performed during the period of this sample
+ */
+ int getSteps();
+
+ /**
+ * Returns the distance moved during the period of this sample, in cm. -1 if unknown.
+ */
+ int getDistanceCm();
+
+ /**
+ * Returns the calories burned during the period of this sample, in calories. -1 if unknown.
+ */
+ int getActiveCalories();
+
+ /**
+ * Returns the heart rate measured at the corresponding timestamp.
+ * The value is returned in heart beats per minute, in the range from
+ * 0-255, where 255 is an illegal value (e.g. due to a bad measurement)
+ *
+ * @return the heart rate value in beats per minute, or -1 if none
+ */
+ int getHeartRate();
+
+ /**
+ * Sets the heart rate value of this sample. Typically only used in
+ * generic db migration.
+ *
+ * @param value the value in bpm
+ */
+ void setHeartRate(int value);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySession.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySession.java
new file mode 100644
index 0000000..6f379c4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySession.java
@@ -0,0 +1,127 @@
+/* Copyright (C) 2020-2024 Petr Van臎k
+
+ 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.model;
+
+import java.io.Serializable;
+import java.util.Date;
+
+// ActivitySession holds activities detected by the steps/hr/intensity
+// and is used in the Activity List
+public class ActivitySession implements Serializable {
+
+ public static int SESSION_NORMAL = 1;
+ public static int SESSION_SUMMARY = 2;
+ public static int SESSION_ONGOING = 3;
+ public static int SESSION_EMPTY = 4;
+
+ private final Date startTime;
+ private final Date endTime;
+ private final int activeSteps;
+ private final int heartRateAverage;
+ private final float intensity;
+ private final float distance;
+ private final ActivityKind activityKind;
+ // following is related to step session, we hold it here for the listview
+ // it is identified by SESSION_SUMMARY
+ private int sessionCount = 0;
+ private int sessionType = SESSION_NORMAL;
+ private boolean isEmptySummary = false; // in case there is no activity on that day
+ private int totalDaySteps;
+
+
+ public ActivitySession(Date startTime,
+ Date endTime,
+ int steps, int heartRateAverage, float intensity, float distance, ActivityKind activityKind) {
+ this.startTime = startTime;
+ this.endTime = endTime;
+ this.activeSteps = steps;
+ this.heartRateAverage = heartRateAverage;
+ this.intensity = intensity;
+ this.distance = distance;
+ this.activityKind = activityKind;
+ }
+
+ public ActivitySession(){
+ this.startTime = null;
+ this.endTime = null;
+ this.activeSteps=0;
+ this.heartRateAverage = 0;
+ this.intensity = 0;
+ this.distance = 0;
+ this.activityKind = ActivityKind.UNKNOWN;
+ };
+
+ public Date getStartTime() {
+ return startTime;
+ }
+
+ public Date getEndTime() {
+ return endTime;
+ }
+
+ public int getActiveSteps() {
+ return activeSteps;
+ }
+
+ public int getHeartRateAverage() {
+ return heartRateAverage;
+ }
+
+ public ActivityKind getActivityKind() {
+ return activityKind;
+ }
+
+ public float getIntensity() {
+ return intensity;
+ }
+
+ public float getDistance() {
+ return distance;
+ }
+
+ public int getSessionCount() {
+ return sessionCount;
+ }
+
+ public void setSessionCount(int sessionCount) {
+ this.sessionCount = sessionCount;
+ }
+
+ public int getSessionType() {
+ return sessionType;
+ }
+
+ public void setSessionType(int sessionType) {
+ this.sessionType = sessionType;
+ }
+
+ public boolean getIsEmptySummary() {
+ return isEmptySummary;
+ }
+
+ public void setEmptySummary(boolean emptySummary) {
+ this.isEmptySummary = emptySummary;
+ }
+
+ public int getTotalDaySteps() {
+ return totalDaySteps;
+ }
+
+ public void setTotalDaySteps(int totalDaySteps) {
+ this.totalDaySteps = totalDaySteps;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummary.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummary.java
new file mode 100644
index 0000000..17efcb9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummary.java
@@ -0,0 +1,51 @@
+/* Copyright (C) 2017-2024 Carsten Pfeiffer, Jos茅 Rebelo, Petr Van臎k
+
+ 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.model;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * Summarized information about a temporal activity.
+ *
+ * // TODO: split into separate entities?
+ */
+public interface ActivitySummary extends Serializable {
+ Long getId();
+ String getName();
+ Date getStartTime();
+ Date getEndTime();
+
+ int getActivityKind();
+ String getGpxTrack();
+
+ long getDeviceId();
+
+ long getUserId();
+ String getSummaryData();
+ // long getSteps();
+// float getDistanceMeters();
+// float getAscentMeters();
+// float getDescentMeters();
+// float getMinAltitude();
+// float getMaxAltitude();
+// float getCalories();
+//
+// float getMaxSpeed();
+// float getMinSpeed();
+// float getAverageSpeed();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java
new file mode 100644
index 0000000..ef39ef9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java
@@ -0,0 +1,164 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.typeadapters.RuntimeTypeAdapterFactory;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.lang.reflect.Type;
+import java.util.LinkedHashMap;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryProgressEntry;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry;
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryTableRowEntry;
+
+/**
+ * A small wrapper for a JSONObject, with helper methods to add activity summary data in the format
+ * Gadgetbridge expects.
+ */
+public class ActivitySummaryData {
+ private static final Gson GSON = new GsonBuilder()
+ .registerTypeAdapterFactory(RuntimeTypeAdapterFactory
+ .of(ActivitySummaryEntry.class, "type")
+ .registerSubtype(ActivitySummarySimpleEntry.class, null) // no type for backwards compatibility
+ .registerSubtype(ActivitySummaryProgressEntry.class, "progress")
+ .registerSubtype(ActivitySummaryTableRowEntry.class, "tableRow")
+ .recognizeSubtypes()
+ )
+ //.serializeNulls()
+ //.setPrettyPrinting()
+ .create();
+
+ private final LinkedHashMap<String, ActivitySummaryEntry> entries;
+
+ public ActivitySummaryData() {
+ this.entries = new LinkedHashMap<>();
+ }
+
+ public ActivitySummaryData(final LinkedHashMap<String, ActivitySummaryEntry> entries) {
+ this.entries = entries;
+ }
+
+ public void add(final String key, final Number value, final String unit) {
+ add(null, key, value, unit, false);
+ }
+
+ public void add(final String key, final Number value, final String unit, boolean forceDisplay) {
+ add(null, key, value, unit, forceDisplay);
+ }
+
+ public void add(final String group, final String key, final Number value, final String unit) {
+ add(group, key, value, unit, false);
+ }
+
+ public void add(final String group, final String key, final Number value, final String unit, boolean forceDisplay) {
+ if (value.doubleValue() != 0 || forceDisplay) {
+ entries.put(key, new ActivitySummarySimpleEntry(group, value, unit));
+ }
+ }
+
+ public void add(final String key, final String value) {
+ add(null, key, value);
+ }
+
+ public void add(final String group, final String key, final String value) {
+ if (StringUtils.isBlank(key) || StringUtils.isBlank(value)) {
+ return;
+ }
+
+ entries.put(key, new ActivitySummarySimpleEntry(group, value, ActivitySummaryEntries.UNIT_STRING));
+ }
+
+ public void add(final String key, final ActivitySummaryEntry entry) {
+ entries.put(key, entry);
+ }
+
+ public Set<String> getKeys() {
+ return entries.keySet();
+ }
+
+ public ActivitySummaryEntry get(final String key) {
+ return entries.get(key);
+ }
+
+ public boolean has(final String key) {
+ return entries.containsKey(key);
+ }
+
+ public Number getNumber(final String key, final Number defaultValue) {
+ final ActivitySummaryEntry entry = entries.get(key);
+ if (!(entry instanceof ActivitySummarySimpleEntry)) {
+ return defaultValue;
+ }
+ final ActivitySummarySimpleEntry simpleEntry = (ActivitySummarySimpleEntry) entry;
+ final Object value = simpleEntry.getValue();
+ if (!(value instanceof Number)) {
+ return defaultValue;
+ }
+
+ return ((Number) value).doubleValue();
+ }
+
+ public boolean getBoolean(final String key, final boolean defaultValue) {
+ final ActivitySummaryEntry entry = entries.get(key);
+ if (!(entry instanceof ActivitySummarySimpleEntry)) {
+ return defaultValue;
+ }
+ final ActivitySummarySimpleEntry simpleEntry = (ActivitySummarySimpleEntry) entry;
+ final Object value = simpleEntry.getValue();
+ if (value instanceof Boolean) {
+ return (boolean) value;
+ }
+
+ if (!(value instanceof String)) {
+ return defaultValue;
+ }
+
+ return Boolean.parseBoolean((String) value);
+ }
+
+ @Nullable
+ public static ActivitySummaryData fromJson(final String string) {
+ if (StringUtils.isBlank(string)) {
+ return null;
+ }
+
+ final Type type = new TypeToken<LinkedHashMap<String, ActivitySummaryEntry>>(){}.getType();
+ final LinkedHashMap<String, ActivitySummaryEntry> entries = GSON.fromJson(string, type);
+
+ return new ActivitySummaryData(entries);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return toJson();
+ }
+
+ public String toJson() {
+ return GSON.toJson(entries);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryEntries.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryEntries.java
new file mode 100644
index 0000000..59c456c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryEntries.java
@@ -0,0 +1,183 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public class ActivitySummaryEntries {
+ public static final String STATUS = "status";
+ public static final String TYPE = "watchface_dialog_widget_type"; // TODO: change this?
+
+ public static final String TIME_START = "startTime";
+ public static final String TIME_END = "endTime";
+ public static final String ACTIVE_SECONDS = "activeSeconds";
+
+ public static final String ALTITUDE_AVG = "averageAltitude";
+ public static final String ALTITUDE_BASE = "baseAltitude";
+ public static final String ALTITUDE_MAX = "maxAltitude";
+ public static final String ALTITUDE_MIN = "minAltitude";
+
+ public static final String ASCENT_DISTANCE = "ascentDistance";
+ public static final String ASCENT_METERS = "ascentMeters";
+ public static final String ASCENT_SECONDS = "ascentSeconds";
+ public static final String DESCENT_DISTANCE = "descentDistance";
+ public static final String DESCENT_METERS = "descentMeters";
+ public static final String DESCENT_SECONDS = "descentSeconds";
+ public static final String FLAT_DISTANCE = "flatDistance";
+ public static final String FLAT_SECONDS = "flatSeconds";
+
+ public static final String STEP_RATE_SUM = "stepRateSum";
+ public static final String STEP_RATE_AVG = "stepRateAvg";
+ public static final String STEP_RATE_MAX = "stepRateMax";
+ public static final String STEP_LENGTH_AVG = "stepLengthAvg";
+
+ public static final String CADENCE_AVG = "averageCadence";
+ public static final String CADENCE_MAX = "maxCadence";
+ public static final String CADENCE_MIN = "minCadence";
+
+ public static final String SPEED_AVG = "averageSpeed";
+ public static final String SPEED_MAX = "maxSpeed";
+ public static final String SPEED_MIN = "minSpeed";
+
+ public static final String GROUND_CONTACT_TIME_AVG = "groundContactTimeAvg";
+ public static final String IMPACT_AVG = "impactAvg";
+ public static final String IMPACT_MAX = "impactMax";
+ public static final String SWING_ANGLE_AVG = "swingAngleAvg";
+ public static final String FORE_FOOT_LANDINGS = "foreFootLandings";
+ public static final String MID_FOOT_LANDINGS = "midFootLandings";
+ public static final String BACK_FOOT_LANDINGS = "backFootLandings";
+ public static final String EVERSION_ANGLE_AVG = "eversionAngleAvg";
+ public static final String EVERSION_ANGLE_MAX = "eversionAngleMax";
+
+ public static final String DISTANCE_METERS = "distanceMeters";
+ public static final String ELEVATION_GAIN = "elevationGain";
+ public static final String ELEVATION_LOSS = "elevationLoss";
+
+ public static final String HR_AVG = "averageHR";
+ public static final String HR_MAX = "maxHR";
+ public static final String HR_MIN = "minHR";
+ public static final String HR_ZONE_NA = "hrZoneNa";
+ public static final String HR_ZONE_WARM_UP = "hrZoneWarmUp";
+ public static final String HR_ZONE_EASY = "hrZoneEasy";
+ public static final String HR_ZONE_FAT_BURN = "hrZoneFatBurn";
+ public static final String HR_ZONE_AEROBIC = "hrZoneAerobic";
+ public static final String HR_ZONE_ANAEROBIC = "hrZoneAnaerobic";
+ public static final String HR_ZONE_THRESHOLD = "hrZoneThreshold";
+ public static final String HR_ZONE_EXTREME = "hrZoneExtreme";
+ public static final String HR_ZONE_MAXIMUM = "hrZoneMaximum";
+
+ public static final String RESPIRATION_AVG = "average_respiration_rate";
+ public static final String RESPIRATION_MAX = "max_respiration_rate";
+ public static final String RESPIRATION_MIN = "min_respiration_rate";
+ public static final String SPO2_AVG = "menuitem_spo2";
+ public static final String STRESS_AVG = "menuitem_stress";
+ public static final String HRV_SDRR = "hrv_sdrr";
+ public static final String HRV_RMSSD = "hrv_rmssd";
+
+ public static final String LANE_LENGTH = "laneLength";
+ public static final String LAPS = "laps";
+ public static final String LAP_PACE_AVERAGE = "averageLapPace";
+
+ public static final String PACE_AVG_SECONDS_KM = "averageKMPaceSeconds";
+ public static final String PACE_MAX = "maxPace";
+ public static final String PACE_MIN = "minPace";
+ public static final String STEPS = "steps";
+ public static final String STRIDE_AVG = "averageStride";
+ public static final String STRIDE_MAX = "maxStride";
+ public static final String STRIDE_MIN = "minStride";
+ public static final String STRIDE_TOTAL = "totalStride";
+
+ public static final String STROKE_DISTANCE_AVG = "averageStrokeDistance";
+ public static final String STROKE_AVG_PER_SECOND = "averageStrokesPerSecond";
+ public static final String STROKE_RATE_AVG = "avgStrokeRate";
+ public static final String STROKE_RATE_MAX = "maxStrokeRate";
+ public static final String STROKES = "strokes";
+
+ public static final String JUMP_RATE_AVG = "avgJumpRate";
+ public static final String JUMP_RATE_MAX = "maxJumpRate";
+ public static final String JUMPS = "totalJumps";
+
+ public static final String SWIM_STYLE = "swimStyle";
+ public static final String SWOLF_INDEX = "swolfIndex";
+ public static final String SWOLF_AVG = "swolfAvg";
+ public static final String SWOLF_MAX = "swolfMax";
+ public static final String SWOLF_MIN = "swolfMin";
+
+ public static final String CALORIES_BURNT = "caloriesBurnt";
+ public static final String TRAINING_EFFECT_AEROBIC = "aerobicTrainingEffect";
+ public static final String TRAINING_EFFECT_ANAEROBIC = "anaerobicTrainingEffect";
+ public static final String WORKOUT_LOAD = "currentWorkoutLoad";
+ public static final String MAXIMUM_OXYGEN_UPTAKE = "maximumOxygenUptake";
+ public static final String RECOVERY_TIME = "recoveryTime";
+ public static final String ESTIMATED_SWEAT_LOSS = "estimatedSweatLoss";
+ public static final String LACTATE_THRESHOLD_HR = "lactateThresholdHeartRate";
+
+ public static final String CYCLING_POWER_AVERAGE = "cyclingPowerAverage";
+ public static final String CYCLING_POWER_MIN = "cyclingPowerMin";
+ public static final String CYCLING_POWER_MAX = "cyclingPowerMax";
+
+ public static final String SETS = "workoutSets";
+
+ public static final String UNIT_BPM = "bpm";
+ public static final String UNIT_BREATHS_PER_MIN = "breaths_per_min";
+ public static final String UNIT_CM = "cm";
+ public static final String UNIT_UNIX_EPOCH_SECONDS = "unix_epoch_seconds";
+ public static final String UNIT_KCAL = "calories_unit";
+ public static final String UNIT_ML = "ml";
+ public static final String UNIT_LAPS = "laps_unit";
+ public static final String UNIT_KILOMETERS = "km";
+ public static final String UNIT_METERS = "meters";
+ public static final String UNIT_PERCENTAGE = "%";
+ public static final String UNIT_ML_KG_MIN = "ml/kg/min";
+ public static final String UNIT_NONE = "";
+ public static final String UNIT_HOURS = "hours";
+ public static final String UNIT_SECONDS = "seconds";
+ public static final String UNIT_MILLISECONDS = "milliseconds_ms";
+ public static final String UNIT_SECONDS_PER_KM = "seconds_km";
+ public static final String UNIT_SECONDS_PER_M = "seconds_m";
+ public static final String UNIT_METERS_PER_SECOND = "meters_second";
+ public static final String UNIT_KMPH = "km_h";
+ public static final String UNIT_SPM = "spm";
+ public static final String UNIT_STEPS = "steps_unit";
+ public static final String UNIT_STROKES = "strokes_unit";
+ public static final String UNIT_STROKES_PER_MINUTE = "strokes_minute";
+ public static final String UNIT_STROKES_PER_SECOND = "strokes_second";
+ public static final String UNIT_JUMPS = "jumps_unit";
+ public static final String UNIT_JUMPS_PER_MINUTE = "jumps_minute";
+ public static final String UNIT_YARD = "yard";
+ public static final String UNIT_DEGREES = "degrees";
+ public static final String UNIT_STRING = "string";
+ public static final String UNIT_RAW_STRING = "raw_string";
+ public static final String UNIT_KG = "kg";
+
+ public static final String GROUP_PACE = "Pace";
+ public static final String GROUP_ACTIVITY = "Activity";
+ public static final String GROUP_SPEED = "Speed";
+ public static final String GROUP_ELEVATION = "Elevation";
+ public static final String GROUP_HEART_RATE_ZONES = "HeartRateZones";
+ public static final String GROUP_STROKES = "Strokes";
+ public static final String GROUP_JUMPS = "Jumps";
+ public static final String GROUP_SWIMMING = "Swimming";
+ public static final String GROUP_TRAINING_EFFECT = "TrainingEffect";
+ public static final String GROUP_LAPS = "laps";
+ public static final String GROUP_RUNNING_FORM = "RunningForm";
+
+ /**
+ * Used to signal that this activity has a gps track. This is currently used by ActivitySummaryDetail
+ * to display the share and view gpx buttons, even though there's no gpx file.
+ * FIXME: We should have a cleaner way of doing this.
+ */
+ public static final String INTERNAL_HAS_GPS = "internal_hasGps";
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryItems.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryItems.java
new file mode 100644
index 0000000..052df53
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryItems.java
@@ -0,0 +1,62 @@
+/* Copyright (C) 2020-2024 Petr Van臎k
+
+ 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.model;
+
+import android.content.Context;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.adapter.ActivitySummariesAdapter;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ActivitySummaryItems {
+ ActivitySummariesAdapter itemsAdapter;
+ private int current_position = 0;
+
+ public ActivitySummaryItems(Context context, GBDevice device, int activityKindFilter, long dateFromFilter, long dateToFilter, String nameContainsFilter, long deviceFilter, List itemsFilter) {
+
+ this.itemsAdapter = new ActivitySummariesAdapter(context, device, activityKindFilter, dateFromFilter, dateToFilter, nameContainsFilter, deviceFilter, itemsFilter);
+ }
+
+ public BaseActivitySummary getItem(int position) {
+ if (position == 0) return null;
+ current_position = position;
+ return itemsAdapter.getItem(position);
+ }
+
+ public int getPosition(BaseActivitySummary item) {
+ return itemsAdapter.getPosition(item);
+ }
+
+ public BaseActivitySummary getNextItem() {
+ // last one is empty to avoid items behind fab
+ if (current_position + 2 < itemsAdapter.getItemCount()) {
+ current_position += 1;
+ return itemsAdapter.getItem(current_position);
+ }
+ return null;
+ }
+
+ public BaseActivitySummary getPrevItem() {
+ if (current_position - 1 >= 1) { //0 is empty item for summary dashboard
+ current_position -= 1;
+ return itemsAdapter.getItem(current_position);
+ }
+ return null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryJsonSummary.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryJsonSummary.java
new file mode 100644
index 0000000..d8d03d5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryJsonSummary.java
@@ -0,0 +1,217 @@
+/* Copyright (C) 2020-2024 Jos茅 Rebelo, Petr Van臎k, Reiner Herrmann,
+ Sebastian Krey
+
+ 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.model;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.*;
+
+import org.apache.commons.lang3.tuple.Pair;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+
+public class ActivitySummaryJsonSummary {
+ private static final Logger LOG = LoggerFactory.getLogger(ActivitySummaryJsonSummary.class);
+ private Map<String, List<String>> groupData;
+ private ActivitySummaryData summaryData;
+ private Map<String, List<Pair<String, ActivitySummaryEntry>>> summaryGroupedList;
+ private final ActivitySummaryParser summaryParser;
+ private final BaseActivitySummary baseActivitySummary;
+
+ public ActivitySummaryJsonSummary(final ActivitySummaryParser summaryParser, BaseActivitySummary baseActivitySummary) {
+ this.summaryParser = summaryParser;
+ this.baseActivitySummary = baseActivitySummary;
+ }
+
+ private ActivitySummaryData setSummaryData(BaseActivitySummary item, final boolean forDetails) {
+ final ActivitySummaryData summary = ActivitySummaryData.fromJson(getCorrectSummary(item, forDetails));
+ if (summary == null) {
+ return null;
+ }
+
+ //add additionally computed values here
+ if (item.getBaseAltitude() != null && item.getBaseAltitude() != -20000 && !summary.has("baseAltitude")) {
+ summary.add("baseAltitude", item.getBaseAltitude(), UNIT_METERS);
+ }
+
+ if (!summary.has("averageSpeed") && summary.has("distanceMeters") && summary.has("activeSeconds")) {
+ double distance = summary.getNumber("distanceMeters", 0).doubleValue();
+ double duration = summary.getNumber("activeSeconds", 1).doubleValue();
+ summary.add("averageSpeed", distance / duration, UNIT_METERS_PER_SECOND);
+ }
+
+ if (!summary.has(STEP_RATE_AVG) && summary.has(STEPS) && summary.has(ACTIVE_SECONDS)) {
+ double stepcount = summary.getNumber(STEPS, 0).doubleValue();
+ double duration = summary.getNumber(ACTIVE_SECONDS, 1).doubleValue();
+ summary.add(STEP_RATE_AVG, (double)((int)(((stepcount / duration) * 60)+0.5)), UNIT_SPM);
+ }
+
+ return summary;
+ }
+
+ public ActivitySummaryData getSummaryData(final boolean forDetails) {
+ //returns json with summaryData
+ if (summaryData == null) summaryData = setSummaryData(baseActivitySummary, forDetails);
+ return summaryData;
+ }
+
+ private String getCorrectSummary(BaseActivitySummary item, final boolean forDetails) {
+ if (summaryParser == null) {
+ return item.getSummaryData();
+ }
+ try {
+ item = summaryParser.parseBinaryData(item, forDetails);
+ } catch (final Exception e) {
+ LOG.error("Failed to re-parse corrected summary", e);
+ }
+ return item.getSummaryData();
+ }
+
+ public Map<String, List<Pair<String, ActivitySummaryEntry>>> getSummaryGroupedList() {
+ //returns list grouped by activity groups as per createActivitySummaryGroups
+ if (summaryData == null) summaryData = setSummaryData(baseActivitySummary, true);
+ if (summaryGroupedList == null) summaryGroupedList = setSummaryGroupedList(summaryData);
+ return summaryGroupedList;
+ }
+
+ private Map<String, List<Pair<String, ActivitySummaryEntry>>> setSummaryGroupedList(ActivitySummaryData activitySummaryData) {
+ this.groupData = createActivitySummaryGroups(); //structure for grouping activities into groups, when visualizing
+
+ if (activitySummaryData == null) return null;
+ Iterator<String> keys = activitySummaryData.getKeys().iterator();
+
+ final Map<String, List<Pair<String, ActivitySummaryEntry>>> activeGroups = new LinkedHashMap<>();
+ // Initialize activeGroups with the initial expected order and empty arrays
+ for (final String key : this.groupData.keySet()) {
+ activeGroups.put(key, new LinkedList<>());
+ }
+
+ while (keys.hasNext()) {
+ String key = keys.next();
+ if (key.startsWith("internal")) {
+ continue;
+ }
+ ActivitySummaryEntry item = activitySummaryData.get(key);
+ // Use the group if specified in the entry, otherwise fallback to the array below
+ String groupName = item.getGroup() != null ? item.getGroup() : getDefaultGroup(key);
+
+ List<Pair<String, ActivitySummaryEntry>> group = activeGroups.get(groupName);
+ if (group == null) {
+ // This group is not defined in createActivitySummaryGroups - add it to the end
+ group = new LinkedList<>();
+ activeGroups.put(groupName, group);
+ }
+
+ group.add(Pair.of(key, item));
+ }
+
+ // Convert activeGroups to the expected JSONObject
+ // activeGroups is already ordered
+ final Map<String, List<Pair<String, ActivitySummaryEntry>>> grouped = new LinkedHashMap<>();
+ for (final Map.Entry<String, List<Pair<String, ActivitySummaryEntry>>> entry : activeGroups.entrySet()) {
+ if (entry.getValue().isEmpty()) {
+ // empty group
+ continue;
+ }
+ grouped.put(entry.getKey(), entry.getValue());
+ }
+ return grouped;
+ }
+
+ private String getDefaultGroup(final String searchItem) {
+ final String defaultGroup = GROUP_ACTIVITY;
+ if (groupData == null) return defaultGroup;
+ for (final String groupKey : groupData.keySet()) {
+ final List<String> itemList = groupData.get(groupKey);
+ if (itemList == null) {
+ continue;
+ }
+ for (final String itemKey : itemList) {
+ if (itemKey.equals(searchItem)) {
+ return groupKey;
+ }
+ }
+ }
+
+ // NB: Default group must be present in group JSONObject created by createActivitySummaryGroups
+ return defaultGroup;
+ }
+
+ /**
+ * @noinspection ArraysAsListWithZeroOrOneArgument
+ */
+ private static Map<String, List<String>> createActivitySummaryGroups() {
+ return new LinkedHashMap<String, List<String>>() {{
+ // NB: Default group Activity must be present in this definition, otherwise it wouldn't
+ // be shown.
+ put(GROUP_ACTIVITY, Arrays.asList(
+ DISTANCE_METERS, STEPS, STEP_RATE_SUM, ACTIVE_SECONDS, CALORIES_BURNT,
+ STRIDE_TOTAL, HR_AVG, HR_MAX, HR_MIN, STRIDE_AVG, STRIDE_MAX, STRIDE_MIN,
+ STEP_LENGTH_AVG
+ ));
+ put(GROUP_SPEED, Arrays.asList(
+ SPEED_AVG, SPEED_MAX, SPEED_MIN, PACE_AVG_SECONDS_KM, PACE_MIN,
+ PACE_MAX, "averageSpeed2", CADENCE_AVG, CADENCE_MAX, CADENCE_MIN,
+ STEP_RATE_AVG, STEP_RATE_MAX
+ ));
+ put(GROUP_ELEVATION, Arrays.asList(
+ ASCENT_METERS, DESCENT_METERS, ALTITUDE_MAX, ALTITUDE_MIN, ALTITUDE_AVG,
+ ALTITUDE_BASE, ASCENT_SECONDS, DESCENT_SECONDS, FLAT_SECONDS, ASCENT_DISTANCE,
+ DESCENT_DISTANCE, FLAT_DISTANCE, ELEVATION_GAIN, ELEVATION_LOSS
+ ));
+ put(GROUP_STROKES, Arrays.asList(
+ STROKE_DISTANCE_AVG, STROKE_AVG_PER_SECOND, STROKES,
+ STROKE_RATE_AVG, STROKE_RATE_MAX
+ ));
+ put(GROUP_JUMPS, Arrays.asList(
+ JUMPS, JUMP_RATE_AVG, JUMP_RATE_MAX
+ ));
+ put(GROUP_SWIMMING, Arrays.asList(
+ SWOLF_INDEX, SWOLF_AVG, SWOLF_MAX, SWOLF_MIN, SWIM_STYLE
+ ));
+ put(GROUP_TRAINING_EFFECT, Arrays.asList(
+ TRAINING_EFFECT_AEROBIC, TRAINING_EFFECT_ANAEROBIC, WORKOUT_LOAD,
+ MAXIMUM_OXYGEN_UPTAKE, RECOVERY_TIME, LACTATE_THRESHOLD_HR
+ ));
+ put(GROUP_LAPS, Arrays.asList(
+ LAP_PACE_AVERAGE, LAPS, LANE_LENGTH
+ ));
+ put(GROUP_PACE, Arrays.asList(
+ ));
+ put(GROUP_RUNNING_FORM, Arrays.asList(
+ GROUND_CONTACT_TIME_AVG, IMPACT_AVG, IMPACT_MAX, SWING_ANGLE_AVG,
+ FORE_FOOT_LANDINGS, MID_FOOT_LANDINGS, BACK_FOOT_LANDINGS,
+ EVERSION_ANGLE_AVG, EVERSION_ANGLE_MAX
+ ));
+ put(GROUP_HEART_RATE_ZONES, Arrays.asList(
+ HR_ZONE_NA, HR_ZONE_WARM_UP, HR_ZONE_FAT_BURN, HR_ZONE_EASY, HR_ZONE_AEROBIC, HR_ZONE_ANAEROBIC,
+ HR_ZONE_THRESHOLD, HR_ZONE_EXTREME, HR_ZONE_MAXIMUM
+ ));
+ put(SETS, Arrays.asList(
+ ));
+ }};
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryParser.java
new file mode 100644
index 0000000..b007dfb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryParser.java
@@ -0,0 +1,76 @@
+/* Copyright (C) 2020-2024 Andreas Shimokawa
+
+ 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.model;
+
+import java.util.Date;
+import java.util.List;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public interface ActivitySummaryParser {
+ /**
+ * Re-parse an existing {@link BaseActivitySummary}, updating it from the existing binary data.
+ *
+ * @param summary the existing {@link BaseActivitySummary}. It's not guaranteed that it
+ * contains any raw binary data.
+ * @param forDetails whether the parsing is for the details page. If this is false, the parser
+ * should avoid slow operations such as reading and parsing raw files from
+ * storage.
+ * @return the update {@link BaseActivitySummary}
+ */
+ BaseActivitySummary parseBinaryData(BaseActivitySummary summary, final boolean forDetails);
+
+ static BaseActivitySummary findOrCreateBaseActivitySummary(final DaoSession session,
+ final GBDevice gbDevice,
+ final int timestampSeconds) {
+ final Device device = DBHelper.getDevice(gbDevice, session);
+ return findOrCreateBaseActivitySummary(session, device.getId(), timestampSeconds);
+ }
+
+ static BaseActivitySummary findOrCreateBaseActivitySummary(final DaoSession session,
+ final long deviceId,
+ final int timestampSeconds) {
+ final User user = DBHelper.getUser(session);
+ final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao();
+ final QueryBuilder<BaseActivitySummary> qb = summaryDao.queryBuilder();
+ qb.where(BaseActivitySummaryDao.Properties.StartTime.eq(new Date(timestampSeconds * 1000L)));
+ qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(deviceId));
+ qb.where(BaseActivitySummaryDao.Properties.UserId.eq(user.getId()));
+ final List<BaseActivitySummary> summaries = qb.build().list();
+ if (summaries.isEmpty()) {
+ final BaseActivitySummary summary = new BaseActivitySummary();
+ summary.setStartTime(new Date(timestampSeconds * 1000L));
+ summary.setDeviceId(deviceId);
+ summary.setUser(user);
+
+ // These will be set later, once we parse the summary
+ summary.setEndTime(new Date(timestampSeconds * 1000L));
+ summary.setActivityKind(ActivityKind.UNKNOWN.getCode());
+
+ return summary;
+ }
+
+ return summaries.get(0);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java
new file mode 100644
index 0000000..8161339
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java
@@ -0,0 +1,91 @@
+/* Copyright (C) 2017-2024 Carsten Pfeiffer, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+
+public class ActivityTrack {
+ private Date baseTime;
+ private Device device;
+ private User user;
+ private String name;
+ private List<ActivityPoint> currentSegment = new ArrayList<>();
+ private List<List<ActivityPoint>> segments = new ArrayList<List<ActivityPoint>>() {{
+ add(currentSegment);
+ }};
+
+ public void setBaseTime(Date baseTime) {
+ this.baseTime = baseTime;
+ }
+
+ public Device getDevice() {
+ return device;
+ }
+
+ public void setDevice(Device device) {
+ this.device = device;
+ }
+
+ public User getUser() {
+ return user;
+ }
+
+ public void setUser(User user) {
+ this.user = user;
+ }
+
+ /**
+ * Add a track point to the current segment.
+ */
+ public void addTrackPoint(final ActivityPoint point) {
+ currentSegment.add(point);
+ }
+
+ public void addTrackPoints(final Collection<ActivityPoint> points) {
+ currentSegment.addAll(points);
+ }
+
+ public void startNewSegment() {
+ // Only really start a new segment if the current one is not empty
+ if (!currentSegment.isEmpty()) {
+ currentSegment = new ArrayList<>();
+ segments.add(currentSegment);
+ }
+ }
+
+ public List<List<ActivityPoint>> getSegments() {
+ return segments;
+ }
+
+ public Date getBaseTime() {
+ return baseTime;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityUser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityUser.java
new file mode 100644
index 0000000..c3e9e22
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityUser.java
@@ -0,0 +1,213 @@
+/* Copyright (C) 2016-2024 0nse, Andreas Shimokawa, Carsten Pfeiffer,
+ Damien Gaignon, Daniele Gobbetti, Jos茅 Rebelo, Petr Van臎k, Sebastian Kranz
+
+ 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.model;
+
+import java.time.LocalDate;
+import java.time.Period;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+/**
+ * Class holding the common user information needed by most activity trackers
+ */
+public class ActivityUser {
+
+ public static final int GENDER_FEMALE = 0;
+ public static final int GENDER_MALE = 1;
+ public static final int GENDER_OTHER = 2;
+
+ private String activityUserName;
+ private int activityUserGender;
+ private LocalDate activityUserDateOfBirth;
+ private int activityUserHeightCm;
+ private int activityUserWeightKg;
+ private int activityUserSleepDurationGoal;
+ private int activityUserStepsGoal;
+ private int activityUserCaloriesBurntGoal;
+ private int activityUserDistanceGoalMeters;
+ private int activityUserActiveTimeGoalMinutes;
+ private int activityUserStandingTimeGoalHours;
+ private int activityUserStepLengthCm;
+
+ private static final String defaultUserName = "gadgetbridge-user";
+ public static final int defaultUserGender = GENDER_FEMALE;
+ public static final String defaultUserDateOfBirth = "1970-01-01";
+ public static final int defaultUserAge = 0;
+ public static final int defaultUserHeightCm = 175;
+ public static final int defaultUserWeightKg = 70;
+ public static final int defaultUserSleepDurationGoal = 7;
+ public static final int defaultUserStepsGoal = 8000;
+ public static final int defaultUserCaloriesBurntGoal = 350;
+ public static final int defaultUserDistanceGoalMeters = 5000;
+ public static final int defaultUserActiveTimeGoalMinutes = 60;
+ public static final int defaultUserStepLengthCm = 0;
+ public static final int defaultUserGoalWeightKg = 70;
+ public static final int defaultUserGoalStandingTimeHours = 12;
+ public static final int defaultUserFatBurnTimeMinutes = 30;
+
+ public static final String PREF_USER_NAME = "mi_user_alias";
+ public static final String PREF_USER_DATE_OF_BIRTH = "activity_user_date_of_birth";
+ public static final String PREF_USER_GENDER = "activity_user_gender";
+ public static final String PREF_USER_HEIGHT_CM = "activity_user_height_cm";
+ public static final String PREF_USER_WEIGHT_KG = "activity_user_weight_kg";
+ public static final String PREF_USER_SLEEP_DURATION = "activity_user_sleep_duration";
+ public static final String PREF_USER_STEPS_GOAL = "fitness_goal"; // FIXME: for compatibility
+ public static final String PREF_USER_CALORIES_BURNT = "activity_user_calories_burnt";
+ public static final String PREF_USER_DISTANCE_METERS = "activity_user_distance_meters";
+ public static final String PREF_USER_ACTIVETIME_MINUTES = "activity_user_activetime_minutes";
+ public static final String PREF_USER_STEP_LENGTH_CM = "activity_user_step_length_cm";
+ public static final String PREF_USER_GOAL_WEIGHT_KG = "activity_user_goal_weight_kg";
+ public static final String PREF_USER_GOAL_STANDING_TIME_HOURS = "activity_user_goal_standing_time_minutes";
+ public static final String PREF_USER_GOAL_FAT_BURN_TIME_MINUTES = "activity_user_goal_fat_burn_time_minutes";
+
+ public ActivityUser() {
+ fetchPreferences();
+ }
+
+ public String getName() {
+ return activityUserName;
+ }
+
+ public int getWeightKg() {
+ return activityUserWeightKg;
+ }
+
+ /**
+ * @see #GENDER_FEMALE
+ * @see #GENDER_MALE
+ * @see #GENDER_OTHER
+ */
+ public int getGender() {
+ return activityUserGender;
+ }
+
+ public LocalDate getDateOfBirth() {
+ return activityUserDateOfBirth;
+ }
+
+ /**
+ * @return the user defined height or a default value when none is set or the stored
+ * value is 0.
+ */
+
+ public int getHeightCm() {
+ if (activityUserHeightCm < 1) {
+ activityUserHeightCm = defaultUserHeightCm;
+ }
+ return activityUserHeightCm;
+ }
+
+ /**
+ * @return the user defined step length or the calculated default value when none is set or the stored
+ * value is 0.
+ */
+ public int getStepLengthCm() {
+ if (activityUserStepLengthCm < 1) {
+ activityUserStepLengthCm = (int) (getHeightCm() * 0.43);
+ }
+ return activityUserStepLengthCm;
+ }
+
+ /**
+ * @return the user defined sleep duration or the default value when none is set or the stored
+ * value is out of any logical bounds.
+ */
+ public int getSleepDurationGoal() {
+ if (activityUserSleepDurationGoal < 1 || activityUserSleepDurationGoal > 24) {
+ activityUserSleepDurationGoal = defaultUserSleepDurationGoal;
+ }
+ return activityUserSleepDurationGoal;
+ }
+
+ public int getStepsGoal() {
+ if (activityUserStepsGoal < 1) {
+ activityUserStepsGoal = defaultUserStepsGoal;
+ }
+ return activityUserStepsGoal;
+ }
+
+ public int getAge() {
+ return Period.between(getDateOfBirth(), LocalDate.now()).getYears();
+ }
+
+ private void fetchPreferences() {
+ Prefs prefs = GBApplication.getPrefs();
+ activityUserName = prefs.getString(PREF_USER_NAME, defaultUserName);
+ activityUserGender = prefs.getInt(PREF_USER_GENDER, defaultUserGender);
+ activityUserHeightCm = prefs.getInt(PREF_USER_HEIGHT_CM, defaultUserHeightCm);
+ activityUserWeightKg = prefs.getInt(PREF_USER_WEIGHT_KG, defaultUserWeightKg);
+ activityUserDateOfBirth = prefs.getLocalDate(PREF_USER_DATE_OF_BIRTH, defaultUserDateOfBirth);
+ activityUserSleepDurationGoal = prefs.getInt(PREF_USER_SLEEP_DURATION, defaultUserSleepDurationGoal);
+ activityUserStepsGoal = prefs.getInt(PREF_USER_STEPS_GOAL, defaultUserStepsGoal);
+ activityUserCaloriesBurntGoal = prefs.getInt(PREF_USER_CALORIES_BURNT, defaultUserCaloriesBurntGoal);
+ activityUserDistanceGoalMeters = prefs.getInt(PREF_USER_DISTANCE_METERS, defaultUserDistanceGoalMeters);
+ activityUserActiveTimeGoalMinutes = prefs.getInt(PREF_USER_ACTIVETIME_MINUTES, defaultUserActiveTimeGoalMinutes);
+ activityUserStandingTimeGoalHours = prefs.getInt(PREF_USER_GOAL_STANDING_TIME_HOURS, defaultUserGoalStandingTimeHours);
+ activityUserStepLengthCm = prefs.getInt(PREF_USER_STEP_LENGTH_CM, defaultUserStepLengthCm);
+ }
+
+ /**
+ * @deprecated use {@link #getDateOfBirth()}.
+ */
+ @Deprecated
+ public Date getUserBirthday() {
+ final LocalDate dateOfBirth = getDateOfBirth();
+ Calendar cal = DateTimeUtils.getCalendarUTC();
+ cal.set(GregorianCalendar.YEAR, dateOfBirth.getYear());
+ cal.set(GregorianCalendar.MONTH, dateOfBirth.getMonthValue() - 1);
+ cal.set(GregorianCalendar.DAY_OF_MONTH, dateOfBirth.getDayOfMonth());
+ return cal.getTime();
+ }
+
+ public int getCaloriesBurntGoal()
+ {
+ if (activityUserCaloriesBurntGoal < 1) {
+ activityUserCaloriesBurntGoal = defaultUserCaloriesBurntGoal;
+ }
+ return activityUserCaloriesBurntGoal;
+ }
+
+ public int getDistanceGoalMeters()
+ {
+ if (activityUserDistanceGoalMeters < 1) {
+ activityUserDistanceGoalMeters = defaultUserDistanceGoalMeters;
+ }
+ return activityUserDistanceGoalMeters;
+ }
+
+ public int getActiveTimeGoalMinutes()
+ {
+ if (activityUserActiveTimeGoalMinutes < 1) {
+ activityUserActiveTimeGoalMinutes = defaultUserActiveTimeGoalMinutes;
+ }
+ return activityUserActiveTimeGoalMinutes;
+ }
+
+ public int getStandingTimeGoalHours()
+ {
+ if (activityUserStandingTimeGoalHours < 1) {
+ activityUserStandingTimeGoalHours = defaultUserGoalStandingTimeHours;
+ }
+ return activityUserStandingTimeGoalHours;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Alarm.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Alarm.java
new file mode 100644
index 0000000..59c3a59
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Alarm.java
@@ -0,0 +1,64 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Dmitry
+ Markin, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+import java.io.Serializable;
+
+public interface Alarm extends Serializable {
+ /**
+ * The {@link android.os.Bundle} name for transferring parceled alarms.
+ */
+ String EXTRA_ALARM = "alarm";
+
+ byte ALARM_ONCE = 0;
+ byte ALARM_MON = 1;
+ byte ALARM_TUE = 2;
+ byte ALARM_WED = 4;
+ byte ALARM_THU = 8;
+ byte ALARM_FRI = 16;
+ byte ALARM_SAT = 32;
+ byte ALARM_SUN = 64;
+
+ byte ALARM_DAILY = Alarm.ALARM_MON | Alarm.ALARM_TUE | Alarm.ALARM_WED | Alarm.ALARM_THU | Alarm.ALARM_FRI | Alarm.ALARM_SAT | Alarm.ALARM_SUN;
+
+ int getPosition();
+
+ boolean getEnabled();
+
+ boolean getUnused();
+
+ boolean getSmartWakeup();
+
+ Integer getSmartWakeupInterval();
+
+ boolean getSnooze();
+
+ int getRepetition();
+
+ boolean isRepetitive();
+
+ boolean getRepetition(int dow);
+
+ int getHour();
+
+ int getMinute();
+
+ String getTitle();
+
+ String getDescription();
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AppNotificationType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AppNotificationType.java
new file mode 100644
index 0000000..145c491
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AppNotificationType.java
@@ -0,0 +1,197 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, AnthonyDiGirolamo, Daniele
+ Gobbetti, Davis Mosenkovs, foxstidious, Frank Slezak, Kaz Wolfe, Kevin Richter,
+ Lukas Veneziano, Marvin D, Matthieu Baerts, Mave95, Maxim Baz, michaelneu,
+ musover, NotAFIle, Tomas Radej, w2q
+
+ 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.model;
+
+import java.util.HashMap;
+
+public class AppNotificationType extends HashMap<String, NotificationType> {
+
+ private static AppNotificationType _instance;
+
+ public static AppNotificationType getInstance() {
+ if (_instance == null) {
+ return (_instance = new AppNotificationType());
+ }
+
+ return _instance;
+ }
+
+ private AppNotificationType() {
+ // Generic Email
+ put("com.fsck.k9", NotificationType.GENERIC_EMAIL);
+ put("com.fsck.k9.material", NotificationType.GENERIC_EMAIL);
+ put("com.imaeses.squeaky", NotificationType.GENERIC_EMAIL);
+ put("com.android.email", NotificationType.GENERIC_EMAIL);
+ put("ch.protonmail.android", NotificationType.GENERIC_EMAIL);
+ put("security.pEp", NotificationType.GENERIC_EMAIL);
+ put("eu.faircode.email", NotificationType.GENERIC_EMAIL);
+
+ // Generic SMS
+ put("com.moez.QKSMS", NotificationType.GENERIC_SMS);
+ put("com.android.mms", NotificationType.GENERIC_SMS);
+ put("com.android.messaging", NotificationType.GENERIC_SMS);
+ put("com.sonyericsson.conversations", NotificationType.GENERIC_SMS);
+ put("org.smssecure.smssecure", NotificationType.GENERIC_SMS);
+
+ // Generic Calendar
+ put("com.android.calendar", NotificationType.GENERIC_CALENDAR);
+ put("mikado.bizcalpro", NotificationType.BUSINESS_CALENDAR);
+
+ // Google
+ put("com.google.android.gm", NotificationType.GMAIL);
+ put("com.google.android.apps.inbox", NotificationType.GOOGLE_INBOX);
+ put("com.google.android.calendar", NotificationType.GENERIC_CALENDAR);
+ put("com.google.android.apps.messaging", NotificationType.GOOGLE_MESSENGER);
+ put("com.google.android.talk", NotificationType.GOOGLE_HANGOUTS);
+ put("com.google.android.apps.maps", NotificationType.GOOGLE_MAPS);
+ put("com.google.android.apps.photos", NotificationType.GOOGLE_PHOTOS);
+ put("com.google.android.apps.googlevoice", NotificationType.GENERIC_SMS);
+
+ // Conversations
+ put("eu.siacs.conversations", NotificationType.CONVERSATIONS);
+ put("de.pixart.messenger", NotificationType.CONVERSATIONS);
+
+ // Riot, Element, ElementX
+ put("im.vector.alpha", NotificationType.RIOT);
+ put("im.vector.app", NotificationType.ELEMENT);
+ put("io.element.android.x", NotificationType.ELEMENTX);
+
+ // Signal
+ put("org.thoughtcrime.securesms", NotificationType.SIGNAL);
+ put("im.molly.app", NotificationType.MOLLY);
+ put("im.molly.app.unifiedpush", NotificationType.MOLLY);
+
+ // Wire
+ put("com.wire", NotificationType.WIRE);
+
+ // Telegram
+ put("org.telegram.messenger", NotificationType.TELEGRAM);
+ put("org.telegram.messenger.beta", NotificationType.TELEGRAM);
+ put("org.telegram.messenger.web", NotificationType.TELEGRAM);
+ put("org.telegram.plus", NotificationType.TELEGRAM); // "Plus Messenger"
+ put("org.thunderdog.challegram", NotificationType.TELEGRAM);
+ put("nekox.messenger", NotificationType.TELEGRAM);
+
+ // Threema
+ put("ch.threema.app", NotificationType.THREEMA);
+
+ // Kontalk
+ put("org.kontalk", NotificationType.KONTALK);
+
+ // Antox
+ put("chat.tox.antox", NotificationType.ANTOX);
+
+ // Twitter
+ put("org.mariotaku.twidere", NotificationType.TWITTER);
+ put("com.twitter.android", NotificationType.TWITTER);
+ put("org.andstatus.app", NotificationType.TWITTER);
+ put("org.mustard.android", NotificationType.TWITTER);
+
+ // Facebook
+ put("me.zeeroooo.materialfb", NotificationType.FACEBOOK);
+ put("it.rignanese.leo.slimfacebook", NotificationType.FACEBOOK);
+ put("me.jakelane.wrapperforfacebook", NotificationType.FACEBOOK);
+ put("com.facebook.katana", NotificationType.FACEBOOK);
+ put("org.indywidualni.fblite", NotificationType.FACEBOOK);
+
+ // Facebook Messenger
+ put("com.facebook.orca", NotificationType.FACEBOOK_MESSENGER);
+ put("com.facebook.mlite", NotificationType.FACEBOOK_MESSENGER);
+
+ // WhatsApp
+ put("com.whatsapp", NotificationType.WHATSAPP);
+
+ // HipChat
+ put("com.hipchat", NotificationType.HIPCHAT);
+
+ // Skype
+ put("com.skype.raider", NotificationType.SKYPE);
+
+ // Skype for business
+ put("com.microsoft.office.lync15", NotificationType.SKYPE);
+
+ // Mailbox
+ put("com.mailboxapp", NotificationType.MAILBOX);
+
+ // Snapchat
+ put("com.snapchat.android", NotificationType.SNAPCHAT);
+
+ // WeChat
+ put("com.tencent.mm", NotificationType.WECHAT);
+
+ // Viber
+ put("com.viber.voip", NotificationType.VIBER);
+
+ // Instagram
+ put("com.instagram.android", NotificationType.INSTAGRAM);
+
+ // Kik
+ put("kik.android", NotificationType.KIK);
+
+ // Line
+ put("jp.naver.line.android", NotificationType.LINE);
+
+ // BBM
+ put("com.bbm", NotificationType.BBM);
+
+ // Microsoft Outlook
+ put("com.microsoft.office.outlook", NotificationType.OUTLOOK);
+
+ // Business Calendar
+ put("com.appgenix.bizcal", NotificationType.BUSINESS_CALENDAR);
+
+ // Yahoo Mail
+ put("com.yahoo.mobile.client.android.mail", NotificationType.YAHOO_MAIL);
+
+ // Kakao Talk
+ put("com.kakao.talk", NotificationType.KAKAO_TALK);
+
+ // Amazon
+ put("com.amazon.mshop.android.shopping", NotificationType.AMAZON);
+
+ // LinkedIn
+ put("com.linkedin.android", NotificationType.LINKEDIN);
+
+ // Slack
+ put("com.slack", NotificationType.SLACK);
+
+ // Transit
+ put("com.thetransitapp.droid", NotificationType.TRANSIT);
+
+ // Etar
+ put("ws.xsoh.etar", NotificationType.GENERIC_CALENDAR);
+
+ // Discord
+ put("com.discord", NotificationType.DISCORD);
+
+ // COL Reminder
+ put("com.colapps.reminder", NotificationType.COL_REMINDER);
+
+ //Deltachat
+ put("com.b44t.messenger", NotificationType.DELTACHAT);
+
+ // KDE Itinerary
+ put("org.kde.itinerary", NotificationType.TRANSIT);
+
+ // KTrip
+ put("org.kde.ktrip", NotificationType.TRANSIT);
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BatteryConfig.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BatteryConfig.java
new file mode 100644
index 0000000..f89406f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BatteryConfig.java
@@ -0,0 +1,89 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo, Petr Van臎k
+
+ 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.model;
+
+import java.util.Objects;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class BatteryConfig {
+ private final int batteryIndex;
+ private final int batteryIcon;
+ private final int batteryLabel;
+ private final int defaultLowThreshold;
+ private final int defaultFullThreshold;
+
+ public BatteryConfig(int batteryIndex) {
+ this(batteryIndex, GBDevice.BATTERY_ICON_DEFAULT, GBDevice.BATTERY_LABEL_DEFAULT);
+ }
+
+ public BatteryConfig(int batteryIndex, int batteryIcon, int batteryLabel) {
+ this(batteryIndex, batteryIcon, batteryLabel, 10, 100);
+ }
+
+ public BatteryConfig(final int batteryIndex, final int batteryIcon, final int batteryLabel, final int defaultLowThreshold, final int defaultFullThreshold) {
+ this.batteryIndex = batteryIndex;
+ this.batteryIcon = batteryIcon;
+ this.batteryLabel = batteryLabel;
+ this.defaultLowThreshold = defaultLowThreshold;
+ this.defaultFullThreshold = defaultFullThreshold;
+ }
+
+ public int getBatteryIndex() {
+ return batteryIndex;
+ }
+
+ public int getBatteryIcon() {
+ return batteryIcon;
+ }
+
+ public int getBatteryLabel() {
+ return batteryLabel;
+ }
+
+ public int getDefaultLowThreshold() {
+ return defaultLowThreshold;
+ }
+
+ public int getDefaultFullThreshold() {
+ return defaultFullThreshold;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof BatteryConfig)) return false;
+ BatteryConfig that = (BatteryConfig) o;
+ return getBatteryIndex() == that.getBatteryIndex() &&
+ getBatteryIcon() == that.getBatteryIcon() &&
+ getBatteryLabel() == that.getBatteryLabel() &&
+ getDefaultLowThreshold() == that.getDefaultLowThreshold() &&
+ getDefaultFullThreshold() == that.getDefaultFullThreshold();
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ getBatteryIndex(),
+ getBatteryIcon(),
+ getBatteryLabel(),
+ getDefaultLowThreshold(),
+ getDefaultFullThreshold()
+ );
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BatteryState.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BatteryState.java
new file mode 100644
index 0000000..d54c66c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BatteryState.java
@@ -0,0 +1,27 @@
+/* Copyright (C) 2015-2024 Daniele Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public enum BatteryState {
+ UNKNOWN,
+ BATTERY_NORMAL,
+ BATTERY_LOW,
+ BATTERY_CHARGING,
+ BATTERY_CHARGING_FULL,
+ BATTERY_NOT_CHARGING_FULL,
+ NO_BATTERY
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BodyEnergySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BodyEnergySample.java
new file mode 100644
index 0000000..50a412a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/BodyEnergySample.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public interface BodyEnergySample extends TimeSample {
+ /**
+ * Body energy value, between 0 and 100.
+ */
+ int getEnergy();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CalendarEventSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CalendarEventSpec.java
new file mode 100644
index 0000000..553c63a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CalendarEventSpec.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Daniele Gobbetti, Gabriele
+ Monaco
+
+ 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.model;
+
+import java.util.ArrayList;
+
+public class CalendarEventSpec {
+ public static final byte TYPE_UNKNOWN = 0;
+ public static final byte TYPE_SUNRISE = 1;
+ public static final byte TYPE_SUNSET = 2;
+
+ public byte type;
+ public long id;
+ public int timestamp;
+ public int durationInSeconds;
+ public String title;
+ public String description;
+ public String location;
+ public String calName;
+ public int color;
+ public boolean allDay;
+ public ArrayList<Long> reminders; // unix epoch millis
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CallSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CallSpec.java
new file mode 100644
index 0000000..a7561c1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CallSpec.java
@@ -0,0 +1,41 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Davis Mosenkovs, Dmitry
+ Markin, mvn23
+
+ 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.model;
+
+public class CallSpec {
+ public static final int CALL_UNDEFINED = 0;
+ public static final int CALL_ACCEPT = 1;
+ public static final int CALL_INCOMING = 2;
+ public static final int CALL_OUTGOING = 3;
+ public static final int CALL_REJECT = 4;
+ public static final int CALL_START = 5;
+ public static final int CALL_END = 6;
+
+ public String number;
+ public String name;
+
+ public String sourceName;
+
+ /**
+ * The application that generated the notification.
+ */
+ public String sourceAppId;
+
+ public int command;
+ public int dndSuppressed;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CannedMessagesSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CannedMessagesSpec.java
new file mode 100644
index 0000000..39351ef
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CannedMessagesSpec.java
@@ -0,0 +1,26 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa
+
+ 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.model;
+
+public class CannedMessagesSpec {
+ public static final byte TYPE_GENERIC = 0;
+ public static final byte TYPE_REJECTEDCALLS = 1;
+ public static final byte TYPE_NEWSMS = 2;
+
+ public int type;
+ public String[] cannedMessages;
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Contact.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Contact.java
new file mode 100644
index 0000000..115f24e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Contact.java
@@ -0,0 +1,32 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+import java.io.Serializable;
+
+public interface Contact extends Serializable {
+ /**
+ * The {@link android.os.Bundle} name for transferring parceled contacts.
+ */
+ String EXTRA_CONTACT = "contact";
+
+ String getContactId();
+
+ String getName();
+
+ String getNumber();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DailyTotals.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DailyTotals.java
new file mode 100644
index 0000000..367c825
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DailyTotals.java
@@ -0,0 +1,207 @@
+/* Copyright (C) 2019-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
+ Dakhno, Jos茅 Rebelo, Petr Van臎k
+
+ 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.model;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Serializable;
+import java.util.Calendar;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.charts.ActivityAnalysis;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.DefaultRestingMetabolicRateProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+
+public class DailyTotals implements Serializable {
+ private static final Logger LOG = LoggerFactory.getLogger(DailyTotals.class);
+
+ private final long steps;
+ private final long distance;
+ private final long activeCalories;
+ private final long restingCalories;
+ private final long[] sleep; // light deep rem awake
+
+ public DailyTotals() {
+ this(0, 0, new long[]{0, 0, 0 ,0}, 0, 0);
+ }
+
+ public DailyTotals(final long steps, final long distance, final long[] sleep, final long activeCalories, final long restingCalories) {
+ this.steps = steps;
+ this.distance = distance;
+ this.sleep = sleep;
+ this.activeCalories = activeCalories;
+ this.restingCalories = restingCalories;
+ }
+
+ public long getSteps() {
+ return steps;
+ }
+
+ public long getActiveCalories() {
+ return activeCalories;
+ }
+
+ public long getRestingCalories() {
+ return restingCalories;
+ }
+
+ public long getDistance() {
+ return distance;
+ }
+
+ public long getSleep() {
+ // exclude awake sleep
+ return sleep[0] + sleep[1] + sleep[2];
+ }
+
+ public static DailyTotals getDailyTotalsForDevice(GBDevice device, Calendar day) {
+
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ return getDailyTotalsForDevice(device, day, handler);
+ } catch (Exception e) {
+ //GB.toast("Error loading sleep/steps widget data for device: " + device, Toast.LENGTH_SHORT, GB.ERROR, e);
+ return new DailyTotals();
+ }
+ }
+
+ public static DailyTotals getDailyTotalsForDevice(GBDevice device, Calendar day, DBHandler handler) {
+ ActivityAnalysis analysis = new ActivityAnalysis();
+ ActivityAmounts totalAmounts;
+ ActivityAmounts amountsSleep;
+
+ totalAmounts = analysis.calculateActivityAmounts(getSamplesOfDay(handler, day, 0, device));
+ amountsSleep = analysis.calculateActivityAmounts(getSamplesOfDay(handler, day, -12, device));
+
+ long[] sleep = getTotalsSleepForActivityAmounts(amountsSleep);
+
+ long totalSteps = 0;
+ long totalDistance = 0;
+ long totalActiveCalories = 0;
+ long totalRestingCalories = 0;
+ for (ActivityAmount amount : totalAmounts.getAmounts()) {
+ totalSteps += amount.getTotalSteps();
+ totalDistance += amount.getTotalDistance();
+ totalActiveCalories += amount.getTotalActiveCalories();
+ }
+ totalRestingCalories = getRestingCaloriesOfDay(handler, day, device);
+
+ // Purposely not including awake sleep
+ return new DailyTotals(totalSteps, totalDistance, sleep, totalActiveCalories, totalRestingCalories);
+ }
+
+ private static long[] getTotalsSleepForActivityAmounts(ActivityAmounts activityAmounts) {
+ long totalSecondsDeepSleep = 0;
+ long totalSecondsLightSleep = 0;
+ long totalSecondsRemSleep = 0;
+ long totalSecondsAwakeSleep = 0;
+ for (ActivityAmount amount : activityAmounts.getAmounts()) {
+ if (amount.getActivityKind() == ActivityKind.DEEP_SLEEP) {
+ totalSecondsDeepSleep += amount.getTotalSeconds();
+ } else if (amount.getActivityKind() == ActivityKind.LIGHT_SLEEP) {
+ totalSecondsLightSleep += amount.getTotalSeconds();
+ } else if (amount.getActivityKind() == ActivityKind.REM_SLEEP) {
+ totalSecondsRemSleep += amount.getTotalSeconds();
+ } else if (amount.getActivityKind() == ActivityKind.AWAKE_SLEEP) {
+ totalSecondsAwakeSleep += amount.getTotalSeconds();
+ }
+ }
+ long totalMinutesDeepSleep = (totalSecondsDeepSleep / 60);
+ long totalMinutesLightSleep = (totalSecondsLightSleep / 60);
+ long totalMinutesRemSleep = (totalSecondsRemSleep / 60);
+ long totalMinutesAwakeSleep = (totalSecondsAwakeSleep / 60);
+ return new long[]{totalMinutesLightSleep, totalMinutesDeepSleep, totalMinutesRemSleep, totalMinutesAwakeSleep};
+ }
+
+ private static List<? extends ActivitySample> getSamplesOfDay(DBHandler db, Calendar day, int offsetHours, GBDevice device) {
+ int startTs;
+ int endTs;
+
+ day = (Calendar) day.clone(); // do not modify the caller's argument
+ day.set(Calendar.HOUR_OF_DAY, 0);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ day.add(Calendar.HOUR, offsetHours);
+
+ startTs = (int) (day.getTimeInMillis() / 1000);
+ endTs = startTs + 24 * 60 * 60 - 1;
+
+ return getSamples(db, device, startTs, endTs);
+ }
+
+ private static int getRestingCaloriesOfDay(DBHandler db, Calendar day, GBDevice device) {
+ Calendar calendar = Calendar.getInstance();
+ day.add(Calendar.DATE, 0);
+ day.set(Calendar.HOUR_OF_DAY, 0);
+ day.set(Calendar.MINUTE, 0);
+ day.set(Calendar.SECOND, 0);
+ day.add(Calendar.HOUR, 0);
+ RestingMetabolicRateSample metabolicRate = getRestingMetabolicRate(db, day, device);
+ if (metabolicRate == null) {
+ // should never happen
+ return 0;
+ }
+ double passedDayProportion = 1;
+ boolean sameDay = calendar.get(Calendar.DAY_OF_YEAR) == day.get(Calendar.DAY_OF_YEAR) &&
+ calendar.get(Calendar.YEAR) == day.get(Calendar.YEAR);
+ if (sameDay) {
+ passedDayProportion = (double) (calendar.getTimeInMillis() - day.getTimeInMillis()) / (24L * 60 * 60 * 1000);
+ }
+ return (int) (metabolicRate.getRestingMetabolicRate() * passedDayProportion);
+ }
+
+ public static List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ return getAllSamples(db, device, tsFrom, tsTo);
+ }
+
+ protected static RestingMetabolicRateSample getRestingMetabolicRate(DBHandler db, Calendar day, GBDevice device) {
+ // FIXME this cast is ugly and might lead to issues
+ AbstractTimeSampleProvider<? extends RestingMetabolicRateSample> provider = (AbstractTimeSampleProvider<? extends RestingMetabolicRateSample>)
+ device.getDeviceCoordinator().getRestingMetabolicRateProvider(device, db.getDaoSession());
+ final long endOfDayTimestamp = day.getTimeInMillis() + 86_400_000L;
+ final RestingMetabolicRateSample latestSample = provider.getLastSampleBefore(endOfDayTimestamp);
+ if (latestSample != null) {
+ return latestSample;
+ }
+ DefaultRestingMetabolicRateProvider defaultProvider = new DefaultRestingMetabolicRateProvider(device, db.getDaoSession());
+ return defaultProvider.getLastSampleBefore(endOfDayTimestamp);
+ }
+
+ protected static SampleProvider<? extends AbstractActivitySample> getProvider(DBHandler db, GBDevice device) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.getSampleProvider(device, db.getDaoSession());
+ }
+
+ protected static List<? extends ActivitySample> getAllSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
+ SampleProvider<? extends ActivitySample> provider = getProvider(db, device);
+ return provider.getAllActivitySamples(tsFrom, tsTo);
+ }
+
+ public static ActivitySample getFirstSample(DBHandler db, GBDevice device) {
+ SampleProvider<? extends ActivitySample> provider = getProvider(db, device);
+ return provider.getFirstActivitySample();
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java
new file mode 100644
index 0000000..1379eee
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java
@@ -0,0 +1,196 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniel Dakhno, Daniele Gobbetti, Davis Mosenkovs, Frank Slezak,
+ Gabriele Monaco, Gordon Williams, ivanovlev, JohnnySun, Jos茅 Rebelo, Julien
+ Pivotto, Kasha, mvn23, Petr Van臎k, Sebastian Kranz, Steffen Liebergeld,
+ Taavi Eom盲e
+
+ 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.model;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.EventHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceCommunicationService;
+
+/**
+ *
+ */
+public interface DeviceService extends EventHandler {
+ String PREFIX = "nodomain.freeyourgadget.gadgetbridge.devices";
+
+ String ACTION_CONNECT = PREFIX + ".action.connect";
+ String ACTION_NOTIFICATION = PREFIX + ".action.notification";
+ String ACTION_DELETE_NOTIFICATION = PREFIX + ".action.delete_notification";
+ String ACTION_CALLSTATE = PREFIX + ".action.callstate";
+ String ACTION_SETCANNEDMESSAGES = PREFIX + ".action.setcannedmessages";
+ String ACTION_SETTIME = PREFIX + ".action.settime";
+ String ACTION_SETMUSICINFO = PREFIX + ".action.setmusicinfo";
+ String ACTION_SETMUSICSTATE = PREFIX + ".action.setmusicstate";
+ String ACTION_SET_PHONE_VOLUME = PREFIX + ".action.set_phone_volume";
+ String ACTION_SET_PHONE_SILENT_MODE = PREFIX + ".action.set_phone_silent_mode";
+ String ACTION_SETNAVIGATIONINFO = PREFIX + ".action.setnavigationinfo";
+ String ACTION_REQUEST_DEVICEINFO = PREFIX + ".action.request_deviceinfo";
+ String ACTION_REQUEST_APPINFO = PREFIX + ".action.request_appinfo";
+ String ACTION_REQUEST_SCREENSHOT = PREFIX + ".action.request_screenshot";
+ String ACTION_STARTAPP = PREFIX + ".action.startapp";
+ String ACTION_DOWNLOADAPP = PREFIX + ".action.downloadapp";
+ String ACTION_DELETEAPP = PREFIX + ".action.deleteapp";
+ String ACTION_APP_CONFIGURE = PREFIX + ".action.app_configure";
+ String ACTION_APP_REORDER = PREFIX + ".action.app_reorder";
+ String ACTION_INSTALL = PREFIX + ".action.install";
+ String ACTION_RESET = PREFIX + ".action.reset";
+ String ACTION_HEARTRATE_TEST = PREFIX + ".action.heartrate_test";
+ String ACTION_FETCH_RECORDED_DATA = PREFIX + ".action.fetch_activity_data";
+ String ACTION_DISCONNECT = PREFIX + ".action.disconnect";
+ String ACTION_FIND_DEVICE = PREFIX + ".action.find_device";
+ String ACTION_PHONE_FOUND = PREFIX + ".action.phone_found";
+ String ACTION_SET_CONSTANT_VIBRATION = PREFIX + ".action.set_constant_vibration";
+ String ACTION_SET_ALARMS = PREFIX + ".action.set_alarms";
+ String ACTION_SAVE_ALARMS = PREFIX + ".action.save_alarms";
+ String ACTION_SAVE_REMINDERS = PREFIX + ".action.save_reminders";
+ String ACTION_SET_REMINDERS = PREFIX + ".action.set_reminders";
+ String ACTION_SET_LOYALTY_CARDS = PREFIX + ".action.set_loyalty_cards";
+ String ACTION_SET_WORLD_CLOCKS = PREFIX + ".action.set_world_clocks";
+ String ACTION_SET_CONTACTS = PREFIX + ".action.set_contacts";
+ String ACTION_ENABLE_REALTIME_STEPS = PREFIX + ".action.enable_realtime_steps";
+ String ACTION_REALTIME_SAMPLES = PREFIX + ".action.realtime_samples";
+ String ACTION_ENABLE_REALTIME_HEARTRATE_MEASUREMENT = PREFIX + ".action.realtime_hr_measurement";
+ String ACTION_ENABLE_HEARTRATE_SLEEP_SUPPORT = PREFIX + ".action.enable_heartrate_sleep_support";
+ String ACTION_SET_HEARTRATE_MEASUREMENT_INTERVAL = PREFIX + ".action.set_heartrate_measurement_intervarl";
+ String ACTION_ADD_CALENDAREVENT = PREFIX + ".action.add_calendarevent";
+ String ACTION_DELETE_CALENDAREVENT = PREFIX + ".action.delete_calendarevent";
+ String ACTION_SEND_CONFIGURATION = PREFIX + ".action.send_configuration";
+ String ACTION_READ_CONFIGURATION = PREFIX + ".action.read_configuration";
+ String ACTION_SEND_WEATHER = PREFIX + ".action.send_weather";
+ String ACTION_TEST_NEW_FUNCTION = PREFIX + ".action.test_new_function";
+ String ACTION_SET_FM_FREQUENCY = PREFIX + ".action.set_fm_frequency";
+ 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_CAMERA_STATUS_CHANGE = PREFIX + ".action.camera_status_change";
+ String ACTION_REQUEST_MUSIC_LIST = PREFIX + ".action.request_music_list";
+ String ACTION_REQUEST_MUSIC_OPERATION = PREFIX + ".action.request_music_operation";
+
+ 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";
+ String EXTRA_NOTIFICATION_KEY = "notification_key";
+ String EXTRA_NOTIFICATION_PHONENUMBER = "notification_phonenumber";
+ String EXTRA_NOTIFICATION_SENDER = "notification_sender";
+ String EXTRA_NOTIFICATION_SOURCENAME = "notification_sourcename";
+ String EXTRA_NOTIFICATION_SOURCEAPPID = "notification_sourceappid";
+ String EXTRA_NOTIFICATION_SUBJECT = "notification_subject";
+ String EXTRA_NOTIFICATION_TITLE = "notification_title";
+ String EXTRA_NOTIFICATION_TYPE = "notification_type";
+ String EXTRA_NOTIFICATION_ACTIONS = "notification_actions";
+ String EXTRA_NOTIFICATION_PEBBLE_COLOR = "notification_pebble_color";
+ String EXTRA_NOTIFICATION_ICONID = "notification_iconid";
+ String NOTIFICATION_PICTURE_PATH = "notification_picture_path";
+ String EXTRA_NOTIFICATION_DNDSUPPRESSED = "notification_dndsuppressed";
+ String EXTRA_FIND_START = "find_start";
+ String EXTRA_VIBRATION_INTENSITY = "vibration_intensity";
+ String EXTRA_CALL_COMMAND = "call_command";
+ String EXTRA_CALL_PHONENUMBER = "call_phonenumber";
+ String EXTRA_CALL_DISPLAYNAME = "call_displayname";
+ String EXTRA_CALL_SOURCENAME = "call_sourcename";
+ String EXTRA_CALL_SOURCEAPPID = "call_sourceappid";
+ String EXTRA_CALL_DNDSUPPRESSED = "call_dndsuppressed";
+ String EXTRA_CANNEDMESSAGES = "cannedmessages";
+ String EXTRA_CANNEDMESSAGES_TYPE = "cannedmessages_type";
+ String EXTRA_MUSIC_ARTIST = "music_artist";
+ String EXTRA_MUSIC_ALBUM = "music_album";
+ String EXTRA_MUSIC_TRACK = "music_track";
+ String EXTRA_MUSIC_DURATION = "music_duration";
+ String EXTRA_MUSIC_TRACKNR = "music_tracknr";
+ String EXTRA_MUSIC_TRACKCOUNT = "music_trackcount";
+ String EXTRA_MUSIC_STATE = "music_state";
+ String EXTRA_MUSIC_SHUFFLE = "music_shuffle";
+ String EXTRA_MUSIC_REPEAT = "music_repeat";
+ String EXTRA_MUSIC_POSITION = "music_position";
+ String EXTRA_MUSIC_RATE = "music_rate";
+ String EXTRA_PHONE_VOLUME = "phone_volume";
+ String EXTRA_PHONE_RINGER_MODE = "ringer_mode";
+ String EXTRA_NAVIGATION_INSTRUCTION = "navigation_instruction";
+ String EXTRA_NAVIGATION_DISTANCE_TO_TURN = "navigation_distance_to_turn";
+ String EXTRA_NAVIGATION_NEXT_ACTION = "navigation_next_action";
+ String EXTRA_NAVIGATION_ETA = "navigation_eta";
+ String EXTRA_APP_UUID = "app_uuid";
+ String EXTRA_APP_START = "app_start";
+ String EXTRA_APP_CONFIG = "app_config";
+ String EXTRA_APP_CONFIG_ID = "app_config_id";
+ String EXTRA_URI = "uri";
+ String EXTRA_CONFIG = "config";
+ String EXTRA_ALARMS = "alarms";
+ String EXTRA_REMINDERS = "reminders";
+ String EXTRA_LOYALTY_CARDS = "loyalty_cards";
+ String EXTRA_WORLD_CLOCKS = "world_clocks";
+ String EXTRA_CONTACTS = "contacts";
+ String EXTRA_CONNECT_FIRST_TIME = "connect_first_time";
+ String EXTRA_BOOLEAN_ENABLE = "enable_realtime_steps";
+ String EXTRA_INTERVAL_SECONDS = "interval_seconds";
+ String EXTRA_WEATHER = "weather";
+ String EXTRA_RECORDED_DATA_TYPES = "data_types";
+ String EXTRA_FM_FREQUENCY = "fm_frequency";
+ String EXTRA_LED_COLOR = "led_color";
+ String EXTRA_GPS_LOCATION = "gps_location";
+ String EXTRA_RESET_FLAGS = "reset_flags";
+ String EXTRA_CAMERA_EVENT = "event";
+ String EXTRA_CAMERA_FILENAME = "filename";
+
+ /**
+ * Use EXTRA_REALTIME_SAMPLE instead
+ */
+ @Deprecated
+ String EXTRA_REALTIME_STEPS = "realtime_steps";
+ String EXTRA_REALTIME_SAMPLE = "realtime_sample";
+ String EXTRA_TIMESTAMP = "timestamp";
+ /**
+ * Use EXTRA_REALTIME_SAMPLE instead
+ */
+ @Deprecated
+ String EXTRA_HEART_RATE_VALUE = "hr_value";
+ String EXTRA_CALENDAREVENT_ID = "calendarevent_id";
+ String EXTRA_CALENDAREVENT_TYPE = "calendarevent_type";
+ 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";
+ String EXTRA_CALENDAREVENT_CALNAME = "calendarevent_calname";
+ String EXTRA_CALENDAREVENT_COLOR = "calendarevent_color";
+
+ void connect();
+
+ void connect(boolean firstTime);
+
+ void disconnect();
+
+ void quit();
+
+ DeviceService forDevice(GBDevice device);
+
+ /**
+ * Requests information from the {@link DeviceCommunicationService} about the connection state,
+ * firmware info, etc.
+ * <p/>
+ * Note that this will not need a connection to the device -- only the cached information
+ * from the service will be reported.
+ */
+ void requestDeviceInfo();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
new file mode 100644
index 0000000..4b25f28
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
@@ -0,0 +1,619 @@
+/* Copyright (C) 2015-2024 115ek, akasaka / Genjitsu Labs, Alicia Hormann,
+ Andreas B枚hler, Andreas Shimokawa, Andrew Watkins, angelpup, Carsten Pfeiffer,
+ Cre3per, Damien Gaignon, DanialHanif, Daniel Dakhno, Daniele Gobbetti, Daniel
+ Thompson, Da Pa, Dmytro Bielik, Frank Ertl, Gabriele Monaco, GeekosaurusR3x,
+ Guido J盲kel, Gordon Williams, Jean-Fran莽ois Greffier, jfgreffier, jhey, Joa虄o Paulo
+ Barraca, Jochen S, Johannes Krude, Jos茅 Rebelo, ksiwczynski, ladbsoft,
+ Lesur Frederic, Maciej Ku艣nierz, mamucho, Manuel Ru脽, Maxime Reyrolle,
+ maxirnilian, Michael, narektor, Noodlez, odavo32nof, opavlov, pangwalla,
+ Pavel Elagin, Petr Kadlec, Petr Van臎k, protomors, Quallenauge, Quang Ng么,
+ Raghd Hamzeh, Sami Alaoui, Sebastian Kranz, sedy89, Sophanimus, Stefan Bora,
+ Taavi Eom盲e, thermatk, tiparega, Vadim Kaushan, x29a, xaos, Yoran Vulker,
+ Yukai Li
+
+ 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.model;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.UnknownDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.asteroidos.AsteroidOSDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.bandwpseries.BandWPSeriesDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.binary_sensor.coordinator.BinarySensorCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.casio.ecbs100.CasioECBS100DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.casio.gb6900.CasioGB6900DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.casio.gbx100.CasioGBX100DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGMWB5000DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGWB5600DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.CmfWatchPro2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.CmfWatchProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR02Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR03Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR06Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR09Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR10Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.coordinator.CyclingSensorCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.divoom.PixooCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.domyos.DomyosT540Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.femometer.FemometerVinca2DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.fitpro.FitProDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.fitpro.colacao.ColaCao21Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.fitpro.colacao.ColaCao23Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.flipper.zero.FlipperZeroCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBuds2DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBuds2ProDeviceCoordinator;
+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.watches.enduro.GarminEnduro3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.epix.GarminEpixProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix5Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix5PlusCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix5XPlusCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix6Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix6SProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix6SSapphireCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix6SapphireCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix7Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix7ProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix7SCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix7XCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix8Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner165Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner235Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner245Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner245MusicCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner255Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner255MusicCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner255SCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner255SMusicCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner265Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner265SCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner55Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner620Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner955Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner965Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2SCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2SSolarCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2SolTacCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2SolarCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2XSolarCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinctCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinctCrossoverCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinctSolarCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.swim.GarminSwim2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu.GarminVenu2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu.GarminVenu2PlusCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu.GarminVenu2SCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu.GarminVenu3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu.GarminVenu3SCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu.GarminVenuCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu.GarminVenuSq2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu.GarminVenuSqCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivoactive.GarminVivoActive3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivoactive.GarminVivoActive4Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivoactive.GarminVivoActive4SCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivoactive.GarminVivoActive5Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivomove.GarminVivomoveHrCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivomove.GarminVivomoveStyleCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivomove.GarminVivomoveTrendCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivosmart.GarminVivosmart5Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivosport.GarminVivosportCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.hama.fit6900.HamaFit6900DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.hplus.EXRIZUK8Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.hplus.HPlusCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.hplus.MakibesF68Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.hplus.Q8Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.hplus.SG2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitactive.AmazfitActiveCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitactiveedge.AmazfitActiveEdgeCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbalance.AmazfitBalanceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitband5.AmazfitBand5Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitband7.AmazfitBand7Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip.AmazfitBipCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip.AmazfitBipLiteCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip3.AmazfitBip3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip3pro.AmazfitBip3ProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip5.AmazfitBip5Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip5unity.AmazfitBip5UnityCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbips.AmazfitBipSCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbips.AmazfitBipSLiteCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbipu.AmazfitBipUCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbipupro.AmazfitBipUProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitcheetahpro.AmazfitCheetahProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitcheetahround.AmazfitCheetahRoundCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitcheetahsquare.AmazfitCheetahSquareCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitcor.AmazfitCorCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitcor2.AmazfitCor2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitfalcon.AmazfitFalconCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtr.AmazfitGTRCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtr.AmazfitGTRLiteCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtr2.AmazfitGTR2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtr2.AmazfitGTR2eCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtr3.AmazfitGTR3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtr3pro.AmazfitGTR3ProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtr4.AmazfitGTR4Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtrmini.AmazfitGTRMiniCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgts.AmazfitGTSCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgts2.AmazfitGTS2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgts2.AmazfitGTS2MiniCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgts2.AmazfitGTS2eCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgts3.AmazfitGTS3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgts4.AmazfitGTS4Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgts4mini.AmazfitGTS4MiniCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitneo.AmazfitNeoCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitpop.AmazfitPopCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitpoppro.AmazfitPopProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfittrex.AmazfitTRexCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfittrex2.AmazfitTRex2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfittrex3.AmazfitTRex3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfittrexpro.AmazfitTRexProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfittrexultra.AmazfitTRexUltraCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitvergel.AmazfitVergeLCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitx.AmazfitXCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband2.MiBand2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband2.MiBand2HRXCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband3.MiBand3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband4.MiBand4Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband5.MiBand5Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband6.MiBand6Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband7.MiBand7Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppe.ZeppECoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.freebuds5i.HuaweiFreebuds5iCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband3.HonorBand3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband4.HonorBand4Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband5.HonorBand5Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband6.HonorBand6Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband7.HonorBand7Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honormagicwatch2.HonorMagicWatch2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorwatchgs3.HonorWatchGS3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorwatchgspro.HonorWatchGSProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband2pro.HuaweiBand2ProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband3pro.HuaweiBand3ProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband4pro.HuaweiBand4ProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband6.HuaweiBand6Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband7.HuaweiBand7Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband8.HuaweiBand8Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband9.HuaweiBand9Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweibandaw70.HuaweiBandAw70Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweitalkbandb6.HuaweiTalkBandB6Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatch3.HuaweiWatch3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatch4pro.HuaweiWatch4ProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchd2.HuaweiWatchD2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchfit.HuaweiWatchFitCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchfit2.HuaweiWatchFit2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchfit3.HuaweiWatchFit3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchgt.HuaweiWatchGTCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchgt2.HuaweiWatchGT2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchgt2e.HuaweiWatchGT2eCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchgt3.HuaweiWatchGT3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchgt4.HuaweiWatchGT4Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchgt5.HuaweiWatchGT5Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchgtcyber.HuaweiWatchGTCyberCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchgtrunner.HuaweiWatchGTRunnerCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchultimate.HuaweiWatchUltimateCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.id115.ID115Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.idasen.IdasenCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.itag.ITagCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.jyou.BFH16DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.jyou.TeclastH30.TeclastH30Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.jyou.y5.Y5Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.BohemicSmartBraceletDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.LefunDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.lefun.VivitarHrBpMonitorActivityTrackerCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.lenovo.watchxplus.WatchXPlusDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.liveview.LiveviewCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.makibeshr3.MakibesHR3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.marstek.MarstekB2500DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandCoordinator;
+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.mijia_lywsd.MijiaXmwsdj04Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.miscale.MiCompositionScaleCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.miscale.MiSmartScaleCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.moondrop.MoondropSpaceTravelCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.no1f1.No1F1Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.nothing.CmfBudsPro2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.nothing.Ear1Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.nothing.Ear2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.nothing.EarStickCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.nut.NutCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAir2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAirCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.pinetime.PineTimeJFCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.qc35.QC35Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.QHybridCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.realme.RealmeBudsT110Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.roidmi.Roidmi1Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.roidmi.Roidmi3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.scannable.ScannableDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.smaq2oss.SMAQ2OSSCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.soflow.SoFlowCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyLinkBudsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyLinkBudsSCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWF1000XM3Coordinator;
+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.SonyWFC500Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWFC700NCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWFSP800NCoordinator;
+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.SonyWIC100Coordinator;
+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.soundcore.liberty3_pro.SoundcoreLiberty3ProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.liberty4_nc.SoundcoreLiberty4NCCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.motion300.SoundcoreMotion300Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.supercars.SuperCarsCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.tlw64.TLW64Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.um25.Coordinator.UM25Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.vesc.VescCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.vibratissimo.VibratissimoCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.waspos.WaspOSCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.watch9.Watch9DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.withingssteelhr.WithingsSteelHRDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband7pro.MiBand7ProCoordinator;
+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.miband9.MiBand9Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband9pro.MiBand9ProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatch.MiWatchLiteCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatchcolorsport.MiWatchColorSportCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmibuds5pro.RedmiBuds5ProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmismartband2.RedmiSmartBand2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmismartbandpro.RedmiSmartBandProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch2.RedmiWatch2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch2lite.RedmiWatch2LiteCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch3.RedmiWatch3Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch3active.RedmiWatch3ActiveCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch4.RedmiWatch4Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch5active.RedmiWatch5ActiveCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch5lite.RedmiWatch5LiteCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs1.XiaomiWatchS1Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs1active.XiaomiWatchS1ActiveCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs1pro.XiaomiWatchS1ProCoordinator;
+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.service.devices.gatt_client.BleGattClientCoordinator;
+
+/**
+ * For every supported device, a device type constant must exist.
+ * <p>
+ * Note: they name of the enum is stored in the DB, so it is fixed forever,
+ * and may not be changed.
+ * <p>
+ * Migration note: As of <a href="https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/3347">#3347</a>,
+ * the numeric device id is not used anymore. If your database has development devices that still used
+ * the numeric ID, you need to update assets/migrations/devicetype.json before installing Gadgetbridge
+ * after rebasing, in order for your device to be migrated correctly. If you failed to do this and the
+ * device is now not being displayed, please update the file and uncomment the call to migrateDeviceTypes
+ * in GBApplication.
+ */
+public enum DeviceType {
+ UNKNOWN(UnknownDeviceCoordinator.class),
+ PEBBLE(PebbleCoordinator.class),
+ MIBAND(MiBandCoordinator.class),
+ MIBAND2(MiBand2Coordinator.class),
+ MIBAND2_HRX(MiBand2HRXCoordinator.class),
+ AMAZFITBIP(AmazfitBipCoordinator.class),
+ AMAZFITCOR(AmazfitCorCoordinator.class),
+ MIBAND3(MiBand3Coordinator.class),
+ AMAZFITCOR2(AmazfitCor2Coordinator.class),
+ MIBAND4(MiBand4Coordinator.class),
+ AMAZFITBIP_LITE(AmazfitBipLiteCoordinator.class),
+ AMAZFITGTR(AmazfitGTRCoordinator.class),
+ AMAZFITGTS(AmazfitGTSCoordinator.class),
+ AMAZFITBIPS(AmazfitBipSCoordinator.class),
+ AMAZFITGTR_LITE(AmazfitGTRLiteCoordinator.class),
+ AMAZFITTREX(AmazfitTRexCoordinator.class),
+ MIBAND5(MiBand5Coordinator.class),
+ AMAZFITBAND5(AmazfitBand5Coordinator.class),
+ AMAZFITBIPS_LITE(AmazfitBipSLiteCoordinator.class),
+ AMAZFITGTR2(AmazfitGTR2Coordinator.class),
+ AMAZFITGTS2(AmazfitGTS2Coordinator.class),
+ AMAZFITBIPU(AmazfitBipUCoordinator.class),
+ AMAZFITVERGEL(AmazfitVergeLCoordinator.class),
+ AMAZFITBIPUPRO(AmazfitBipUProCoordinator.class),
+ AMAZFITNEO(AmazfitNeoCoordinator.class),
+ AMAZFITGTS2_MINI(AmazfitGTS2MiniCoordinator.class),
+ ZEPP_E(ZeppECoordinator.class),
+ AMAZFITGTR2E(AmazfitGTR2eCoordinator.class),
+ AMAZFITGTS2E(AmazfitGTS2eCoordinator.class),
+ AMAZFITX(AmazfitXCoordinator.class),
+ MIBAND6(MiBand6Coordinator.class),
+ AMAZFITTREXPRO(AmazfitTRexProCoordinator.class),
+ AMAZFITPOP(AmazfitPopCoordinator.class),
+ AMAZFITPOPPRO(AmazfitPopProCoordinator.class),
+ MIBAND7(MiBand7Coordinator.class),
+ MIBAND7PRO(MiBand7ProCoordinator.class),
+ MIBAND8(MiBand8Coordinator.class),
+ MIBAND8ACTIVE(MiBand8ActiveCoordinator.class),
+ MIBAND8PRO(MiBand8ProCoordinator.class),
+ MIBAND9(MiBand9Coordinator.class),
+ MIBAND9PRO(MiBand9ProCoordinator.class),
+ MIWATCHLITE(MiWatchLiteCoordinator.class),
+ MIWATCHCOLORSPORT(MiWatchColorSportCoordinator.class),
+ REDMIBUDS5PRO(RedmiBuds5ProCoordinator.class),
+ REDMIWATCH3ACTIVE(RedmiWatch3ActiveCoordinator.class),
+ REDMIWATCH3(RedmiWatch3Coordinator.class),
+ REDMISMARTBAND2(RedmiSmartBand2Coordinator.class),
+ REDMIWATCH2(RedmiWatch2Coordinator.class),
+ REDMIWATCH2LITE(RedmiWatch2LiteCoordinator.class),
+ REDMISMARTBANDPRO(RedmiSmartBandProCoordinator.class),
+ REDMIWATCH4(RedmiWatch4Coordinator.class),
+ REDMIWATCH5ACTIVE(RedmiWatch5ActiveCoordinator.class),
+ REDMIWATCH5LITE(RedmiWatch5LiteCoordinator.class),
+ XIAOMI_WATCH_S1_ACTIVE(XiaomiWatchS1ActiveCoordinator.class),
+ XIAOMI_WATCH_S1_PRO(XiaomiWatchS1ProCoordinator.class),
+ XIAOMI_WATCH_S1(XiaomiWatchS1Coordinator.class),
+ XIAOMI_WATCH_S3(XiaomiWatchS3Coordinator.class),
+ AMAZFITGTS3(AmazfitGTS3Coordinator.class),
+ AMAZFITGTR3(AmazfitGTR3Coordinator.class),
+ AMAZFITGTR4(AmazfitGTR4Coordinator.class),
+ AMAZFITBAND7(AmazfitBand7Coordinator.class),
+ AMAZFITGTS4(AmazfitGTS4Coordinator.class),
+ AMAZFITGTS4MINI(AmazfitGTS4MiniCoordinator.class),
+ AMAZFITTREX2(AmazfitTRex2Coordinator.class),
+ AMAZFITTREX3(AmazfitTRex3Coordinator.class),
+ AMAZFITGTR3PRO(AmazfitGTR3ProCoordinator.class),
+ AMAZFITBIP3(AmazfitBip3Coordinator.class),
+ AMAZFITBIP3PRO(AmazfitBip3ProCoordinator.class),
+ AMAZFITCHEETAHPRO(AmazfitCheetahProCoordinator.class),
+ AMAZFITCHEETAHSQUARE(AmazfitCheetahSquareCoordinator.class),
+ AMAZFITCHEETAHROUND(AmazfitCheetahRoundCoordinator.class),
+ AMAZFITBIP5(AmazfitBip5Coordinator.class),
+ AMAZFITBIP5UNITY(AmazfitBip5UnityCoordinator.class),
+ AMAZFITTREXULTRA(AmazfitTRexUltraCoordinator.class),
+ AMAZFITGTRMINI(AmazfitGTRMiniCoordinator.class),
+ AMAZFITFALCON(AmazfitFalconCoordinator.class),
+ AMAZFITBALANCE(AmazfitBalanceCoordinator.class),
+ AMAZFITACTIVE(AmazfitActiveCoordinator.class),
+ AMAZFITACTIVEEDGE(AmazfitActiveEdgeCoordinator.class),
+ HPLUS(HPlusCoordinator.class),
+ MAKIBESF68(MakibesF68Coordinator.class),
+ EXRIZUK8(EXRIZUK8Coordinator.class),
+ Q8(Q8Coordinator.class),
+ SG2(SG2Coordinator.class),
+ NO1F1(No1F1Coordinator.class),
+ TECLASTH30(TeclastH30Coordinator.class),
+ Y5(Y5Coordinator.class),
+ XWATCH(XWatchCoordinator.class),
+ ZETIME(ZeTimeCoordinator.class),
+ ID115(ID115Coordinator.class),
+ WATCH9(Watch9DeviceCoordinator.class),
+ WATCHXPLUS(WatchXPlusDeviceCoordinator.class),
+ ROIDMI(Roidmi1Coordinator.class),
+ ROIDMI3(Roidmi3Coordinator.class),
+ CASIOECBS100(CasioECBS100DeviceCoordinator.class),
+
+ CASIOGB6900(CasioGB6900DeviceCoordinator.class),
+ CASIOGBX100(CasioGBX100DeviceCoordinator.class),
+ CASIOGWB5600(CasioGWB5600DeviceCoordinator.class),
+ CASIOGMWB5000(CasioGMWB5000DeviceCoordinator.class),
+ MISMARTSCALE(MiSmartScaleCoordinator.class),
+ MICOMPOSITIONSCALE(MiCompositionScaleCoordinator.class),
+ BFH16(BFH16DeviceCoordinator.class),
+ MAKIBESHR3(MakibesHR3Coordinator.class),
+ BANGLEJS(BangleJSCoordinator.class),
+ FOSSILQHYBRID(QHybridCoordinator.class),
+ TLW64(TLW64Coordinator.class),
+ PINETIME_JF(PineTimeJFCoordinator.class),
+ MIJIA_LYWSD02(MijiaLywsd02Coordinator.class),
+ MIJIA_LYWSD03(MijiaLywsd03Coordinator.class),
+ MIJIA_XMWSDJ04(MijiaXmwsdj04Coordinator.class),
+ MIJIA_MHO_C303(MijiaMhoC303Coordinator.class),
+ LEFUN(LefunDeviceCoordinator.class),
+ VIVITAR_HR_BP_MONITOR_ACTIVITY_TRACKER(VivitarHrBpMonitorActivityTrackerCoordinator.class),
+ BOHEMIC_SMART_BRACELET(BohemicSmartBraceletDeviceCoordinator.class),
+ SMAQ2OSS(SMAQ2OSSCoordinator.class),
+ FITPRO(FitProDeviceCoordinator.class),
+ COLACAO21(ColaCao21Coordinator.class),
+ COLACAO23(ColaCao23Coordinator.class),
+ ITAG(ITagCoordinator.class),
+ IKEA_IDASEN(IdasenCoordinator.class),
+ NUTMINI(NutCoordinator.class),
+ VIVOMOVE_HR(GarminVivomoveHrCoordinator.class),
+ GARMIN_ENDURO_3(GarminEnduro3Coordinator.class),
+ GARMIN_EPIX_PRO(GarminEpixProCoordinator.class),
+ GARMIN_FENIX_5(GarminFenix5Coordinator.class),
+ GARMIN_FENIX_5_PLUS(GarminFenix5PlusCoordinator.class),
+ GARMIN_FENIX_5X_PLUS(GarminFenix5XPlusCoordinator.class),
+ GARMIN_FENIX_6(GarminFenix6Coordinator.class),
+ GARMIN_FENIX_6_SAPPHIRE(GarminFenix6SapphireCoordinator.class),
+ GARMIN_FENIX_6S_PRO(GarminFenix6SProCoordinator.class),
+ GARMIN_FENIX_6S_SAPPHIRE(GarminFenix6SSapphireCoordinator.class),
+ GARMIN_FENIX_7(GarminFenix7Coordinator.class),
+ GARMIN_FENIX_7S(GarminFenix7SCoordinator.class),
+ GARMIN_FENIX_7X(GarminFenix7XCoordinator.class),
+ GARMIN_FENIX_7_PRO(GarminFenix7ProCoordinator.class),
+ GARMIN_FENIX_8(GarminFenix8Coordinator.class),
+ GARMIN_FORERUNNER_55(GarminForerunner55Coordinator.class),
+ GARMIN_FORERUNNER_165(GarminForerunner165Coordinator.class),
+ GARMIN_FORERUNNER_235(GarminForerunner235Coordinator.class),
+ GARMIN_FORERUNNER_245(GarminForerunner245Coordinator.class),
+ GARMIN_FORERUNNER_245_MUSIC(GarminForerunner245MusicCoordinator.class),
+ GARMIN_FORERUNNER_255(GarminForerunner255Coordinator.class),
+ GARMIN_FORERUNNER_255_MUSIC(GarminForerunner255MusicCoordinator.class),
+ GARMIN_FORERUNNER_255S(GarminForerunner255SCoordinator.class),
+ GARMIN_FORERUNNER_255S_MUSIC(GarminForerunner255SMusicCoordinator.class),
+ GARMIN_FORERUNNER_265(GarminForerunner265Coordinator.class),
+ GARMIN_FORERUNNER_265S(GarminForerunner265SCoordinator.class),
+ GARMIN_FORERUNNER_620(GarminForerunner620Coordinator.class),
+ GARMIN_FORERUNNER_955(GarminForerunner955Coordinator.class),
+ GARMIN_FORERUNNER_965(GarminForerunner965Coordinator.class),
+ GARMIN_SWIM_2(GarminSwim2Coordinator.class),
+ GARMIN_INSTINCT(GarminInstinctCoordinator.class),
+ GARMIN_INSTINCT_SOLAR(GarminInstinctSolarCoordinator.class),
+ GARMIN_INSTINCT_2(GarminInstinct2Coordinator.class),
+ GARMIN_INSTINCT_2S(GarminInstinct2SCoordinator.class),
+ GARMIN_INSTINCT_2S_SOLAR(GarminInstinct2SSolarCoordinator.class),
+ GARMIN_INSTINCT_2X_SOLAR(GarminInstinct2XSolarCoordinator.class),
+ GARMIN_INSTINCT_2_SOLAR(GarminInstinct2SolarCoordinator.class),
+ GARMIN_INSTINCT_2_SOLTAC(GarminInstinct2SolTacCoordinator.class),
+ GARMIN_INSTINCT_CROSSOVER(GarminInstinctCrossoverCoordinator.class),
+ GARMIN_VIVOMOVE_STYLE(GarminVivomoveStyleCoordinator.class),
+ GARMIN_VIVOMOVE_TREND(GarminVivomoveTrendCoordinator.class),
+ GARMIN_VENU(GarminVenuCoordinator.class),
+ GARMIN_VENU_SQ(GarminVenuSqCoordinator.class),
+ GARMIN_VENU_SQ_2(GarminVenuSq2Coordinator.class),
+ GARMIN_VENU_2(GarminVenu2Coordinator.class),
+ GARMIN_VENU_2S(GarminVenu2SCoordinator.class),
+ GARMIN_VENU_2_PLUS(GarminVenu2PlusCoordinator.class),
+ GARMIN_VENU_3(GarminVenu3Coordinator.class),
+ GARMIN_VENU_3S(GarminVenu3SCoordinator.class),
+ GARMIN_VIVOACTIVE_3(GarminVivoActive3Coordinator.class),
+ GARMIN_VIVOACTIVE_4(GarminVivoActive4Coordinator.class),
+ GARMIN_VIVOACTIVE_4S(GarminVivoActive4SCoordinator.class),
+ GARMIN_VIVOACTIVE_5(GarminVivoActive5Coordinator.class),
+ GARMIN_VIVOSMART_5(GarminVivosmart5Coordinator.class),
+ GARMIN_VIVOSPORT(GarminVivosportCoordinator.class),
+ VIBRATISSIMO(VibratissimoCoordinator.class),
+ SONY_SWR12(SonySWR12DeviceCoordinator.class),
+ LIVEVIEW(LiveviewCoordinator.class),
+ WASPOS(WaspOSCoordinator.class),
+ UM25(UM25Coordinator.class),
+ DOMYOS_T540(DomyosT540Coordinator.class),
+ NOTHING_EAR1(Ear1Coordinator.class),
+ NOTHING_EAR2(Ear2Coordinator.class),
+ NOTHING_EAR_STICK(EarStickCoordinator.class),
+ NOTHING_CMF_BUDS_PRO_2(CmfBudsPro2Coordinator.class),
+ NOTHING_CMF_WATCH_PRO(CmfWatchProCoordinator.class),
+ NOTHING_CMF_WATCH_PRO_2(CmfWatchPro2Coordinator.class),
+ GALAXY_BUDS_PRO(GalaxyBudsProDeviceCoordinator.class),
+ GALAXY_BUDS_LIVE(GalaxyBudsLiveDeviceCoordinator.class),
+ GALAXY_BUDS(GalaxyBudsDeviceCoordinator.class),
+ GALAXY_BUDS2(GalaxyBuds2DeviceCoordinator.class),
+ GALAXY_BUDS2_PRO(GalaxyBuds2ProDeviceCoordinator.class),
+ SONY_WH_1000XM3(SonyWH1000XM3Coordinator.class),
+ SONY_WF_SP800N(SonyWFSP800NCoordinator.class),
+ SONY_WI_C100(SonyWIC100Coordinator.class),
+ SONY_WI_SP600N(SonyWISP600NCoordinator.class),
+ SONY_WH_1000XM4(SonyWH1000XM4Coordinator.class),
+ SONY_WF_1000XM3(SonyWF1000XM3Coordinator.class),
+ SONY_WH_1000XM2(SonyWH1000XM2Coordinator.class),
+ SONY_WF_1000XM4(SonyWF1000XM4Coordinator.class),
+ SONY_LINKBUDS(SonyLinkBudsCoordinator.class),
+ SONY_LINKBUDS_S(SonyLinkBudsSCoordinator.class),
+ SONY_WH_1000XM5(SonyWH1000XM5Coordinator.class),
+ SONY_WF_1000XM5(SonyWF1000XM5Coordinator.class),
+ SONY_WF_C500(SonyWFC500Coordinator.class),
+ SONY_WF_C700N(SonyWFC700NCoordinator.class),
+ SOUNDCORE_LIBERTY3_PRO(SoundcoreLiberty3ProCoordinator.class),
+ SOUNDCORE_LIBERTY4_NC(SoundcoreLiberty4NCCoordinator.class),
+ SOUNDCORE_MOTION300(SoundcoreMotion300Coordinator.class),
+ MOONDROP_SPACE_TRAVEL(MoondropSpaceTravelCoordinator.class),
+ BOSE_QC35(QC35Coordinator.class),
+ HONORBAND3(HonorBand3Coordinator.class),
+ HONORBAND4(HonorBand4Coordinator.class),
+ HONORBAND5(HonorBand5Coordinator.class),
+ HUAWEIBANDAW70(HuaweiBandAw70Coordinator.class),
+ HUAWEIBAND6(HuaweiBand6Coordinator.class),
+ HUAWEIWATCHGT(HuaweiWatchGTCoordinator.class),
+ HUAWEIBAND2PRO(HuaweiBand2ProCoordinator.class),
+ HUAWEIBAND3PRO(HuaweiBand3ProCoordinator.class),
+ HUAWEIBAND4PRO(HuaweiBand4ProCoordinator.class),
+ HUAWEIWATCHGT2(HuaweiWatchGT2Coordinator.class),
+ HUAWEIWATCHGT2E(HuaweiWatchGT2eCoordinator.class),
+ HUAWEITALKBANDB6(HuaweiTalkBandB6Coordinator.class),
+ HUAWEIBAND7(HuaweiBand7Coordinator.class),
+ HONORBAND6(HonorBand6Coordinator.class),
+ HONORBAND7(HonorBand7Coordinator.class),
+ HONORMAGICWATCH2(HonorMagicWatch2Coordinator.class),
+ HONORWATCHGS3(HonorWatchGS3Coordinator.class),
+ HONORWATCHGSPRO(HonorWatchGSProCoordinator.class),
+ HUAWEIWATCHD2(HuaweiWatchD2Coordinator.class),
+ HUAWEIWATCHGT3(HuaweiWatchGT3Coordinator.class),
+ HUAWEIWATCHGT4(HuaweiWatchGT4Coordinator.class),
+ HUAWEIWATCHGT5(HuaweiWatchGT5Coordinator.class),
+ HUAWEIWATCHGTRUNNER(HuaweiWatchGTRunnerCoordinator.class),
+ HUAWEIWATCHGTCYBER(HuaweiWatchGTCyberCoordinator.class),
+ HUAWEIBAND8(HuaweiBand8Coordinator.class),
+ HUAWEIBAND9(HuaweiBand9Coordinator.class),
+ HUAWEIWATCHFIT(HuaweiWatchFitCoordinator.class),
+ HUAWEIWATCHFIT2(HuaweiWatchFit2Coordinator.class),
+ HUAWEIWATCHFIT3(HuaweiWatchFit3Coordinator.class),
+ HUAWEIWATCHULTIMATE(HuaweiWatchUltimateCoordinator.class),
+ HUAWEIWATCH3(HuaweiWatch3Coordinator.class),
+ HUAWEIWATCH4PRO(HuaweiWatch4ProCoordinator.class),
+ HUAWEI_FREEBUDS5I(HuaweiFreebuds5iCoordinator.class),
+ VESC(VescCoordinator.class),
+ BINARY_SENSOR(BinarySensorCoordinator.class),
+ FLIPPER_ZERO(FlipperZeroCoordinator.class),
+ SUPER_CARS(SuperCarsCoordinator.class),
+ ASTEROIDOS(AsteroidOSDeviceCoordinator.class),
+ OPPO_ENCO_AIR(OppoEncoAirCoordinator.class),
+ OPPO_ENCO_AIR2(OppoEncoAir2Coordinator.class),
+ REALME_BUDS_T110(RealmeBudsT110Coordinator.class),
+ SOFLOW_SO6(SoFlowCoordinator.class),
+ WITHINGS_STEEL_HR(WithingsSteelHRDeviceCoordinator.class),
+ SONY_WENA_3(SonyWena3Coordinator.class),
+ FEMOMETER_VINCA2(FemometerVinca2DeviceCoordinator.class),
+ PIXOO(PixooCoordinator.class),
+ HAMA_FIT6900(HamaFit6900DeviceCoordinator.class),
+ COLMI_R02(ColmiR02Coordinator.class),
+ COLMI_R03(ColmiR03Coordinator.class),
+ COLMI_R06(ColmiR06Coordinator.class),
+ COLMI_R09(ColmiR09Coordinator.class),
+ COLMI_R10(ColmiR10Coordinator.class),
+ B_AND_W_P_SERIES(BandWPSeriesDeviceCoordinator.class),
+ SCANNABLE(ScannableDeviceCoordinator.class),
+ CYCLING_SENSOR(CyclingSensorCoordinator.class),
+ BLE_GATT_CLIENT(BleGattClientCoordinator.class),
+ MARSTEK_B2500(MarstekB2500DeviceCoordinator.class),
+ TEST(TestDeviceCoordinator.class);
+
+ private DeviceCoordinator coordinator;
+
+ private Class<? extends DeviceCoordinator> coordinatorClass;
+
+ DeviceType(Class<? extends DeviceCoordinator> coordinatorClass) {
+ this.coordinatorClass = coordinatorClass;
+ }
+
+ public boolean isSupported() {
+ return this != UNKNOWN;
+ }
+
+ public static DeviceType fromName(String name) {
+ for (DeviceType type : values()) {
+ if (type.name().equals(name)) {
+ return type;
+ }
+ }
+ return DeviceType.UNKNOWN;
+ }
+
+ public DeviceCoordinator getDeviceCoordinator() {
+ if(coordinator == null){
+ try {
+ coordinator = coordinatorClass.newInstance();
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ return coordinator;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java
new file mode 100644
index 0000000..eb75520
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java
@@ -0,0 +1,143 @@
+/* Copyright (C) 2017-2024 Carsten Pfeiffer, Jos茅 Rebelo, Petr Van臎k
+
+ 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.model;
+
+import android.location.Location;
+
+import androidx.annotation.NonNull;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Comparator;
+
+public class GPSCoordinate {
+ private final double latitude;
+ private final double longitude;
+ private final double altitude;
+
+ public static final double UNKNOWN_ALTITUDE = -20000d;
+
+ public static final int GPS_DECIMAL_DEGREES_SCALE = 6; // precise to 111.132mm at equator: https://en.wikipedia.org/wiki/Decimal_degrees
+
+ public GPSCoordinate(double longitude, double latitude, double altitude) {
+ this.longitude = longitude;
+ this.latitude = latitude;
+ this.altitude = altitude;
+ }
+
+ public GPSCoordinate(double longitude, double latitude) {
+ this(longitude, latitude, UNKNOWN_ALTITUDE);
+ }
+
+ public double getLatitude() {
+ return latitude;
+ }
+
+ public double getLongitude() {
+ return longitude;
+ }
+
+ public double getAltitude() {
+ return altitude;
+ }
+
+ public double getDistance(GPSCoordinate source) {
+ final Location end = new Location("end");
+ end.setLatitude(this.getLatitude());
+ end.setLongitude(this.getLongitude());
+
+ final Location start = new Location("start");
+ start.setLatitude(source.getLatitude());
+ start.setLongitude(source.getLongitude());
+
+ return end.distanceTo(start);
+ }
+
+ public double getAltitudeDifference(GPSCoordinate source) {
+ if (this.getAltitude() == UNKNOWN_ALTITUDE)
+ return 0;
+ if (source.getAltitude() == UNKNOWN_ALTITUDE)
+ return 0;
+ return this.getAltitude() - source.getAltitude();
+ }
+
+ public double getAscent(GPSCoordinate source) {
+ return Math.max(0, this.getAltitudeDifference(source));
+ }
+
+ public double getDescent(GPSCoordinate source) {
+ return Math.max(0, -this.getAltitudeDifference(source));
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ GPSCoordinate that = (GPSCoordinate) o;
+
+ if (Double.compare(that.getLatitude(), getLatitude()) != 0) return false;
+ if (Double.compare(that.getLongitude(), getLongitude()) != 0) return false;
+ return Double.compare(that.getAltitude(), getAltitude()) == 0;
+
+ }
+
+ @Override
+ public int hashCode() {
+ int result;
+ long temp;
+ temp = Double.doubleToLongBits(getLatitude());
+ result = (int) (temp ^ (temp >>> 32));
+ temp = Double.doubleToLongBits(getLongitude());
+ result = 31 * result + (int) (temp ^ (temp >>> 32));
+ temp = Double.doubleToLongBits(getAltitude());
+ result = 31 * result + (int) (temp ^ (temp >>> 32));
+ return result;
+ }
+
+ private String formatLocation(double value) {
+ return new BigDecimal(value).setScale(8, RoundingMode.HALF_UP).toPlainString();
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "lon: " + formatLocation(longitude) + ", lat: " + formatLocation(latitude) + ", alt: " + formatLocation(altitude) + "m";
+ }
+
+ public static class compareLatitude implements Comparator<GPSCoordinate> {
+ @Override
+ public int compare(GPSCoordinate trkPt1, GPSCoordinate trkPt2) {
+ return Double.compare(trkPt1.getLatitude(), trkPt2.getLatitude());
+ }
+ }
+
+ public static class compareLongitude implements Comparator<GPSCoordinate> {
+ @Override
+ public int compare(GPSCoordinate trkPt1, GPSCoordinate trkPt2) {
+ return Double.compare(trkPt1.getLongitude(), trkPt2.getLongitude());
+ }
+ }
+
+ public static class compareElevation implements Comparator<GPSCoordinate> {
+ @Override
+ public int compare(GPSCoordinate trkPt1, GPSCoordinate trkPt2) {
+ return Double.compare(trkPt1.getAltitude(), trkPt2.getAltitude());
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GenericItem.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GenericItem.java
new file mode 100644
index 0000000..94fbd0b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GenericItem.java
@@ -0,0 +1,153 @@
+/* Copyright (C) 2015-2024 Arjan Schrijver, Carsten Pfeiffer, Taavi Eom盲e
+
+ 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.model;
+
+import android.graphics.Bitmap;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.text.Collator;
+import java.util.Objects;
+
+public class GenericItem implements ItemWithDetails {
+ private String name;
+ private String details;
+ private int icon;
+ private boolean warning = false;
+ private Bitmap preview;
+
+ public static final Parcelable.Creator CREATOR = new Parcelable.Creator<GenericItem>() {
+ @Override
+ public GenericItem createFromParcel(Parcel source) {
+ GenericItem item = new GenericItem();
+ item.setName(source.readString());
+ item.setDetails(source.readString());
+ item.setIcon(source.readInt());
+ return item;
+ }
+
+ @Override
+ public GenericItem[] newArray(int size) {
+ return new GenericItem[size];
+ }
+ };
+
+ public GenericItem(String name, String details) {
+ this.name = name;
+ this.details = details;
+ }
+
+ public GenericItem(String name) {
+ this.name = name;
+ }
+
+ public GenericItem() {
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(name);
+ dest.writeString(details);
+ dest.writeInt(icon);
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public void setDetails(String details) {
+ if (details == null || details.equals("(Unknown version)")) {
+ this.warning = true;
+ }
+ this.details = details;
+ }
+
+ public void setIcon(int icon) {
+ this.icon = icon;
+ }
+
+ public boolean getWarning() {
+ return this.warning;
+ }
+
+ public void setWarning(boolean enable) {
+ this.warning = enable;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public String getDetails() {
+ return details;
+ }
+
+ @Override
+ public int getIcon() {
+ return icon;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public void setPreview(Bitmap preview) {
+ this.preview = preview;
+ }
+
+ public Bitmap getPreview() {
+ return preview;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ if (this == o) {
+ return true;
+ }
+
+ GenericItem that = (GenericItem) o;
+
+ return Objects.equals(name, that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return name != null ? name.hashCode() : 0;
+ }
+
+ @Override
+ public int compareTo(ItemWithDetails another) {
+ if (name.equals(another.getName())) {
+ return 0;
+ }
+
+ if (name == null) {
+ return +1;
+ } else if (another.getName() == null) {
+ return -1;
+ }
+
+ return Collator.getInstance().compare(name, another.getName());
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HeartRateSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HeartRateSample.java
new file mode 100644
index 0000000..d446c9b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HeartRateSample.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public interface HeartRateSample extends TimeSample {
+ /**
+ * Returns the heart rate value.
+ */
+ int getHeartRate();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HrvSummarySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HrvSummarySample.java
new file mode 100644
index 0000000..82a9d17
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HrvSummarySample.java
@@ -0,0 +1,80 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public interface HrvSummarySample extends TimeSample {
+ enum Status {
+ NONE(0),
+ POOR(1),
+ LOW(2),
+ UNBALANCED(3),
+ BALANCED(4),
+ ;
+
+ private final int num;
+
+ Status(final int num) {
+ this.num = num;
+ }
+
+ public int getNum() {
+ return num;
+ }
+
+ public static Status fromNum(final int num) {
+ for (Status value : Status.values()) {
+ if (value.getNum() == num) {
+ return value;
+ }
+ }
+
+ throw new IllegalArgumentException("Unknown hrv status num " + num);
+ }
+ }
+
+ /**
+ * Weekly average, in milliseconds.
+ */
+ Integer getWeeklyAverage();
+
+ /**
+ * Last night average, in milliseconds.
+ */
+ Integer getLastNightAverage();
+
+ /**
+ * Last night 5-min high, in milliseconds.
+ */
+ Integer getLastNight5MinHigh();
+
+ /**
+ * Baseline low upper, in milliseconds.
+ */
+ Integer getBaselineLowUpper();
+
+ /**
+ * Baseline balanced lower, in milliseconds.
+ */
+ Integer getBaselineBalancedLower();
+
+ /**
+ * Baseline balanced upper, in milliseconds.
+ */
+ Integer getBaselineBalancedUpper();
+
+ Status getStatus();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HrvValueSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HrvValueSample.java
new file mode 100644
index 0000000..153c70b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/HrvValueSample.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public interface HrvValueSample extends TimeSample {
+ /**
+ * HRV value, in milliseconds.
+ */
+ int getValue();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ItemWithDetails.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ItemWithDetails.java
new file mode 100644
index 0000000..60a9ccb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ItemWithDetails.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer
+
+ 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.model;
+
+import android.graphics.Bitmap;
+import android.os.Parcelable;
+
+public interface ItemWithDetails extends Parcelable, Comparable<ItemWithDetails> {
+ String getName();
+
+ String getDetails();
+
+ int getIcon();
+
+ Bitmap getPreview();
+
+ /**
+ * Equality is based on #getName() only.
+ *
+ * @param other
+ */
+ boolean equals(Object other);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Measurement.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Measurement.java
new file mode 100644
index 0000000..2d2de2a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Measurement.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2016-2024 Carsten Pfeiffer
+
+ 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.model;
+
+public class Measurement {
+ private final int value;
+ private final long timestamp;
+
+ public Measurement(int value, long timestamp) {
+ this.value = value;
+ this.timestamp = timestamp;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public int hashCode() {
+ return (int) (71 ^ value ^ timestamp);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o instanceof Measurement) {
+ Measurement m = (Measurement) o;
+ return timestamp == m.timestamp && value == m.value;
+ }
+ return super.equals(o);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/MusicSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/MusicSpec.java
new file mode 100644
index 0000000..215f9b7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/MusicSpec.java
@@ -0,0 +1,92 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Taavi Eom盲e
+
+ 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.model;
+
+import java.util.Objects;
+
+public class MusicSpec {
+ public static final int MUSIC_UNKNOWN = -1;
+
+ public static final int MUSIC_UNDEFINED = 0;
+ public static final int MUSIC_PLAY = 1;
+ public static final int MUSIC_PAUSE = 2;
+ public static final int MUSIC_PLAYPAUSE = 3;
+ public static final int MUSIC_NEXT = 4;
+ public static final int MUSIC_PREVIOUS = 5;
+
+ public String artist = null;
+ public String album = null;
+ public String track = null;
+ public int duration = MUSIC_UNKNOWN;
+ public int trackCount = MUSIC_UNKNOWN;
+ public int trackNr = MUSIC_UNKNOWN;
+
+ public MusicSpec() {
+
+ }
+
+ public MusicSpec(MusicSpec old) {
+ this.duration = old.duration;
+ this.trackCount = old.trackCount;
+ this.trackNr = old.trackNr;
+ this.track = old.track;
+ this.album = old.album;
+ this.artist = old.artist;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof MusicSpec)) {
+ return false;
+ }
+ MusicSpec musicSpec = (MusicSpec) obj;
+
+ return Objects.equals(this.artist, musicSpec.artist) &&
+ Objects.equals(this.album, musicSpec.album) &&
+ Objects.equals(this.track, musicSpec.track) &&
+ this.duration == musicSpec.duration &&
+ this.trackCount == musicSpec.trackCount &&
+ this.trackNr == musicSpec.trackNr;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = artist != null ? artist.hashCode() : 0;
+ result = 31 * result + (album != null ? album.hashCode() : 0);
+ result = 31 * result + (track != null ? track.hashCode() : 0);
+ result = 31 * result + duration;
+ result = 31 * result + trackCount;
+ result = 31 * result + trackNr;
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "MusicSpec{" +
+ "artist='" + artist + '\'' +
+ ", album='" + album + '\'' +
+ ", track='" + track + '\'' +
+ ", duration=" + duration +
+ ", trackCount=" + trackCount +
+ ", trackNr=" + trackNr +
+ '}';
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/MusicStateSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/MusicStateSpec.java
new file mode 100644
index 0000000..9af1747
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/MusicStateSpec.java
@@ -0,0 +1,91 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Avamander, Carsten Pfeiffer,
+ Daniele Gobbetti, Steffen Liebergeld, Taavi Eom盲e
+
+ 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.model;
+
+public class MusicStateSpec {
+ public static final int STATE_UNKNOWN = -1;
+
+ public static final int STATE_PLAYING = 0;
+ public static final int STATE_PAUSED = 1;
+ public static final int STATE_STOPPED = 2;
+
+ public static final int STATE_SHUFFLE_ENABLED = 1;
+
+ public byte state = STATE_UNKNOWN;
+ /**
+ * Position of the current media in seconds
+ */
+ public int position = STATE_UNKNOWN;
+ /**
+ * Speed of playback, usually 0 or 100 (full speed)
+ */
+ public int playRate = STATE_UNKNOWN;
+ public byte shuffle = STATE_UNKNOWN;
+ public byte repeat = STATE_UNKNOWN;
+
+ public MusicStateSpec() {
+
+ }
+
+ public MusicStateSpec(MusicStateSpec old) {
+ this.state = old.state;
+ this.position = old.position;
+ this.playRate = old.playRate;
+ this.shuffle = old.shuffle;
+ this.repeat = old.repeat;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof MusicStateSpec)) {
+ return false;
+ }
+ MusicStateSpec stateSpec = (MusicStateSpec) obj;
+
+ return this.state == stateSpec.state &&
+ Math.abs(this.position - stateSpec.position)<=2 &&
+ this.playRate == stateSpec.playRate &&
+ this.shuffle == stateSpec.shuffle &&
+ this.repeat == stateSpec.repeat;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = (int) state;
+//ignore the position -- it is taken into account in equals()
+//result = 31 * result + position;
+ result = 31 * result + playRate;
+ result = 31 * result + (int) shuffle;
+ result = 31 * result + (int) repeat;
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "MusicStateSpec{" +
+ "state=" + state +
+ ", position=" + position +
+ ", playRate=" + playRate +
+ ", shuffle=" + shuffle +
+ ", repeat=" + repeat +
+ '}';
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NavigationInfoSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NavigationInfoSpec.java
new file mode 100644
index 0000000..7d45506
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NavigationInfoSpec.java
@@ -0,0 +1,44 @@
+/* Copyright (C) 2021-2024 Andreas Shimokawa, Arjan Schrijver, Gordon Williams
+
+ 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.model;
+
+public class NavigationInfoSpec {
+ public static final int ACTION_CONTINUE = 1;
+ public static final int ACTION_TURN_LEFT = 2;
+ public static final int ACTION_TURN_LEFT_SLIGHTLY = 3;
+ public static final int ACTION_TURN_LEFT_SHARPLY = 4;
+ public static final int ACTION_TURN_RIGHT = 5;
+ public static final int ACTION_TURN_RIGHT_SLIGHTLY = 6;
+ public static final int ACTION_TURN_RIGHT_SHARPLY = 7;
+ public static final int ACTION_KEEP_LEFT = 8;
+ public static final int ACTION_KEEP_RIGHT = 9;
+ public static final int ACTION_UTURN_LEFT = 10;
+ public static final int ACTION_UTURN_RIGHT = 11;
+ public static final int ACTION_OFFROUTE = 12;
+ public static final int ACTION_ROUNDABOUT_RIGHT = 13;
+ public static final int ACTION_ROUNDABOUT_LEFT = 14;
+ public static final int ACTION_ROUNDABOUT_STRAIGHT = 15;
+ public static final int ACTION_ROUNDABOUT_UTURN = 16;
+ public static final int ACTION_FINISH = 17;
+ public static final int ACTION_MERGE = 18;
+
+ // ETA? Total Distance?
+ public String instruction;
+ public String distanceToTurn; ///< Distance to turn (as a string, eg "100m")
+ public int nextAction; ///< One of the ACTION_ constants
+ public String ETA; ///< Estimated time of Arrival
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java
new file mode 100644
index 0000000..778d4c7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java
@@ -0,0 +1,89 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Daniele
+ Gobbetti, Frank Slezak, Jos茅 Rebelo, mvn23, 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.model;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class NotificationSpec {
+ public int flags;
+ private static final AtomicInteger c = new AtomicInteger((int) (System.currentTimeMillis()/1000));
+ private int id;
+ public String key;
+ public long when;
+ public String sender;
+ public String phoneNumber;
+ public String title;
+ public String subject;
+ public String body;
+ public NotificationType type;
+ public String sourceName;
+ public String[] cannedReplies;
+ /**
+ * Wearable actions that were attached to the incoming notifications and will be passed to the gadget (includes the "reply" action)
+ */
+ public ArrayList<Action> attachedActions;
+ /**
+ * The application that generated the notification.
+ */
+ public String sourceAppId;
+ /**
+ * The notification's icon ID
+ */
+ public int iconId;
+
+ public String picturePath;
+ /**
+ * The color that should be assigned to this notification when displayed on a Pebble
+ */
+ public byte pebbleColor;
+
+ public int dndSuppressed;
+
+ public NotificationSpec() {
+ this(-1);
+ }
+
+ public NotificationSpec(int id) {
+ if (id != -1)
+ this.id = id;
+ else
+ this.id = c.incrementAndGet();
+ this.when = System.currentTimeMillis();
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public static class Action implements Serializable {
+ static final int TYPE_UNDEFINED = -1;
+ public static final int TYPE_WEARABLE_SIMPLE = 0;
+ public static final int TYPE_WEARABLE_REPLY = 1;
+ public static final int TYPE_SYNTECTIC_REPLY_PHONENR = 2;
+ public static final int TYPE_SYNTECTIC_DISMISS = 3;
+ public static final int TYPE_SYNTECTIC_DISMISS_ALL = 4;
+ public static final int TYPE_SYNTECTIC_MUTE = 5;
+ public static final int TYPE_SYNTECTIC_OPEN = 6;
+
+ public int type = TYPE_UNDEFINED;
+ public long handle;
+ public String title;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationType.java
new file mode 100644
index 0000000..3e73b76
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationType.java
@@ -0,0 +1,172 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, AnthonyDiGirolamo, Carsten
+ Pfeiffer, Daniele Gobbetti, Davis Mosenkovs, Frank Slezak, Jos茅 Rebelo,
+ Kaz Wolfe, Kevin Richter, Lukas Veneziano, Maxim Baz, musover
+
+ 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.model;
+
+import java.util.Arrays;
+import java.util.Comparator;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleColor;
+import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleIconID;
+
+public enum NotificationType {
+
+ // TODO: this this pebbleism needs to be moved somewhere else
+ UNKNOWN(PebbleIconID.NOTIFICATION_GENERIC, PebbleColor.DarkCandyAppleRed),
+
+ AMAZON(PebbleIconID.NOTIFICATION_AMAZON, PebbleColor.ChromeYellow),
+ BBM(PebbleIconID.NOTIFICATION_BLACKBERRY_MESSENGER, PebbleColor.DarkGray),
+ CONVERSATIONS(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.Inchworm),
+ FACEBOOK(PebbleIconID.NOTIFICATION_FACEBOOK, PebbleColor.CobaltBlue),
+ FACEBOOK_MESSENGER(PebbleIconID.NOTIFICATION_FACEBOOK_MESSENGER, PebbleColor.BlueMoon),
+ GENERIC_ALARM_CLOCK(PebbleIconID.ALARM_CLOCK, PebbleColor.Red),
+ GENERIC_CALENDAR(PebbleIconID.TIMELINE_CALENDAR, PebbleColor.BlueMoon),
+ GENERIC_EMAIL(PebbleIconID.GENERIC_EMAIL, PebbleColor.Orange),
+ GENERIC_NAVIGATION(PebbleIconID.LOCATION, PebbleColor.Orange),
+ GENERIC_PHONE(PebbleIconID.DURING_PHONE_CALL, PebbleColor.JaegerGreen),
+ GENERIC_SMS(PebbleIconID.GENERIC_SMS, PebbleColor.VividViolet),
+ GMAIL(PebbleIconID.NOTIFICATION_GMAIL, PebbleColor.Red),
+ GOOGLE_HANGOUTS(PebbleIconID.NOTIFICATION_GOOGLE_HANGOUTS, PebbleColor.JaegerGreen),
+ GOOGLE_INBOX(PebbleIconID.NOTIFICATION_GOOGLE_INBOX, PebbleColor.BlueMoon),
+ GOOGLE_MAPS(PebbleIconID.NOTIFICATION_GOOGLE_MAPS, PebbleColor.BlueMoon),
+ GOOGLE_MESSENGER(PebbleIconID.NOTIFICATION_GOOGLE_MESSENGER, PebbleColor.VividCerulean),
+ GOOGLE_PHOTOS(PebbleIconID.NOTIFICATION_GOOGLE_PHOTOS, PebbleColor.BlueMoon),
+ HIPCHAT(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.CobaltBlue),
+ INSTAGRAM(PebbleIconID.NOTIFICATION_INSTAGRAM, PebbleColor.CobaltBlue),
+ KAKAO_TALK(PebbleIconID.NOTIFICATION_KAKAOTALK, PebbleColor.Yellow),
+ KIK(PebbleIconID.NOTIFICATION_KIK, PebbleColor.IslamicGreen),
+ LIGHTHOUSE(PebbleIconID.NOTIFICATION_LIGHTHOUSE, PebbleColor.PictonBlue), // ??? - No idea what this is, but it works.
+ LINE(PebbleIconID.NOTIFICATION_LINE, PebbleColor.IslamicGreen),
+ LINKEDIN(PebbleIconID.NOTIFICATION_LINKEDIN, PebbleColor.CobaltBlue),
+ MAILBOX(PebbleIconID.NOTIFICATION_MAILBOX, PebbleColor.VividCerulean),
+ OUTLOOK(PebbleIconID.NOTIFICATION_OUTLOOK, PebbleColor.BlueMoon),
+ BUSINESS_CALENDAR(PebbleIconID.TIMELINE_CALENDAR, PebbleColor.BlueMoon),
+ RIOT(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.LavenderIndigo),
+ SIGNAL(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.BlueMoon),
+ WIRE(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.BlueMoon),
+ SKYPE(PebbleIconID.NOTIFICATION_SKYPE, PebbleColor.VividCerulean),
+ SLACK(PebbleIconID.NOTIFICATION_SLACK, PebbleColor.Folly),
+ SNAPCHAT(PebbleIconID.NOTIFICATION_SNAPCHAT, PebbleColor.Icterine),
+ TELEGRAM(PebbleIconID.NOTIFICATION_TELEGRAM, PebbleColor.VividCerulean),
+ THREEMA(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.JaegerGreen),
+ KONTALK(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.JaegerGreen),
+ ANTOX(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.JaegerGreen),
+ DISCORD(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.Purpureus),
+ TRANSIT(PebbleIconID.LOCATION, PebbleColor.JaegerGreen),
+ TWITTER(PebbleIconID.NOTIFICATION_TWITTER, PebbleColor.BlueMoon),
+ VIBER(PebbleIconID.NOTIFICATION_VIBER, PebbleColor.VividViolet),
+ WECHAT(PebbleIconID.NOTIFICATION_WECHAT, PebbleColor.KellyGreen),
+ WHATSAPP(PebbleIconID.NOTIFICATION_WHATSAPP, PebbleColor.IslamicGreen),
+ YAHOO_MAIL(PebbleIconID.NOTIFICATION_YAHOO_MAIL, PebbleColor.Indigo),
+ COL_REMINDER(PebbleIconID.NOTIFICATION_REMINDER, PebbleColor.IslamicGreen),
+ DELTACHAT(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.BlueMoon),
+ ELEMENT(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.Malachite),
+ ELEMENTX(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.Malachite),
+ MOLLY(PebbleIconID.NOTIFICATION_HIPCHAT, PebbleColor.LavenderIndigo);
+
+ // Note: if you add any more constants, update all clients as well
+
+ public final int icon;
+ public final byte color;
+
+ NotificationType(int icon, byte color) {
+ this.icon = icon;
+ this.color = color;
+ }
+
+ /**
+ * Returns the enum constant as a fixed String value, e.g. to be used
+ * as preference key. In case the keys are ever changed, this method
+ * may be used to bring backward compatibility.
+ */
+ public String getFixedValue() {
+ return name().toLowerCase();
+ }
+
+ public String getGenericType() {
+ switch (this) {
+ case GENERIC_EMAIL:
+ case GENERIC_NAVIGATION:
+ case GENERIC_SMS:
+ case GENERIC_PHONE:
+ case GENERIC_CALENDAR:
+ case GENERIC_ALARM_CLOCK:
+ return getFixedValue();
+ case FACEBOOK:
+ case TWITTER:
+ case SNAPCHAT:
+ case INSTAGRAM:
+ case LINKEDIN:
+ return "generic_social";
+ case CONVERSATIONS:
+ case FACEBOOK_MESSENGER:
+ case RIOT:
+ case SIGNAL:
+ case WIRE:
+ case TELEGRAM:
+ case THREEMA:
+ case KONTALK:
+ case ANTOX:
+ case WHATSAPP:
+ case GOOGLE_MESSENGER:
+ case GOOGLE_HANGOUTS:
+ case HIPCHAT:
+ case SKYPE:
+ case WECHAT:
+ case KIK:
+ case KAKAO_TALK:
+ case SLACK:
+ case LINE:
+ case VIBER:
+ case DISCORD:
+ case DELTACHAT:
+ case ELEMENT:
+ case ELEMENTX:
+ case MOLLY:
+ return "generic_chat";
+ case GMAIL:
+ case GOOGLE_INBOX:
+ case MAILBOX:
+ case OUTLOOK:
+ case YAHOO_MAIL:
+ return "generic_email";
+ case COL_REMINDER:
+ case UNKNOWN:
+ default:
+ return "generic";
+ }
+ }
+
+ public static NotificationType[] sortedValues() {
+ final NotificationType[] sorted = NotificationType.values();
+ Arrays.sort(sorted, new Comparator<NotificationType>() {
+ @Override public int compare(final NotificationType n1, final NotificationType n2) {
+ // Keep unknown first
+ if (n1.equals(NotificationType.UNKNOWN)) {
+ return -1;
+ } else if (n2.equals(NotificationType.UNKNOWN)) {
+ return 1;
+ }
+
+ return n1.name().compareToIgnoreCase(n2.name());
+ }
+ });
+
+ return sorted;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/PaiSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/PaiSample.java
new file mode 100644
index 0000000..afde7fc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/PaiSample.java
@@ -0,0 +1,35 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public interface PaiSample extends TimeSample {
+ float getPaiLow();
+
+ float getPaiModerate();
+
+ float getPaiHigh();
+
+ int getTimeLow();
+
+ int getTimeModerate();
+
+ int getTimeHigh();
+
+ float getPaiToday();
+
+ float getPaiTotal();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RecordedDataTypes.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RecordedDataTypes.java
new file mode 100644
index 0000000..560de73
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RecordedDataTypes.java
@@ -0,0 +1,39 @@
+/* Copyright (C) 2018-2024 Andreas Shimokawa, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public class RecordedDataTypes {
+ public static final int TYPE_ACTIVITY = 0x00000001;
+ public static final int TYPE_WORKOUTS = 0x00000002;
+ public static final int TYPE_GPS_TRACKS = 0x00000004;
+ public static final int TYPE_TEMPERATURE = 0x00000008;
+ public static final int TYPE_DEBUGLOGS = 0x00000010;
+ public static final int TYPE_SPO2 = 0x00000020;
+ public static final int TYPE_STRESS = 0x00000040;
+ public static final int TYPE_HEART_RATE = 0x00000080;
+ public static final int TYPE_PAI = 0x00000100;
+ public static final int TYPE_SLEEP_RESPIRATORY_RATE = 0x00000200;
+ public static final int TYPE_HUAMI_STATISTICS = 0x00000400;
+
+ public static final int TYPE_ALL = (int)0xffffffff;
+
+ // Types to fetch during sync - scheduled, sync button, etc.
+ // Does not include debug logs or workouts
+ public static final int TYPE_SYNC = TYPE_ACTIVITY | TYPE_GPS_TRACKS | TYPE_SPO2 | TYPE_STRESS |
+ TYPE_HEART_RATE | TYPE_PAI | TYPE_SLEEP_RESPIRATORY_RATE;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Reminder.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Reminder.java
new file mode 100644
index 0000000..77da461
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Reminder.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2021-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+import java.io.Serializable;
+import java.util.Date;
+
+public interface Reminder extends Serializable {
+ /**
+ * The {@link android.os.Bundle} name for transferring parceled reminders.
+ */
+ String EXTRA_REMINDER = "reminder";
+
+ int ONCE = 0;
+ int EVERY_DAY = 1;
+ int EVERY_WEEK = 2;
+ int EVERY_MONTH = 3;
+ int EVERY_YEAR = 4;
+
+ String getReminderId();
+ String getMessage();
+ Date getDate();
+ int getRepetition();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RespiratoryRateSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RespiratoryRateSample.java
new file mode 100644
index 0000000..fa6a975
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RespiratoryRateSample.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public interface RespiratoryRateSample extends TimeSample {
+ /**
+ * Returns the respiratory rate value, in breaths per minute.
+ */
+ float getRespiratoryRate();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RestingMetabolicRateSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RestingMetabolicRateSample.java
new file mode 100644
index 0000000..312dfb1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RestingMetabolicRateSample.java
@@ -0,0 +1,23 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractTimeSample;
+
+public abstract class RestingMetabolicRateSample extends AbstractTimeSample {
+ public abstract int getRestingMetabolicRate();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SleepScoreSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SleepScoreSample.java
new file mode 100644
index 0000000..752215d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SleepScoreSample.java
@@ -0,0 +1,37 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractTimeSample;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+public abstract class SleepScoreSample extends AbstractTimeSample {
+ public abstract int getSleepScore();
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{" +
+ "timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
+ ", userId=" + getUserId() +
+ ", deviceId=" + getDeviceId() +
+ ", sleepScore=" + getSleepScore() +
+ "}";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SleepState.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SleepState.java
new file mode 100644
index 0000000..687a548
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SleepState.java
@@ -0,0 +1,23 @@
+/* Copyright (C) 2023-2024 Yoran Vulker
+
+ 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.model;
+
+public enum SleepState {
+ UNKNOWN,
+ ASLEEP,
+ AWAKE,
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Spo2Sample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Spo2Sample.java
new file mode 100644
index 0000000..67600ba
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Spo2Sample.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public interface Spo2Sample extends TimeSample {
+ enum Type {
+ MANUAL(0),
+ AUTOMATIC(1),
+ UNKNOWN(2),
+ ;
+
+ private final int num;
+
+ Type(final int num) {
+ this.num = num;
+ }
+
+ public int getNum() {
+ return num;
+ }
+
+ public static Type fromNum(final int num) {
+ for (Type value : Type.values()) {
+ if (value.getNum() == num) {
+ return value;
+ }
+ }
+
+ throw new IllegalArgumentException("Unknown num " + num);
+ }
+ }
+
+ /**
+ * Returns the measurement type for this SpO2 value.
+ */
+ Type getType();
+
+ /**
+ * Returns the SpO2 value between 0 and 100%.
+ */
+ int getSpo2();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/StressSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/StressSample.java
new file mode 100644
index 0000000..dcab60e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/StressSample.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public interface StressSample extends TimeSample {
+ enum Type {
+ MANUAL(0),
+ AUTOMATIC(1),
+ UNKNOWN(2),
+ ;
+
+ private final int num;
+
+ Type(final int num) {
+ this.num = num;
+ }
+
+ public int getNum() {
+ return num;
+ }
+
+ public static Type fromNum(final int num) {
+ for (Type value : Type.values()) {
+ if (value.getNum() == num) {
+ return value;
+ }
+ }
+
+ throw new IllegalArgumentException("Unknown num " + num);
+ }
+ }
+
+ /**
+ * Returns the measurement type for this stress value.
+ */
+ Type getType();
+
+ /**
+ * Returns the stress value between 0 and 100.
+ */
+ int getStress();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SummaryOfDay.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SummaryOfDay.java
new file mode 100644
index 0000000..feb902c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/SummaryOfDay.java
@@ -0,0 +1,28 @@
+/* Copyright (C) 2015-2024 Carsten Pfeiffer
+
+ 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.model;
+
+public interface SummaryOfDay {
+ byte getProvider();
+
+ int getSteps();
+
+ int getDayStartWakeupTime();
+
+ int getDayEndFallAsleepTime();
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TemperatureSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TemperatureSample.java
new file mode 100644
index 0000000..ea6e732
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TemperatureSample.java
@@ -0,0 +1,28 @@
+/* Copyright (C) 2023-2024 Alicia Hormann
+
+ 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.model;
+
+public interface TemperatureSample extends TimeSample {
+ /**
+ * Returns the temperature value.
+ */
+ float getTemperature();
+ /**
+ * Returns the temperature type (the position on the body where the measurement was taken).
+ */
+ int getTemperatureType();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TimeSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TimeSample.java
new file mode 100644
index 0000000..1514923
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TimeSample.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2023-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public interface TimeSample {
+ /**
+ * Unix timestamp of the sample, i.e. the number of milliseconds since 1970-01-01 00:00:00 UTC.
+ */
+ long getTimestamp();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TimeStamped.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TimeStamped.java
new file mode 100644
index 0000000..d909a54
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/TimeStamped.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2016-2024 Carsten Pfeiffer
+
+ 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.model;
+
+public interface TimeStamped {
+ /**
+ * Unix timestamp of the sample, i.e. the number of seconds since 1970-01-01 00:00:00 UTC.
+ */
+ int getTimestamp();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ValidByDate.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ValidByDate.java
new file mode 100644
index 0000000..6785d5e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ValidByDate.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2016-2024 Carsten Pfeiffer
+
+ 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.model;
+
+import java.util.Date;
+
+public interface ValidByDate {
+ Date getValidFromUTC();
+ Date getValidToUTC();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Vo2MaxSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Vo2MaxSample.java
new file mode 100644
index 0000000..a2fc499
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Vo2MaxSample.java
@@ -0,0 +1,32 @@
+/* Copyright (C) 2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+public interface Vo2MaxSample extends TimeSample {
+ enum Type {
+ ANY,
+ RUNNING,
+ CYCLING
+ }
+
+ Type getType();
+
+ /**
+ * The VO2 Max value, in ml/kg/min.
+ */
+ float getValue();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WearingState.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WearingState.java
new file mode 100644
index 0000000..853de61
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WearingState.java
@@ -0,0 +1,23 @@
+/* Copyright (C) 2023-2024 Yoran Vulker
+
+ 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.model;
+
+public enum WearingState {
+ UNKNOWN,
+ WEARING,
+ NOT_WEARING,
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Weather.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Weather.java
new file mode 100644
index 0000000..525c7be
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Weather.java
@@ -0,0 +1,1213 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Daniele Gobbetti, Jos茅 Rebelo,
+ Petr Van臎k, Sebastian Kranz
+
+ 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.model;
+
+import androidx.annotation.Nullable;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.ObjectStreamException;
+import java.util.ArrayList;
+import java.util.List;
+
+public class Weather {
+ private static final Logger LOG = LoggerFactory.getLogger(Weather.class);
+
+ private final ArrayList<WeatherSpec> weatherSpecs = new ArrayList<>();
+
+ private JSONObject reconstructedOWMForecast = null;
+
+ private File cacheFile;
+
+ private Weather() {
+ // Use getInstance
+ }
+
+ @Nullable
+ public WeatherSpec getWeatherSpec() {
+ if (weatherSpecs.isEmpty()) {
+ return null;
+ }
+
+ return weatherSpecs.get(0);
+ }
+
+ public List<WeatherSpec> getWeatherSpecs() {
+ return weatherSpecs;
+ }
+
+ public void setWeatherSpec(final List<WeatherSpec> newWeatherSpecs) {
+ weatherSpecs.clear();
+ weatherSpecs.addAll(newWeatherSpecs);
+ saveToCache();
+ }
+
+ public JSONObject createReconstructedOWMWeatherReply() {
+ final WeatherSpec weatherSpec = getWeatherSpec();
+ if (weatherSpec == null) {
+ return null;
+ }
+ JSONObject reconstructedOWMWeather = new JSONObject();
+ JSONArray weather = new JSONArray();
+ JSONObject condition = new JSONObject();
+ JSONObject main = new JSONObject();
+ JSONObject wind = new JSONObject();
+
+ try {
+ condition.put("id", weatherSpec.currentConditionCode);
+ condition.put("main", weatherSpec.currentCondition);
+ condition.put("description", weatherSpec.currentCondition);
+ condition.put("icon", Weather.mapToOpenWeatherMapIcon(weatherSpec.currentConditionCode));
+ weather.put(condition);
+
+
+ main.put("temp", weatherSpec.currentTemp);
+ main.put("humidity", weatherSpec.currentHumidity);
+ main.put("temp_min", weatherSpec.todayMinTemp);
+ main.put("temp_max", weatherSpec.todayMaxTemp);
+
+ wind.put("speed", (weatherSpec.windSpeed / 3.6f)); //meter per second
+ wind.put("deg", weatherSpec.windDirection);
+
+ reconstructedOWMWeather.put("weather", weather);
+ reconstructedOWMWeather.put("main", main);
+ reconstructedOWMWeather.put("name", weatherSpec.location);
+ reconstructedOWMWeather.put("wind", wind);
+
+ } catch (JSONException e) {
+ LOG.error("Error while reconstructing OWM weather reply");
+ return null;
+ }
+ LOG.debug("Weather JSON for WEBVIEW: " + reconstructedOWMWeather.toString());
+ return reconstructedOWMWeather;
+ }
+
+ public JSONObject getReconstructedOWMForecast() {
+ return reconstructedOWMForecast;
+ }
+
+ public void setReconstructedOWMForecast(JSONObject reconstructedOWMForecast) {
+ this.reconstructedOWMForecast = reconstructedOWMForecast;
+ }
+
+ private static final Weather weather = new Weather();
+ public static Weather getInstance() { return weather; }
+
+ public static byte mapToPebbleCondition(int openWeatherMapCondition) {
+/* deducted values:
+ 0 = sun + cloud
+ 1 = clouds
+ 2 = some snow
+ 3 = some rain
+ 4 = heavy rain
+ 5 = heavy snow
+ 6 = sun + cloud + rain (default icon?)
+ 7 = sun
+ 8 = rain + snow
+ 9 = 6
+ 10, 11, ... = empty icon
+ */
+ switch (openWeatherMapCondition) {
+//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 230: //thunderstorm with light drizzle: //11d
+ case 231: //thunderstorm with drizzle: //11d
+ case 232: //thunderstorm with heavy drizzle: //11d
+ case 212: //heavy thunderstorm: //11d
+ case 221: //ragged thunderstorm: //11d
+ return 4;
+//Group 3xx: Drizzle
+ case 300: //light intensity drizzle: //09d
+ case 301: //drizzle: //09d
+ case 302: //heavy intensity drizzle: //09d
+ case 310: //light intensity drizzle rain: //09d
+ case 311: //drizzle rain: //09d
+ case 312: //heavy intensity drizzle rain: //09d
+ case 313: //shower rain and drizzle: //09d
+ case 314: //heavy shower rain and drizzle: //09d
+ case 321: //shower drizzle: //09d
+ case 500: //light rain: //10d
+ case 501: //moderate rain: //10d
+ return 3;
+//Group 5xx: Rain
+ case 502: //heavy intensity rain: //10d
+ case 503: //very heavy rain: //10d
+ case 504: //extreme rain: //10d
+ case 511: //freezing rain: //13d
+ case 520: //light intensity shower rain: //09d
+ case 521: //shower rain: //09d
+ case 522: //heavy intensity shower rain: //09d
+ case 531: //ragged shower rain: //09d
+ return 4;
+//Group 6xx: Snow
+ case 600: //light snow: //[[file:13d.png]]
+ case 601: //snow: //[[file:13d.png]]
+ case 620: //light shower snow: //[[file:13d.png]]
+ return 2;
+ case 602: //heavy snow: //[[file:13d.png]]
+ case 611: //sleet: //[[file:13d.png]]
+ case 612: //shower sleet: //[[file:13d.png]]
+ case 621: //shower snow: //[[file:13d.png]]
+ case 622: //heavy shower snow: //[[file:13d.png]]
+ return 5;
+ case 615: //light rain and snow: //[[file:13d.png]]
+ case 616: //rain and snow: //[[file:13d.png]]
+ return 8;
+//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 741: //fog: //[[file:50d.png]]
+ case 751: //sand: //[[file:50d.png]]
+ case 761: //dust: //[[file:50d.png]]
+ case 762: //volcanic ash: //[[file:50d.png]]
+ case 771: //squalls: //[[file:50d.png]]
+ case 781: //tornado: //[[file:50d.png]]
+ case 900: //tornado
+ return 6;
+//Group 800: Clear
+ case 800: //clear sky: //[[file:01d.png]] [[file:01n.png]]
+ return 7;
+//Group 80x: Clouds
+ case 801: //few clouds: //[[file:02d.png]] [[file:02n.png]]
+ case 802: //scattered clouds: //[[file:03d.png]] [[file:03d.png]]
+ case 803: //broken clouds: //[[file:04d.png]] [[file:03d.png]]
+ case 804: //overcast clouds: //[[file:04d.png]] [[file:04d.png]]
+ return 0;
+//Group 90x: Extreme
+ case 901: //tropical storm
+ case 903: //cold
+ case 904: //hot
+ case 905: //windy
+ case 906: //hail
+//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:
+ return 6;
+
+ }
+ }
+
+ public static int mapToYahooCondition(int openWeatherMapCondition) {
+ // openweathermap.org conditions:
+ // http://openweathermap.org/weather-conditions
+ switch (openWeatherMapCondition) {
+//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 230: //thunderstorm with light drizzle: //11d
+ case 231: //thunderstorm with drizzle: //11d
+ case 232: //thunderstorm with heavy drizzle: //11d
+ return 4;
+ case 212: //heavy thunderstorm: //11d
+ case 221: //ragged thunderstorm: //11d
+ return 3;
+//Group 3xx: Drizzle
+ case 300: //light intensity drizzle: //09d
+ case 301: //drizzle: //09d
+ case 302: //heavy intensity drizzle: //09d
+ case 310: //light intensity drizzle rain: //09d
+ case 311: //drizzle rain: //09d
+ case 312: //heavy intensity drizzle rain: //09d
+ return 9;
+ case 313: //shower rain and drizzle: //09d
+ case 314: //heavy shower rain and drizzle: //09d
+ case 321: //shower drizzle: //09d
+ return 11;
+//Group 5xx: Rain
+ case 500: //light rain: //10d
+ case 501: //moderate rain: //10d
+ case 502: //heavy intensity rain: //10d
+ case 503: //very heavy rain: //10d
+ case 504: //extreme rain: //10d
+ case 511: //freezing rain: //13d
+ return 10;
+ case 520: //light intensity shower rain: //09d
+ return 40;
+ case 521: //shower rain: //09d
+ case 522: //heavy intensity shower rain: //09d
+ case 531: //ragged shower rain: //09d
+ return 12;
+//Group 6xx: Snow
+ case 600: //light snow: //[[file:13d.png]]
+ return 7;
+ case 601: //snow: //[[file:13d.png]]
+ return 16;
+ case 602: //heavy snow: //[[file:13d.png]]
+ return 15;
+ case 611: //sleet: //[[file:13d.png]]
+ case 612: //shower sleet: //[[file:13d.png]]
+ return 18;
+ case 615: //light rain and snow: //[[file:13d.png]]
+ case 616: //rain and snow: //[[file:13d.png]]
+ return 5;
+ case 620: //light shower snow: //[[file:13d.png]]
+ return 14;
+ case 621: //shower snow: //[[file:13d.png]]
+ return 46;
+ case 622: //heavy shower snow: //[[file:13d.png]]
+//Group 7xx: Atmosphere
+ case 701: //mist: //[[file:50d.png]]
+ case 711: //smoke: //[[file:50d.png]]
+ return 22;
+ case 721: //haze: //[[file:50d.png]]
+ return 21;
+ case 731: //sandcase dust whirls: //[[file:50d.png]]
+ return 3200;
+ case 741: //fog: //[[file:50d.png]]
+ return 20;
+ case 751: //sand: //[[file:50d.png]]
+ case 761: //dust: //[[file:50d.png]]
+ return 19;
+ case 762: //volcanic ash: //[[file:50d.png]]
+ case 771: //squalls: //[[file:50d.png]]
+ return 3200;
+ case 781: //tornado: //[[file:50d.png]]
+ case 900: //tornado
+ return 0;
+//Group 800: Clear
+ case 800: //clear sky: //[[file:01d.png]] [[file:01n.png]]
+ return 32;
+//Group 80x: Clouds
+ case 801: //few clouds: //[[file:02d.png]] [[file:02n.png]]
+ case 802: //scattered clouds: //[[file:03d.png]] [[file:03d.png]]
+ return 34;
+ case 803: //broken clouds: //[[file:04d.png]] [[file:03d.png]]
+ case 804: //overcast clouds: //[[file:04d.png]] [[file:04d.png]]
+ return 44;
+//Group 90x: Extreme
+ case 901: //tropical storm
+ return 1;
+ case 903: //cold
+ return 25;
+ case 904: //hot
+ return 36;
+ case 905: //windy
+ return 24;
+ case 906: //hail
+ return 17;
+//Group 9xx: Additional
+ case 951: //calm
+ case 952: //light breeze
+ case 953: //gentle breeze
+ case 954: //moderate breeze
+ case 955: //fresh breeze
+ return 34;
+ case 956: //strong breeze
+ case 957: //high windcase near gale
+ return 24;
+ case 958: //gale
+ case 959: //severe gale
+ case 960: //storm
+ case 961: //violent storm
+ return 3200;
+ case 902: //hurricane
+ case 962: //hurricane
+ return 2;
+ default:
+ return 3200;
+
+ }
+ }
+
+ public static String mapToOpenWeatherMapIcon(int openWeatherMapCondition) {
+ //see https://openweathermap.org/weather-conditions
+ String condition = "02d"; //generic "variable" icon
+
+ if (openWeatherMapCondition >= 200 && openWeatherMapCondition < 300) {
+ condition = "11d";
+ } else if (openWeatherMapCondition >= 300 && openWeatherMapCondition < 500) {
+ condition = "09d";
+ } else if (openWeatherMapCondition >= 500 && openWeatherMapCondition < 510) {
+ condition = "10d";
+ } else if (openWeatherMapCondition >= 511 && openWeatherMapCondition < 600) {
+ condition = "09d";
+ } else if (openWeatherMapCondition >= 600 && openWeatherMapCondition < 700) {
+ condition = "13d";
+ } else if (openWeatherMapCondition >= 700 && openWeatherMapCondition < 800) {
+ condition = "50d";
+ } else if (openWeatherMapCondition == 800) {
+ condition = "01d"; //TODO: night?
+ } else if (openWeatherMapCondition == 801) {
+ condition = "02d"; //TODO: night?
+ } else if (openWeatherMapCondition == 802) {
+ condition = "03d"; //TODO: night?
+ } else if (openWeatherMapCondition == 803 || openWeatherMapCondition == 804) {
+ condition = "04d"; //TODO: night?
+ }
+
+ return condition;
+ }
+
+ public static int mapToOpenWeatherMapCondition(int yahooCondition) {
+ switch (yahooCondition) {
+//yahoo weather conditions:
+//https://developer.yahoo.com/weather/documentation.html
+ case 0: //tornado
+ return 900;
+ case 1: //tropical storm
+ return 901;
+ case 2: //hurricane
+ return 962;
+ case 3: //severe thunderstorms
+ return 212;
+ case 4: //thunderstorms
+ return 211;
+ case 5: //mixed rain and snow
+ case 6: //mixed rain and sleet
+ return 616;
+ case 7: //mixed snow and sleet
+ return 600;
+ case 8: //freezing drizzle
+ case 9: //drizzle
+ return 301;
+ case 10: //freezing rain
+ return 511;
+ case 11: //showers
+ case 12: //showers
+ return 521;
+ case 13: //snow flurries
+ case 14: //light snow showers
+ return 620;
+ case 15: //blowing snow
+ case 41: //heavy snow
+ case 42: //scattered snow showers
+ case 43: //heavy snow
+ case 46: //snow showers
+ return 602;
+ case 16: //snow
+ return 601;
+ case 17: //hail
+ case 35: //mixed rain and hail
+ return 906;
+ case 18: //sleet
+ return 611;
+ case 19: //dust
+ return 761;
+ case 20: //foggy
+ return 741;
+ case 21: //haze
+ return 721;
+ case 22: //smoky
+ return 711;
+ case 23: //blustery
+ case 24: //windy
+ return 905;
+ case 25: //cold
+ return 903;
+ case 26: //cloudy
+ case 27: //mostly cloudy (night)
+ case 28: //mostly cloudy (day)
+ return 804;
+ case 29: //partly cloudy (night)
+ case 30: //partly cloudy (day)
+ return 801;
+ case 31: //clear (night)
+ case 32: //sunny
+ return 800;
+ case 33: //fair (night)
+ case 34: //fair (day)
+ return 801;
+ case 36: //hot
+ return 904;
+ case 37: //isolated thunderstorms
+ case 38: //scattered thunderstorms
+ case 39: //scattered thunderstorms
+ return 210;
+ case 40: //scattered showers
+ return 520;
+ case 44: //partly cloudy
+ return 801;
+ case 45: //thundershowers
+ case 47: //isolated thundershowers
+ return 211;
+ case 3200: //not available
+ default:
+ return -1;
+ }
+ }
+
+ public static String getConditionString(int openWeatherMapCondition) {
+ switch (openWeatherMapCondition) {
+ case 200:
+ return "thunderstorm with light rain";
+ case 201:
+ return "thunderstorm with rain";
+ case 202:
+ return "thunderstorm with heavy rain";
+ case 210:
+ return "light thunderstorm:";
+ case 211:
+ return "thunderstorm";
+ case 230:
+ return "thunderstorm with light drizzle";
+ case 231:
+ return "thunderstorm with drizzle";
+ case 232:
+ return "thunderstorm with heavy drizzle";
+ case 212:
+ return "heavy thunderstorm";
+ case 221:
+ return "ragged thunderstorm";
+ //Group 3xx: Drizzle
+ case 300:
+ return "light intensity drizzle";
+ case 301:
+ return "drizzle";
+ case 302:
+ return "heavy intensity drizzle";
+ case 310:
+ return "light intensity drizzle rain";
+ case 311:
+ return "drizzle rain";
+ case 312:
+ return "heavy intensity drizzle rain";
+ case 313:
+ return "shower rain and drizzle";
+ case 314:
+ return "heavy shower rain and drizzle";
+ case 321:
+ return "shower drizzle";
+ //Group 5xx: Rain
+ case 500:
+ return "light rain";
+ case 501:
+ return "moderate rain";
+ case 502:
+ return "heavy intensity rain";
+ case 503:
+ return "very heavy rain";
+ case 504:
+ return "extreme rain";
+ case 511:
+ return "freezing rain";
+ case 520:
+ return "light intensity shower rain";
+ case 521:
+ return "shower rain";
+ case 522:
+ return "heavy intensity shower rain";
+ case 531:
+ return "ragged shower rain";
+ //Group 6xx: Snow
+ case 600:
+ return "light snow";
+ case 601:
+ return "snow";
+ case 620:
+ return "light shower snow";
+ case 602:
+ return "heavy snow";
+ case 611:
+ return "sleet";
+ case 612:
+ return "shower sleet";
+ case 621:
+ return "shower snow";
+ case 622:
+ return "heavy shower snow";
+ case 615:
+ return "light rain and snow";
+ case 616:
+ return "rain and snow";
+ //Group 7xx: Atmosphere
+ case 701:
+ return "mist";
+ case 711:
+ return "smoke";
+ case 721:
+ return "haze";
+ case 731:
+ return "sandcase dust whirls";
+ case 741:
+ return "fog";
+ case 751:
+ return "sand";
+ case 761:
+ return "dust";
+ case 762:
+ return "volcanic ash";
+ case 771:
+ return "squalls";
+ case 781:
+ return "tornado";
+ case 900:
+ return "tornado";
+ case 800:
+ return "clear sky";
+ //Group 80x: Clouds
+ case 801:
+ return "few clouds";
+ case 802:
+ return "scattered clouds";
+ case 803:
+ return "broken clouds";
+ case 804:
+ return "overcast clouds";
+ //Group 90x: Extreme
+ case 901:
+ return "tropical storm";
+ case 903:
+ return "cold";
+ case 904:
+ return "hot";
+ case 905:
+ return "windy";
+ case 906:
+ return "hail";
+ //Group 9xx: Additional
+ case 951:
+ return "calm";
+ case 952:
+ return "light breeze";
+ case 953:
+ return "gentle breeze";
+ case 954:
+ return "moderate breeze";
+ case 955:
+ return "fresh breeze";
+ case 956:
+ return "strong breeze";
+ case 957:
+ return "high windcase near gale";
+ case 958:
+ return "gale";
+ case 959:
+ return "severe gale";
+ case 960:
+ return "storm";
+ case 961:
+ return "violent storm";
+ case 902:
+ return "hurricane";
+ case 962:
+ return "hurricane";
+ default:
+ return "";
+ }
+ }
+
+ public static byte mapToZeTimeConditionOld(int openWeatherMapCondition) {
+/* deducted values:
+ 0 = partly cloudy
+ 1 = cloudy
+ 2 = sunny
+ 3 = windy/gale
+ 4 = heavy rain
+ 5 = snowy
+ 6 = storm
+ */
+ switch (openWeatherMapCondition) {
+//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 230: //thunderstorm with light drizzle: //11d
+ case 231: //thunderstorm with drizzle: //11d
+ case 232: //thunderstorm with heavy drizzle: //11d
+ case 212: //heavy thunderstorm: //11d
+ case 221: //ragged thunderstorm: //11d
+//Group 7xx: Atmosphere
+ case 771: //squalls: //[[file:50d.png]]
+ case 781: //tornado: //[[file:50d.png]]
+//Group 90x: Extreme
+ case 900: //tornado
+ case 901: //tropical storm
+//Group 9xx: Additional
+ case 960: //storm
+ case 961: //violent storm
+ case 902: //hurricane
+ case 962: //hurricane
+ return 6;
+//Group 3xx: Drizzle
+ case 300: //light intensity drizzle: //09d
+ case 301: //drizzle: //09d
+ case 302: //heavy intensity drizzle: //09d
+ case 310: //light intensity drizzle rain: //09d
+ case 311: //drizzle rain: //09d
+ case 312: //heavy intensity drizzle rain: //09d
+ case 313: //shower rain and drizzle: //09d
+ case 314: //heavy shower rain and drizzle: //09d
+ case 321: //shower drizzle: //09d
+//Group 5xx: Rain
+ case 500: //light rain: //10d
+ case 501: //moderate rain: //10d
+ case 502: //heavy intensity rain: //10d
+ case 503: //very heavy rain: //10d
+ case 504: //extreme rain: //10d
+ case 511: //freezing rain: //13d
+ case 520: //light intensity shower rain: //09d
+ case 521: //shower rain: //09d
+ case 522: //heavy intensity shower rain: //09d
+ case 531: //ragged shower rain: //09d
+//Group 90x: Extreme
+ case 906: //hail
+ return 4;
+//Group 6xx: Snow
+ case 600: //light snow: //[[file:13d.png]]
+ case 601: //snow: //[[file:13d.png]]
+ case 620: //light shower snow: //[[file:13d.png]]
+ case 602: //heavy snow: //[[file:13d.png]]
+ case 611: //sleet: //[[file:13d.png]]
+ case 612: //shower sleet: //[[file:13d.png]]
+ case 621: //shower snow: //[[file:13d.png]]
+ case 622: //heavy shower snow: //[[file:13d.png]]
+ case 615: //light rain and snow: //[[file:13d.png]]
+ case 616: //rain and snow: //[[file:13d.png]]
+//Group 90x: Extreme
+ case 903: //cold
+ return 5;
+//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 741: //fog: //[[file:50d.png]]
+ case 751: //sand: //[[file:50d.png]]
+ case 761: //dust: //[[file:50d.png]]
+ case 762: //volcanic ash: //[[file:50d.png]]
+ return 1;
+//Group 800: Clear
+ case 800: //clear sky: //[[file:01d.png]] [[file:01n.png]]
+//Group 90x: Extreme
+ case 904: //hot
+ return 2;
+//Group 80x: Clouds
+ case 801: //few clouds: //[[file:02d.png]] [[file:02n.png]]
+ case 802: //scattered clouds: //[[file:03d.png]] [[file:03d.png]]
+ case 803: //broken clouds: //[[file:04d.png]] [[file:03d.png]]
+ case 804: //overcast clouds: //[[file:04d.png]] [[file:04d.png]]
+ default:
+ return 0;
+
+//Group 9xx: Additional
+ case 905: //windy
+ 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
+ return 3;
+ }
+ }
+
+ public static byte mapToZeTimeCondition(int openWeatherMapCondition) {
+/* deducted values:
+ 0 = tornado
+ 1 = typhoon
+ 2 = hurricane
+ 3 = thunderstorm
+ 4 = rain and snow
+ 5 = unavailable
+ 6 = freezing rain
+ 7 = drizzle
+ 8 = showers
+ 9 = snow flurries
+ 10 = blowing snow
+ 11 = snow
+ 12 = sleet
+ 13 = foggy
+ 14 = windy
+ 15 = cloudy
+ 16 = partly cloudy (night)
+ 17 = partly cloudy (day)
+ 18 = clear night
+ 19 = sunny
+ 20 = thundershower
+ 21 = hot
+ 22 = scattered thunders
+ 23 = snow showers
+ 24 = heavy snow
+ */
+ switch (openWeatherMapCondition) {
+//Group 2xx: Thunderstorm
+ case 210: //light thunderstorm:: //11d
+ return 22;
+
+//Group 2xx: Thunderstorm
+ case 200: //thunderstorm with light rain: //11d
+ case 201: //thunderstorm with rain: //11d
+ case 202: //thunderstorm with heavy rain: //11d
+ case 230: //thunderstorm with light drizzle: //11d
+ case 231: //thunderstorm with drizzle: //11d
+ case 232: //thunderstorm with heavy drizzle: //11d
+ return 20;
+
+//Group 2xx: Thunderstorm
+ case 211: //thunderstorm: //11d
+ case 212: //heavy thunderstorm: //11d
+ case 221: //ragged thunderstorm: //11d
+ return 3;
+
+//Group 7xx: Atmosphere
+ case 781: //tornado: //[[file:50d.png]]
+//Group 90x: Extreme
+ case 900: //tornado
+ return 0;
+
+//Group 90x: Extreme
+ case 901: //tropical storm
+ return 1;
+
+// Group 7xx: Atmosphere
+ case 771: //squalls: //[[file:50d.png]]
+//Group 9xx: Additional
+ case 960: //storm
+ case 961: //violent storm
+ case 902: //hurricane
+ case 962: //hurricane
+ return 2;
+
+//Group 3xx: Drizzle
+ case 300: //light intensity drizzle: //09d
+ case 301: //drizzle: //09d
+ case 302: //heavy intensity drizzle: //09d
+ case 310: //light intensity drizzle rain: //09d
+ case 311: //drizzle rain: //09d
+ case 312: //heavy intensity drizzle rain: //09d
+ case 313: //shower rain and drizzle: //09d
+ case 314: //heavy shower rain and drizzle: //09d
+ case 321: //shower drizzle: //09d
+ return 7;
+
+//Group 5xx: Rain
+ case 500: //light rain: //10d
+ case 501: //moderate rain: //10d
+ case 502: //heavy intensity rain: //10d
+ case 503: //very heavy rain: //10d
+ case 504: //extreme rain: //10d
+ case 520: //light intensity shower rain: //09d
+ case 521: //shower rain: //09d
+ case 522: //heavy intensity shower rain: //09d
+ case 531: //ragged shower rain: //09d
+//Group 90x: Extreme
+ case 906: //hail
+ return 8;
+
+//Group 5xx: Rain
+ case 511: //freezing rain: //13d
+ return 6;
+
+//Group 6xx: Snow
+ case 620: //light shower snow: //[[file:13d.png]]
+ case 621: //shower snow: //[[file:13d.png]]
+ case 622: //heavy shower snow: //[[file:13d.png]]
+ return 23;
+
+//Group 6xx: Snow
+ case 615: //light rain and snow: //[[file:13d.png]]
+ case 616: //rain and snow: //[[file:13d.png]]
+ return 4;
+
+//Group 6xx: Snow
+ case 611: //sleet: //[[file:13d.png]]
+ case 612: //shower sleet: //[[file:13d.png]]
+ return 12;
+
+//Group 6xx: Snow
+ case 600: //light snow: //[[file:13d.png]]
+ case 601: //snow: //[[file:13d.png]]
+ return 11;
+//Group 6xx: Snow
+ case 602: //heavy snow: //[[file:13d.png]]
+ return 24;
+
+//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 741: //fog: //[[file:50d.png]]
+ case 751: //sand: //[[file:50d.png]]
+ case 761: //dust: //[[file:50d.png]]
+ case 762: //volcanic ash: //[[file:50d.png]]
+ return 13;
+
+//Group 800: Clear
+ case 800: //clear sky: //[[file:01d.png]] [[file:01n.png]]
+ return 19;
+
+//Group 90x: Extreme
+ case 904: //hot
+ return 21;
+
+//Group 80x: Clouds
+ case 801: //few clouds: //[[file:02d.png]] [[file:02n.png]]
+ case 802: //scattered clouds: //[[file:03d.png]] [[file:03d.png]]
+ case 803: //broken clouds: //[[file:04d.png]] [[file:03d.png]]
+ return 17;
+
+//Group 80x: Clouds
+ case 804: //overcast clouds: //[[file:04d.png]] [[file:04d.png]]
+ return 15;
+
+//Group 9xx: Additional
+ case 905: //windy
+ 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
+ return 14;
+
+ default:
+//Group 90x: Extreme
+ case 903: //cold
+ return 5;
+ }
+ }
+ public static byte mapToCmfCondition(int openWeatherMapCondition) {
+/* deducted values:
+ 1 = sunny
+ 2 = cloudy
+ 3 = overcast
+ 4 = showers
+ 5 = snow showers
+ 6 = fog
+
+ 9 = thunder showers
+
+ 14 = sleet
+
+ 19 = hot (extreme)
+ 20 = cold (extreme)
+
+ 21 = strong wind
+ 22 = (night) sunny - with moon
+ 23 = (night) sunny with stars
+ 24 = (night) cloudy - with moon
+ 25 = sun with haze
+ 26 = cloudy (sun with cloud)
+ */
+ switch (openWeatherMapCondition) {
+//Group 2xx: Thunderstorm
+ case 210: //light thunderstorm:: //11d
+ case 200: //thunderstorm with light rain: //11d
+ case 201: //thunderstorm with rain: //11d
+ case 202: //thunderstorm with heavy rain: //11d
+ case 230: //thunderstorm with light drizzle: //11d
+ case 231: //thunderstorm with drizzle: //11d
+ case 232: //thunderstorm with heavy drizzle: //11d
+ case 211: //thunderstorm: //11d
+ case 212: //heavy thunderstorm: //11d
+ case 221: //ragged thunderstorm: //11d
+ return 9;
+
+//Group 90x: Extreme
+ case 901: //tropical storm
+//Group 7xx: Atmosphere
+ case 781: //tornado: //[[file:50d.png]]
+//Group 90x: Extreme
+ case 900: //tornado
+// Group 7xx: Atmosphere
+ case 771: //squalls: //[[file:50d.png]]
+//Group 9xx: Additional
+ case 960: //storm
+ case 961: //violent storm
+ case 902: //hurricane
+ case 962: //hurricane
+ return 21;
+
+//Group 3xx: Drizzle
+ case 300: //light intensity drizzle: //09d
+ case 301: //drizzle: //09d
+ case 302: //heavy intensity drizzle: //09d
+ case 310: //light intensity drizzle rain: //09d
+ case 311: //drizzle rain: //09d
+ case 312: //heavy intensity drizzle rain: //09d
+ case 313: //shower rain and drizzle: //09d
+ case 314: //heavy shower rain and drizzle: //09d
+ case 321: //shower drizzle: //09d
+//Group 5xx: Rain
+ case 500: //light rain: //10d
+ case 501: //moderate rain: //10d
+ case 502: //heavy intensity rain: //10d
+ case 503: //very heavy rain: //10d
+ case 504: //extreme rain: //10d
+ case 520: //light intensity shower rain: //09d
+ case 521: //shower rain: //09d
+ case 522: //heavy intensity shower rain: //09d
+ case 531: //ragged shower rain: //09d
+ return 4;
+
+//Group 90x: Extreme
+ case 906: //hail
+ case 615: //light rain and snow: //[[file:13d.png]]
+ case 616: //rain and snow: //[[file:13d.png]]
+ case 511: //freezing rain: //13d
+ return 14;
+
+//Group 6xx: Snow
+ case 611: //sleet: //[[file:13d.png]]
+ case 612: //shower sleet: //[[file:13d.png]]
+//Group 6xx: Snow
+ case 600: //light snow: //[[file:13d.png]]
+ case 601: //snow: //[[file:13d.png]]
+//Group 6xx: Snow
+ case 602: //heavy snow: //[[file:13d.png]]
+//Group 6xx: Snow
+ case 620: //light shower snow: //[[file:13d.png]]
+ case 621: //shower snow: //[[file:13d.png]]
+ case 622: //heavy shower snow: //[[file:13d.png]]
+ return 5;
+
+
+//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 741: //fog: //[[file:50d.png]]
+ case 751: //sand: //[[file:50d.png]]
+ case 761: //dust: //[[file:50d.png]]
+ case 762: //volcanic ash: //[[file:50d.png]]
+ return 6;
+
+//Group 800: Clear
+ case 800: //clear sky: //[[file:01d.png]] [[file:01n.png]]
+ return 1;
+
+//Group 90x: Extreme
+ case 904: //hot
+ return 19;
+
+//Group 80x: Clouds
+ case 801: //few clouds: //[[file:02d.png]] [[file:02n.png]]
+ case 802: //scattered clouds: //[[file:03d.png]] [[file:03d.png]]
+ return 26;
+ case 803: //broken clouds: //[[file:04d.png]] [[file:03d.png]]
+ return 2;
+
+//Group 80x: Clouds
+ case 804: //overcast clouds: //[[file:04d.png]] [[file:04d.png]]
+ return 3;
+
+//Group 9xx: Additional
+ case 905: //windy
+ 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
+ return 21;
+
+ default:
+//Group 90x: Extreme
+ case 903: //cold
+ return 20;
+ }
+ }
+
+ public static byte mapToFitProCondition(int openWeatherMapCondition) {
+ switch (openWeatherMapCondition) {
+ case 100:
+ return 1;
+ case 104:
+ return 2;
+ case 101:
+ case 102:
+ case 103:
+ return 3;
+ case 305:
+ case 309:
+ return 4;
+ case 306:
+ case 314:
+ case 399:
+ return 5;
+ case 307:
+ case 308:
+ case 310:
+ case 311:
+ case 312:
+ case 315:
+ case 316:
+ case 317:
+ case 318:
+ return 6;
+ case 300:
+ case 301:
+ case 302:
+ case 303:
+ return 7;
+ case 400:
+ case 407:
+ return 8;
+ case 401:
+ case 408:
+ case 499:
+ return 9;
+ case 402:
+ case 403:
+ case 409:
+ case 410:
+ return 10;
+ case 404:
+ case 405:
+ case 406:
+ return 11;
+ case 500:
+ case 501:
+ case 502:
+ case 509:
+ case 510:
+ case 511:
+ case 512:
+ case 513:
+ case 514:
+ case 515:
+ return 12;
+ case 304:
+ case 313:
+ return 13;
+ case 503:
+ case 504:
+ case 507:
+ case 508:
+ return 14;
+ case 200:
+ case 201:
+ case 202:
+ case 203:
+ case 204:
+ return 15;
+ case 205:
+ case 206:
+ case 207:
+ case 208:
+ return 16;
+ case 209:
+ case 210:
+ case 211:
+ return 17;
+ case 212:
+ return 18;
+ case 231:
+ return 19;
+ default:
+ return 3;
+ }
+ }
+
+ /**
+ * Set the weather cache file. If enabled and the current weather is null, load the cache file.
+ *
+ * @param cacheDir the cache directory, where the cache file will be created
+ * @param enabled whether caching is enabled
+ */
+ public void setCacheFile(final File cacheDir, final boolean enabled) {
+ // FIXME: Do not use serializable for this
+
+ cacheFile = new File(cacheDir, "weatherCache.bin");
+
+ if (enabled) {
+ LOG.info("Setting weather cache file to {}", cacheFile.getPath());
+
+ if (cacheFile.isFile() && weatherSpecs.isEmpty()) {
+ try (final ObjectInputStream o = new ObjectInputStream(new FileInputStream(cacheFile))) {
+ final ArrayList<WeatherSpec> cachedSpecs = (ArrayList<WeatherSpec>) o.readObject();
+ weatherSpecs.addAll(cachedSpecs);
+ } catch (final ObjectStreamException e) {
+ LOG.error("Failed to deserialize weather from cache", e);
+ // keep cacheFile set - it's most likely an older version
+ } catch (final IOException e) {
+ LOG.error("Failed to read weather cache file", e);
+ // Something is wrong with the file
+ cacheFile = null;
+ } catch (final Throwable e) {
+ LOG.error("Failed to read weather from cache", e);
+ // keep cacheFile set - it's most likely an older version
+ }
+ } else if (!weatherSpecs.isEmpty()) {
+ saveToCache();
+ }
+ } else {
+ if (cacheFile.isFile()) {
+ LOG.info("Deleting weather cache file {}", cacheFile.getPath());
+
+ try {
+ cacheFile.delete();
+ } catch (final Throwable e) {
+ LOG.error("Failed to delete cache file", e);
+ cacheFile = null;
+ }
+ }
+ }
+ }
+
+ /**
+ * Save the current weather to cache, if a cache file is enabled and the weather is not null.
+ */
+ public void saveToCache() {
+ if (weatherSpecs.isEmpty() || cacheFile == null) {
+ return;
+ }
+
+ LOG.info("Loading weather from cache {}", cacheFile.getPath());
+
+ try (ObjectOutputStream o = new ObjectOutputStream(new FileOutputStream(cacheFile))) {
+ o.writeObject(weatherSpecs);
+ } catch (final Throwable e) {
+ LOG.error("Failed to save weather to cache", e);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeatherSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeatherSpec.java
new file mode 100644
index 0000000..8a906f0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeatherSpec.java
@@ -0,0 +1,505 @@
+/* Copyright (C) 2016-2024 Andreas Shimokawa, Arjan Schrijver, beardhatcode,
+ Carsten Pfeiffer, Daniele Gobbetti, Enrico Brambilla, Jos茅 Rebelo, Taavi
+ Eom盲e
+
+ 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.model;
+
+import android.location.Location;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.Nullable;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+
+// FIXME: document me and my fields, including units
+public class WeatherSpec implements Parcelable, Serializable {
+
+ public static final Creator<WeatherSpec> CREATOR = new Creator<WeatherSpec>() {
+ @Override
+ public WeatherSpec createFromParcel(Parcel in) {
+ return new WeatherSpec(in);
+ }
+
+ @Override
+ public WeatherSpec[] newArray(int size) {
+ return new WeatherSpec[size];
+ }
+ };
+ public static final int VERSION = 4;
+ private static final long serialVersionUID = VERSION;
+ public int timestamp; // unix epoch timestamp, in seconds
+ public String location;
+ public int currentTemp; // kelvin
+ public int currentConditionCode = 3200; // OpenWeatherMap condition code
+ public String currentCondition;
+ public int currentHumidity;
+ public int todayMaxTemp; // kelvin
+ public int todayMinTemp; // kelvin
+ public float windSpeed; // km per hour
+ public int windDirection; // deg
+ public float uvIndex; // 0.0 to 15.0
+ public int precipProbability; // %
+ public int dewPoint; // kelvin
+ public float pressure; // mb
+ public int cloudCover; // %
+ public float visibility; // m
+ public int sunRise; // unix epoch timestamp, in seconds
+ public int sunSet; // unix epoch timestamp, in seconds
+ public int moonRise; // unix epoch timestamp, in seconds
+ public int moonSet; // unix epoch timestamp, in seconds
+ public int moonPhase; // deg [0, 360[
+ public float latitude;
+ public float longitude;
+ public int feelsLikeTemp; // kelvin
+ public int isCurrentLocation = -1; // 0 for false, 1 for true, -1 for unknown
+ public AirQuality airQuality;
+
+ // Forecasts from the next day onward, in chronological order, one entry per day.
+ // It should not include the current or previous days
+ public ArrayList<Daily> forecasts = new ArrayList<>();
+
+ // Hourly forecasts
+ public ArrayList<Hourly> hourly = new ArrayList<>();
+
+ public WeatherSpec() {
+
+ }
+
+ // Lower bounds of beaufort regions 1 to 12
+ // Values from https://en.wikipedia.org/wiki/Beaufort_scale
+ static final float[] beaufort = new float[] { 2, 6, 12, 20, 29, 39, 50, 62, 75, 89, 103, 118 };
+ // level: 0 1 2 3 4 5 6 7 8 9 10 11 12
+
+ public static int toBeaufort(final float speed) {
+ int l = 0;
+ while (l < beaufort.length && beaufort[l] < speed) {
+ l++;
+ }
+ return l;
+ }
+
+ public int windSpeedAsBeaufort() {
+ return toBeaufort(this.windSpeed);
+ }
+
+ @Nullable
+ public Location getLocation() {
+ if (latitude == 0 && longitude == 0) {
+ return null;
+ }
+ final Location location = new Location("weatherSpec");
+ location.setLatitude(latitude);
+ location.setLongitude(longitude);
+ return location;
+ }
+
+ protected WeatherSpec(Parcel in) {
+ int version = in.readInt();
+ if (version >= 2) {
+ timestamp = in.readInt();
+ location = in.readString();
+ currentTemp = in.readInt();
+ currentConditionCode = in.readInt();
+ currentCondition = in.readString();
+ currentHumidity = in.readInt();
+ todayMaxTemp = in.readInt();
+ todayMinTemp = in.readInt();
+ windSpeed = in.readFloat();
+ windDirection = in.readInt();
+ if (version < 4) {
+ // Deserialize the old Forecast list and convert them to Daily
+ final ArrayList<Forecast> oldForecasts = new ArrayList<>();
+ in.readList(oldForecasts, Forecast.class.getClassLoader());
+ for (final Forecast forecast : oldForecasts) {
+ final Daily d = new Daily();
+ d.minTemp = forecast.minTemp;
+ d.maxTemp = forecast.maxTemp;
+ d.conditionCode = forecast.conditionCode;
+ d.humidity = forecast.humidity;
+ forecasts.add(d);
+ }
+ } else {
+ in.readList(forecasts, Daily.class.getClassLoader());
+ }
+ }
+ if (version >= 3) {
+ uvIndex = in.readFloat();
+ precipProbability = in.readInt();
+ }
+ if (version >= 4) {
+ dewPoint = in.readInt();
+ pressure = in.readFloat();
+ cloudCover = in.readInt();
+ visibility = in.readFloat();
+ sunRise = in.readInt();
+ sunSet = in.readInt();
+ moonRise = in.readInt();
+ moonSet = in.readInt();
+ moonPhase = in.readInt();
+ latitude = in.readFloat();
+ longitude = in.readFloat();
+ feelsLikeTemp = in.readInt();
+ isCurrentLocation = in.readInt();
+ airQuality = in.readParcelable(AirQuality.class.getClassLoader());
+ in.readList(hourly, Hourly.class.getClassLoader());
+ }
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(VERSION);
+ dest.writeInt(timestamp);
+ dest.writeString(location);
+ dest.writeInt(currentTemp);
+ dest.writeInt(currentConditionCode);
+ dest.writeString(currentCondition);
+ dest.writeInt(currentHumidity);
+ dest.writeInt(todayMaxTemp);
+ dest.writeInt(todayMinTemp);
+ dest.writeFloat(windSpeed);
+ dest.writeInt(windDirection);
+ dest.writeList(forecasts);
+ dest.writeFloat(uvIndex);
+ dest.writeInt(precipProbability);
+ dest.writeInt(dewPoint);
+ dest.writeFloat(pressure);
+ dest.writeInt(cloudCover);
+ dest.writeFloat(visibility);
+ dest.writeInt(sunRise);
+ dest.writeInt(sunSet);
+ dest.writeInt(moonRise);
+ dest.writeInt(moonSet);
+ dest.writeInt(moonPhase);
+ dest.writeFloat(latitude);
+ dest.writeFloat(longitude);
+ dest.writeInt(feelsLikeTemp);
+ dest.writeInt(isCurrentLocation);
+ dest.writeParcelable(airQuality, 0);
+ dest.writeList(hourly);
+ }
+
+ /**
+ * Convert the current day's forecast to a {@link Daily} object.
+ */
+ public Daily todayAsDaily() {
+ final Daily daily = new Daily();
+
+ daily.minTemp = this.todayMinTemp;
+ daily.maxTemp = this.todayMaxTemp;
+ daily.conditionCode = this.currentConditionCode;
+ daily.humidity = this.currentHumidity;
+ daily.windSpeed = this.windSpeed;
+ daily.windDirection = this.windDirection;
+ daily.uvIndex = this.uvIndex;
+ daily.precipProbability = this.precipProbability;
+ daily.sunRise = this.sunRise;
+ daily.sunSet = this.sunSet;
+ daily.moonRise = this.moonRise;
+ daily.moonSet = this.moonSet;
+ daily.moonPhase = this.moonPhase;
+ daily.airQuality = this.airQuality;
+
+ return daily;
+ }
+
+ @Deprecated // kept for backwards compatibility with old weather apps
+ public static class Forecast implements Parcelable, Serializable {
+ private static final long serialVersionUID = 1L;
+
+ public static final Creator<Forecast> CREATOR = new Creator<Forecast>() {
+ @Override
+ public Forecast createFromParcel(Parcel in) {
+ return new Forecast(in);
+ }
+
+ @Override
+ public Forecast[] newArray(int size) {
+ return new Forecast[size];
+ }
+ };
+ public int minTemp; // Kelvin
+ public int maxTemp; // Kelvin
+ public int conditionCode; // OpenWeatherMap condition code
+ public int humidity;
+
+ public Forecast() {
+ }
+
+ public Forecast(int minTemp, int maxTemp, int conditionCode, int humidity) {
+ this.minTemp = minTemp;
+ this.maxTemp = maxTemp;
+ this.conditionCode = conditionCode;
+ this.humidity = humidity;
+ }
+
+ Forecast(Parcel in) {
+ minTemp = in.readInt();
+ maxTemp = in.readInt();
+ conditionCode = in.readInt();
+ humidity = in.readInt();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(minTemp);
+ dest.writeInt(maxTemp);
+ dest.writeInt(conditionCode);
+ dest.writeInt(humidity);
+ }
+ }
+
+ public static class AirQuality implements Parcelable, Serializable {
+ public static final int VERSION = 1;
+ private static final long serialVersionUID = VERSION;
+
+ public static final Creator<AirQuality> CREATOR = new Creator<AirQuality>() {
+ @Override
+ public AirQuality createFromParcel(final Parcel in) {
+ return new AirQuality(in);
+ }
+
+ @Override
+ public AirQuality[] newArray(final int size) {
+ return new AirQuality[size];
+ }
+ };
+
+ public int aqi = -1; // Air Quality Index - usually the max across all AQI values for pollutants
+
+ public float co = -1; // Carbon Monoxide, mg/m^3
+ public float no2 = -1; // Nitrogen Dioxide, ug/m^3
+ public float o3 = -1; // Ozone, ug/m^3
+ public float pm10 = -1; // Particulate Matter, 10 microns or less in diameter, ug/m^3
+ public float pm25 = -1; // Particulate Matter, 2.5 microns or less in diameter, ug/m^3
+ public float so2 = -1; // Sulphur Dioxide, ug/m^3
+
+ // Air Quality Index values per pollutant
+ // These are expected to be in the Plume scale (see https://plumelabs.files.wordpress.com/2023/06/plume_aqi_2023.pdf)
+ // Some apps such as Breezy Weather fallback to the WHO 2021 AQI for pollutants that are not mapped in the Plume AQI
+ // https://www.who.int/news-room/fact-sheets/detail/ambient-(outdoor)-air-quality-and-health
+ //
+ // Breezy Weather implementation for reference:
+ // - https://github.com/breezy-weather/breezy-weather/blob/main/app/src/main/java/org/breezyweather/common/basic/models/weather/AirQuality.kt
+ // - https://github.com/breezy-weather/breezy-weather/blob/main/app/src/main/java/org/breezyweather/common/basic/models/options/index/PollutantIndex.kt
+
+ public int coAqi = -1;
+ public int no2Aqi = -1;
+ public int o3Aqi = -1;
+ public int pm10Aqi = -1;
+ public int pm25Aqi = -1;
+ public int so2Aqi = -1;
+
+ public AirQuality() {
+ }
+
+ AirQuality(final Parcel in) {
+ in.readInt(); // version
+ aqi = in.readInt();
+ co = in.readFloat();
+ no2 = in.readFloat();
+ o3 = in.readFloat();
+ pm10 = in.readFloat();
+ pm25 = in.readFloat();
+ so2 = in.readFloat();
+ coAqi = in.readInt();
+ no2Aqi = in.readInt();
+ o3Aqi = in.readInt();
+ pm10Aqi = in.readInt();
+ pm25Aqi = in.readInt();
+ so2Aqi = in.readInt();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeInt(VERSION);
+ dest.writeInt(aqi);
+ dest.writeFloat(co);
+ dest.writeFloat(no2);
+ dest.writeFloat(o3);
+ dest.writeFloat(pm10);
+ dest.writeFloat(pm25);
+ dest.writeFloat(so2);
+ dest.writeInt(coAqi);
+ dest.writeInt(no2Aqi);
+ dest.writeInt(o3Aqi);
+ dest.writeInt(pm10Aqi);
+ dest.writeInt(pm25Aqi);
+ dest.writeInt(so2Aqi);
+ }
+ }
+
+ public static class Daily implements Parcelable, Serializable {
+ public static final int VERSION = 1;
+ private static final long serialVersionUID = VERSION;
+
+ public static final Creator<Daily> CREATOR = new Creator<Daily>() {
+ @Override
+ public Daily createFromParcel(final Parcel in) {
+ return new Daily(in);
+ }
+
+ @Override
+ public Daily[] newArray(final int size) {
+ return new Daily[size];
+ }
+ };
+ public int minTemp; // Kelvin
+ public int maxTemp; // Kelvin
+ public int conditionCode; // OpenWeatherMap condition code
+ public int humidity;
+ public float windSpeed; // km per hour
+ public int windDirection; // deg
+ public float uvIndex; // 0.0 to 15.0
+ public int precipProbability; // %
+ public int sunRise;
+ public int sunSet;
+ public int moonRise;
+ public int moonSet;
+ public int moonPhase;
+ public AirQuality airQuality;
+
+ public Daily() {
+ }
+
+ Daily(final Parcel in) {
+ in.readInt(); // version
+ minTemp = in.readInt();
+ maxTemp = in.readInt();
+ conditionCode = in.readInt();
+ humidity = in.readInt();
+ windSpeed = in.readFloat();
+ windDirection = in.readInt();
+ uvIndex = in.readFloat();
+ precipProbability = in.readInt();
+ sunRise = in.readInt();
+ sunSet = in.readInt();
+ moonRise = in.readInt();
+ moonSet = in.readInt();
+ moonPhase = in.readInt();
+ airQuality = in.readParcelable(AirQuality.class.getClassLoader());
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeInt(VERSION);
+ dest.writeInt(minTemp);
+ dest.writeInt(maxTemp);
+ dest.writeInt(conditionCode);
+ dest.writeInt(humidity);
+ dest.writeFloat(windSpeed);
+ dest.writeInt(windDirection);
+ dest.writeFloat(uvIndex);
+ dest.writeInt(precipProbability);
+ dest.writeInt(sunRise);
+ dest.writeInt(sunSet);
+ dest.writeInt(moonRise);
+ dest.writeInt(moonSet);
+ dest.writeInt(moonPhase);
+ dest.writeParcelable(airQuality, 0);
+ }
+
+ public int windSpeedAsBeaufort() {
+ return toBeaufort(this.windSpeed);
+ }
+ }
+
+ public static class Hourly implements Parcelable, Serializable {
+ public static final int VERSION = 1;
+ private static final long serialVersionUID = VERSION;
+
+ public static final Creator<Hourly> CREATOR = new Creator<Hourly>() {
+ @Override
+ public Hourly createFromParcel(final Parcel in) {
+ return new Hourly(in);
+ }
+
+ @Override
+ public Hourly[] newArray(final int size) {
+ return new Hourly[size];
+ }
+ };
+
+ public int timestamp; // unix epoch timestamp, in seconds
+ public int temp; // Kelvin
+ public int conditionCode; // OpenWeatherMap condition code
+ public int humidity;
+ public float windSpeed; // km per hour
+ public int windDirection; // deg
+ public float uvIndex; // 0.0 to 15.0
+ public int precipProbability; // %
+
+ public Hourly() {
+ }
+
+ Hourly(final Parcel in) {
+ in.readInt(); // version
+ timestamp = in.readInt();
+ temp = in.readInt();
+ conditionCode = in.readInt();
+ humidity = in.readInt();
+ windSpeed = in.readFloat();
+ windDirection = in.readInt();
+ uvIndex = in.readFloat();
+ precipProbability = in.readInt();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeInt(VERSION);
+ dest.writeInt(timestamp);
+ dest.writeInt(temp);
+ dest.writeInt(conditionCode);
+ dest.writeInt(humidity);
+ dest.writeFloat(windSpeed);
+ dest.writeInt(windDirection);
+ dest.writeFloat(uvIndex);
+ dest.writeInt(precipProbability);
+ }
+
+ public int windSpeedAsBeaufort() {
+ return toBeaufort(this.windSpeed);
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeightSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeightSample.java
new file mode 100644
index 0000000..fac2575
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeightSample.java
@@ -0,0 +1,24 @@
+/* Copyright (C) 2024 Severin von Wnuck-Lipinski
+
+ 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.model;
+
+public interface WeightSample extends TimeSample {
+ /**
+ * Returns the weight value.
+ */
+ float getWeightKg();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WorldClock.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WorldClock.java
new file mode 100644
index 0000000..44b0a08
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WorldClock.java
@@ -0,0 +1,32 @@
+/* Copyright (C) 2022-2024 Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.model;
+
+import java.io.Serializable;
+
+public interface WorldClock extends Serializable {
+ /**
+ * The {@link android.os.Bundle} name for transferring parceled world clocks.
+ */
+ String EXTRA_WORLD_CLOCK = "world_clock";
+
+ Boolean getEnabled();
+ String getWorldClockId();
+ String getLabel();
+ String getCode();
+ String getTimeZoneId();
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java
new file mode 100644
index 0000000..a079eb8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java
@@ -0,0 +1,1299 @@
+/* Copyright (C) 2015-2024 Alicia Hormann, Andreas B枚hler, Andreas Shimokawa,
+ Arjan Schrijver, Carsten Pfeiffer, Daniele Gobbetti, Davis Mosenkovs,
+ Dmitriy Bogdanov, foxstidious, Ganblejs, Jos茅 Rebelo, Pauli Salmenrinne,
+ Petr Van臎k, Taavi Eom盲e, Yoran Vulker
+
+ 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;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.companion.CompanionDeviceManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.graphics.Bitmap;
+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;
+
+import androidx.annotation.RequiresApi;
+import androidx.core.app.NotificationCompat;
+import androidx.core.content.FileProvider;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.CameraActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.FindPhoneActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AbstractAppManagerFragment;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.musicmanager.MusicManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
+import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventAppInfo;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCameraRemote;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventDisplayMessage;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFmFrequency;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventSleepStateDetection;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventSilentMode;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdateDeviceInfo;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventLEDColor;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdateDeviceState;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventScreenshot;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventWearState;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceMusicData;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceMusicUpdate;
+import nodomain.freeyourgadget.gadgetbridge.entities.BatteryLevel;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.NotificationListener;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks.OpenTracksController;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceMusic;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceMusicPlaylist;
+import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
+import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.Contact;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.Reminder;
+import nodomain.freeyourgadget.gadgetbridge.model.SleepState;
+import nodomain.freeyourgadget.gadgetbridge.model.WearingState;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.WorldClock;
+import nodomain.freeyourgadget.gadgetbridge.model.NavigationInfoSpec;
+import nodomain.freeyourgadget.gadgetbridge.service.receivers.GBCallControlReceiver;
+import nodomain.freeyourgadget.gadgetbridge.service.receivers.GBMusicControlReceiver;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.SilentMode;
+import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs;
+
+import static nodomain.freeyourgadget.gadgetbridge.util.GB.NOTIFICATION_CHANNEL_HIGH_PRIORITY_ID;
+
+// TODO: support option for a single reminder notification when notifications could not be delivered?
+// conditions: app was running and received notifications, but device was not connected.
+// maybe need to check for "unread notifications" on device for that.
+
+/**
+ * Abstract implementation of DeviceSupport with some implementations for
+ * common functionality. Still transport independent.
+ */
+public abstract class AbstractDeviceSupport implements DeviceSupport {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractDeviceSupport.class);
+ private static final int NOTIFICATION_ID_SCREENSHOT = 8000;
+
+ protected GBDevice gbDevice;
+ private BluetoothAdapter btAdapter;
+ private Context context;
+ private boolean autoReconnect, scanReconnect;
+
+
+
+ @Override
+ public void setContext(GBDevice gbDevice, BluetoothAdapter btAdapter, Context context) {
+ this.gbDevice = gbDevice;
+ this.btAdapter = btAdapter;
+ this.context = context;
+ }
+
+ /**
+ * Default implementation just calls #connect()
+ */
+ @Override
+ public boolean connectFirstTime() {
+ return connect();
+ }
+
+ @Override
+ public boolean isConnected() {
+ return gbDevice.isConnected();
+ }
+
+ /**
+ * Returns true if the device is not only connected, but also
+ * initialized.
+ *
+ * @see GBDevice#isInitialized()
+ */
+ protected boolean isInitialized() {
+ return gbDevice.isInitialized();
+ }
+
+ @Override
+ public void setAutoReconnect(boolean enable) {
+ autoReconnect = enable;
+ }
+
+ @Override
+ public boolean getAutoReconnect() {
+ return autoReconnect;
+ }
+
+ @Override
+ public void setScanReconnect(boolean scanReconnect) {
+ this.scanReconnect = scanReconnect;
+ }
+
+ @Override
+ public boolean getScanReconnect(){
+ return this.scanReconnect;
+ }
+
+ @Override
+ public GBDevice getDevice() {
+ return gbDevice;
+ }
+
+ @Override
+ public BluetoothAdapter getBluetoothAdapter() {
+ return btAdapter;
+ }
+
+ @Override
+ public Context getContext() {
+ return context;
+ }
+
+ public void evaluateGBDeviceEvent(GBDeviceEvent deviceEvent) {
+ if (deviceEvent instanceof GBDeviceEventMusicControl) {
+ handleGBDeviceEvent((GBDeviceEventMusicControl) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventCallControl) {
+ handleGBDeviceEvent((GBDeviceEventCallControl) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventCameraRemote) {
+ handleGBDeviceEvent((GBDeviceEventCameraRemote) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventVersionInfo) {
+ handleGBDeviceEvent((GBDeviceEventVersionInfo) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventAppInfo) {
+ handleGBDeviceEvent((GBDeviceEventAppInfo) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventScreenshot) {
+ handleGBDeviceEvent((GBDeviceEventScreenshot) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventNotificationControl) {
+ handleGBDeviceEvent((GBDeviceEventNotificationControl) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventBatteryInfo) {
+ handleGBDeviceEvent((GBDeviceEventBatteryInfo) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventFindPhone) {
+ handleGBDeviceEvent((GBDeviceEventFindPhone) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventLEDColor) {
+ handleGBDeviceEvent((GBDeviceEventLEDColor) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventUpdateDeviceInfo) {
+ handleGBDeviceEvent((GBDeviceEventUpdateDeviceInfo) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventUpdatePreferences) {
+ handleGBDeviceEvent((GBDeviceEventUpdatePreferences) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventUpdateDeviceState) {
+ handleGBDeviceEvent((GBDeviceEventUpdateDeviceState) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventSilentMode) {
+ handleGBDeviceEvent((GBDeviceEventSilentMode) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventFmFrequency) {
+ handleGBDeviceEvent((GBDeviceEventFmFrequency) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventWearState) {
+ handleGBDeviceEvent((GBDeviceEventWearState) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceEventSleepStateDetection) {
+ handleGBDeviceEvent((GBDeviceEventSleepStateDetection) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceMusicData) {
+ handleGBDeviceEvent((GBDeviceMusicData) deviceEvent);
+ } else if (deviceEvent instanceof GBDeviceMusicUpdate) {
+ handleGBDeviceEvent((GBDeviceMusicUpdate) deviceEvent);
+ }
+
+ }
+
+ private void handleGBDeviceEvent(GBDeviceEventSilentMode deviceEvent) {
+ LOG.info("Got GBDeviceEventSilentMode: enabled = {}", deviceEvent.isEnabled());
+
+ SilentMode.setPhoneSilentMode(getDevice().getAddress(), deviceEvent.isEnabled());
+ }
+
+ private void handleGBDeviceEvent(final GBDeviceEventFindPhone deviceEvent) {
+ final Context context = getContext();
+ LOG.info("Got GBDeviceEventFindPhone: {}", deviceEvent.event);
+ switch (deviceEvent.event) {
+ case START:
+ handleGBDeviceEventFindPhoneStart(true);
+ break;
+ case START_VIBRATE:
+ handleGBDeviceEventFindPhoneStart(false);
+ break;
+ case VIBRATE:
+ final Intent intentVibrate = new Intent(FindPhoneActivity.ACTION_VIBRATE);
+ intentVibrate.setPackage(BuildConfig.APPLICATION_ID);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intentVibrate);
+ break;
+ case RING:
+ final Intent intentRing = new Intent(FindPhoneActivity.ACTION_RING);
+ intentRing.setPackage(BuildConfig.APPLICATION_ID);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intentRing);
+ break;
+ case STOP:
+ final Intent intentStop = new Intent(FindPhoneActivity.ACTION_FOUND);
+ intentStop.setPackage(BuildConfig.APPLICATION_ID);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intentStop);
+ break;
+ default:
+ LOG.warn("unknown GBDeviceEventFindPhone");
+ }
+ }
+
+ private void handleGBDeviceEventFindPhoneStart(final boolean ring) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { // this could be used if app in foreground // TODO: Below Q?
+ Intent startIntent = new Intent(getContext(), FindPhoneActivity.class);
+ startIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startIntent.putExtra(FindPhoneActivity.EXTRA_RING, ring);
+ context.startActivity(startIntent);
+ } else {
+ handleGBDeviceEventFindPhoneStartNotification(ring);
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ private void handleGBDeviceEventFindPhoneStartNotification(final boolean ring) {
+ LOG.info("Got handleGBDeviceEventFindPhoneStartNotification");
+ final CompanionDeviceManager manager = (CompanionDeviceManager) context.getSystemService(Context.COMPANION_DEVICE_SERVICE);
+ if (manager.getAssociations().isEmpty()) {
+ // On Android Q and above, we need the device to be paired as companion. If it is not, display a notification
+ // notifying the user and linking to further instructions
+ final Intent instructionsIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://gadgetbridge.org/basics/pairing/companion-device/"));
+ final PendingIntent pi = PendingIntentUtils.getActivity(context, 0, instructionsIntent, PendingIntent.FLAG_ONE_SHOT, false);
+ final NotificationCompat.Builder notification = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_HIGH_PRIORITY_ID)
+ .setSmallIcon(R.drawable.ic_warning)
+ .setOngoing(false)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setContentIntent(pi)
+ .setAutoCancel(true)
+ .setContentTitle(context.getString(R.string.find_my_phone_notification))
+ .setContentText(context.getString(R.string.find_my_phone_companion_warning));
+
+ GB.notify(GB.NOTIFICATION_ID_PHONE_FIND, notification.build(), context);
+
+ return;
+ }
+
+ final Intent intent = new Intent(context, FindPhoneActivity.class);
+ intent.setPackage(BuildConfig.APPLICATION_ID);
+ intent.putExtra(FindPhoneActivity.EXTRA_RING, ring);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ final PendingIntent pi = PendingIntentUtils.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT, false);
+
+ final NotificationCompat.Builder notification = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_HIGH_PRIORITY_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setOngoing(false)
+ .setFullScreenIntent(pi, true)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setAutoCancel(true)
+ .setGroup("BackgroundService")
+ .setContentTitle(context.getString(R.string.find_my_phone_notification));
+
+ GB.notify(GB.NOTIFICATION_ID_PHONE_FIND, notification.build(), context);
+ context.startActivity(intent);
+ LOG.debug("CompanionDeviceManager associations were found, starting intent");
+ }
+
+
+ private void handleGBDeviceEvent(GBDeviceEventMusicControl musicEvent) {
+ Context context = getContext();
+ LOG.info("Got event for MUSIC_CONTROL");
+ Intent musicIntent = new Intent(GBMusicControlReceiver.ACTION_MUSICCONTROL);
+ musicIntent.putExtra("event", musicEvent.event.ordinal());
+ musicIntent.setPackage(context.getPackageName());
+ context.sendBroadcast(musicIntent);
+ }
+
+ private void handleGBDeviceEvent(GBDeviceEventCallControl callEvent) {
+ Context context = getContext();
+ LOG.info("Got event for CALL_CONTROL");
+ if(callEvent.event == GBDeviceEventCallControl.Event.IGNORE) {
+ LOG.info("Sending intent for mute");
+ Intent broadcastIntent = new Intent(context.getPackageName() + ".MUTE_CALL");
+ broadcastIntent.setPackage(context.getPackageName());
+ context.sendBroadcast(broadcastIntent);
+ return;
+ }
+ Intent callIntent = new Intent(GBCallControlReceiver.ACTION_CALLCONTROL);
+ callIntent.putExtra("event", callEvent.event.ordinal());
+ callIntent.setPackage(context.getPackageName());
+ context.sendBroadcast(callIntent);
+ }
+
+ protected void handleGBDeviceEvent(GBDeviceEventCameraRemote cameraRemoteEvent) {
+ Intent cameraIntent = new Intent(getContext(), CameraActivity.class);
+ cameraIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ cameraIntent.putExtra(CameraActivity.intentExtraEvent, GBDeviceEventCameraRemote.eventToInt(cameraRemoteEvent.event));
+ getContext().startActivity(cameraIntent);
+ }
+
+ protected void handleGBDeviceEvent(GBDeviceEventVersionInfo infoEvent) {
+ Context context = getContext();
+ LOG.info("Got event for VERSION_INFO: " + infoEvent);
+ if (gbDevice == null) {
+ return;
+ }
+ if (infoEvent.fwVersion != null) {
+ gbDevice.setFirmwareVersion(infoEvent.fwVersion);
+ }
+ if (infoEvent.fwVersion2 != null) {
+ gbDevice.setFirmwareVersion2(infoEvent.fwVersion2);
+ }
+ gbDevice.setModel(infoEvent.hwVersion);
+ gbDevice.sendDeviceUpdateIntent(context);
+ }
+
+ protected void handleGBDeviceEvent(GBDeviceEventLEDColor colorEvent) {
+ Context context = getContext();
+ LOG.info("Got event for LED Color: #" + Integer.toHexString(colorEvent.color).toUpperCase(Locale.ROOT));
+ if (gbDevice == null) {
+ return;
+ }
+ gbDevice.setExtraInfo("led_color", colorEvent.color);
+ gbDevice.sendDeviceUpdateIntent(context);
+ }
+
+ protected void handleGBDeviceEvent(GBDeviceEventUpdateDeviceInfo itemEvent) {
+ if (gbDevice == null) {
+ return;
+ }
+
+ gbDevice.addDeviceInfo(itemEvent.item);
+ gbDevice.sendDeviceUpdateIntent(context);
+ }
+
+ protected void handleGBDeviceEvent(GBDeviceEventUpdatePreferences savePreferencesEvent) {
+ if (gbDevice == null) {
+ return;
+ }
+
+ savePreferencesEvent.update(GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()));
+ gbDevice.sendDeviceUpdateIntent(context);
+ }
+
+ protected void handleGBDeviceEvent(GBDeviceEventUpdateDeviceState updateDeviceState) {
+ if (gbDevice == null) {
+ return;
+ }
+
+ gbDevice.setState(updateDeviceState.state);
+ gbDevice.sendDeviceUpdateIntent(getContext());
+ }
+
+ protected void handleGBDeviceEvent(GBDeviceEventFmFrequency frequencyEvent) {
+ Context context = getContext();
+ LOG.info("Got event for FM Frequency");
+ if (gbDevice == null) {
+ return;
+ }
+ gbDevice.setExtraInfo("fm_frequency", frequencyEvent.frequency);
+ gbDevice.sendDeviceUpdateIntent(context);
+ }
+
+ private void handleGBDeviceEvent(GBDeviceEventAppInfo appInfoEvent) {
+ Context context = getContext();
+ LOG.info("Got event for APP_INFO");
+
+ Intent appInfoIntent = new Intent(AbstractAppManagerFragment.ACTION_REFRESH_APPLIST);
+ int appCount = appInfoEvent.apps.length;
+ appInfoIntent.putExtra("app_count", appCount);
+ for (int i = 0; i < appCount; i++) {
+ appInfoIntent.putExtra("app_name" + i, appInfoEvent.apps[i].getName());
+ appInfoIntent.putExtra("app_creator" + i, appInfoEvent.apps[i].getCreator());
+ appInfoIntent.putExtra("app_version" + i, appInfoEvent.apps[i].getVersion());
+ appInfoIntent.putExtra("app_uuid" + i, appInfoEvent.apps[i].getUUID().toString());
+ appInfoIntent.putExtra("app_type" + i, appInfoEvent.apps[i].getType().ordinal());
+ }
+ LocalBroadcastManager.getInstance(context).sendBroadcast(appInfoIntent);
+ }
+
+ private void handleGBDeviceEvent(GBDeviceEventScreenshot screenshot) {
+ if (screenshot.getData() == null) {
+ LOG.warn("Screnshot data is null");
+ return;
+ }
+
+ SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd-hhmmss", Locale.US);
+ String filename = "screenshot_" + dateFormat.format(new Date()) + ".bmp";
+
+ try {
+ String fullpath = GB.writeScreenshot(screenshot, filename);
+ Bitmap bmp = BitmapFactory.decodeFile(fullpath);
+ Intent intent = new Intent();
+ intent.setAction(android.content.Intent.ACTION_VIEW);
+ Uri screenshotURI = FileProvider.getUriForFile(context, context.getApplicationContext().getPackageName() + ".screenshot_provider", new File(fullpath));
+ intent.setDataAndType(screenshotURI, "image/*");
+ intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+
+ PendingIntent pIntent = PendingIntentUtils.getActivity(context, 0, intent, 0, false);
+
+ Intent shareIntent = new Intent(Intent.ACTION_SEND);
+ shareIntent.setType("image/*");
+ shareIntent.putExtra(Intent.EXTRA_STREAM, screenshotURI);
+
+ PendingIntent pendingShareIntent = PendingIntentUtils.getActivity(context, 0, Intent.createChooser(shareIntent, context.getString(R.string.share_screenshot)),
+ PendingIntent.FLAG_UPDATE_CURRENT, false);
+
+ NotificationCompat.Action action = new NotificationCompat.Action.Builder(android.R.drawable.ic_menu_share, context.getString(R.string.share), pendingShareIntent).build();
+
+ Notification notif = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_HIGH_PRIORITY_ID)
+ .setContentTitle(context.getString(R.string.screenshot_taken))
+ .setTicker(context.getString(R.string.screenshot_taken))
+ .setContentText(filename)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setStyle(new NotificationCompat.BigPictureStyle()
+ .bigPicture(bmp))
+ .setContentIntent(pIntent)
+ .addAction(action)
+ .setAutoCancel(true)
+ .build();
+
+ GB.notify(NOTIFICATION_ID_SCREENSHOT, notif, context);
+ } catch (IOException ex) {
+ LOG.error("Error writing screenshot", ex);
+ }
+ }
+
+ private void handleGBDeviceEvent(GBDeviceEventNotificationControl deviceEvent) {
+ Context context = getContext();
+ LOG.info("Got NOTIFICATION CONTROL device event");
+ String action = null;
+ switch (deviceEvent.event) {
+ case DISMISS:
+ action = NotificationListener.ACTION_DISMISS;
+ break;
+ case DISMISS_ALL:
+ action = NotificationListener.ACTION_DISMISS_ALL;
+ break;
+ case OPEN:
+ action = NotificationListener.ACTION_OPEN;
+ break;
+ case MUTE:
+ action = NotificationListener.ACTION_MUTE;
+ break;
+ case REPLY:
+ if (deviceEvent.phoneNumber == null) {
+ deviceEvent.phoneNumber = GBApplication.getIDSenderLookup().lookup((int) (deviceEvent.handle >> 4));
+ }
+ if (deviceEvent.phoneNumber != null) {
+ LOG.info("Got notification reply for SMS from " + deviceEvent.phoneNumber + " : " + deviceEvent.reply);
+ SmsManager.getDefault().sendTextMessage(deviceEvent.phoneNumber, null, deviceEvent.reply, null, null);
+ } else {
+ LOG.info("Got notification reply for notification id " + deviceEvent.handle + " : " + deviceEvent.reply);
+ action = NotificationListener.ACTION_REPLY;
+ }
+ break;
+ }
+ if (action != null) {
+ Intent notificationListenerIntent = new Intent(action);
+ notificationListenerIntent.putExtra("handle", deviceEvent.handle);
+ notificationListenerIntent.putExtra("title", deviceEvent.title);
+ if (deviceEvent.reply != null) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
+ String suffix = prefs.getString("canned_reply_suffix", null);
+ if (suffix != null && !Objects.equals(suffix, "")) {
+ deviceEvent.reply += suffix;
+ }
+ notificationListenerIntent.putExtra("reply", deviceEvent.reply);
+ }
+ LocalBroadcastManager.getInstance(context).sendBroadcast(notificationListenerIntent);
+ }
+ }
+
+ protected void handleGBDeviceEvent(GBDeviceEventBatteryInfo deviceEvent) {
+ Context context = getContext();
+ LOG.info("Got BATTERY_INFO device event");
+ gbDevice.setBatteryLevel(deviceEvent.level, deviceEvent.batteryIndex);
+ gbDevice.setBatteryState(deviceEvent.state);
+ gbDevice.setBatteryVoltage(deviceEvent.voltage, deviceEvent.batteryIndex);
+
+ final DevicePrefs devicePrefs = GBApplication.getDevicePrefs(gbDevice);
+ final BatteryConfig batteryConfig = gbDevice.getDeviceCoordinator().getBatteryConfig(gbDevice)[deviceEvent.batteryIndex];
+
+ if (deviceEvent.level == GBDevice.BATTERY_UNKNOWN) {
+ // no level available, just "high" or "low"
+ GB.removeBatteryFullNotification(context);
+ if (devicePrefs.getBatteryNotifyLowEnabled(batteryConfig) && BatteryState.BATTERY_LOW.equals(deviceEvent.state)) {
+ GB.updateBatteryLowNotification(context.getString(R.string.notif_battery_low, gbDevice.getAliasOrName()),
+ deviceEvent.extendedInfoAvailable() ?
+ context.getString(R.string.notif_battery_low_extended, gbDevice.getAliasOrName(),
+ context.getString(R.string.notif_battery_low_bigtext_last_charge_time, DateFormat.getDateTimeInstance().format(deviceEvent.lastChargeTime.getTime())) +
+ context.getString(R.string.notif_battery_low_bigtext_number_of_charges, String.valueOf(deviceEvent.numCharges)))
+ : ""
+ , context);
+ } else if (devicePrefs.getBatteryNotifyFullEnabled(batteryConfig) && BatteryState.BATTERY_CHARGING_FULL.equals(deviceEvent.state)) {
+ GB.removeBatteryLowNotification(context);
+ GB.updateBatteryFullNotification(context.getString(R.string.notif_battery_full, gbDevice.getAliasOrName()), "", context);
+ } else {
+ GB.removeBatteryLowNotification(context);
+ GB.removeBatteryFullNotification(context);
+ }
+ } else {
+ createStoreTask("Storing battery data", context, deviceEvent).execute();
+
+ final boolean batteryNotifyLowEnabled = devicePrefs.getBatteryNotifyLowEnabled(batteryConfig);
+ final boolean isBatteryLow = deviceEvent.level <= devicePrefs.getBatteryNotifyLowThreshold(batteryConfig) &&
+ (BatteryState.BATTERY_LOW.equals(deviceEvent.state) || BatteryState.BATTERY_NORMAL.equals(deviceEvent.state));
+
+ final boolean batteryNotifyFullEnabled = devicePrefs.getBatteryNotifyFullEnabled(batteryConfig);
+ final boolean isBatteryFull = deviceEvent.level >= devicePrefs.getBatteryNotifyFullThreshold(batteryConfig) &&
+ (BatteryState.BATTERY_CHARGING.equals(deviceEvent.state) || BatteryState.BATTERY_CHARGING_FULL.equals(deviceEvent.state));
+
+ //show the notification if the battery level is below threshold and only if not connected to charger
+ if (batteryNotifyLowEnabled && isBatteryLow) {
+ GB.removeBatteryFullNotification(context);
+ GB.updateBatteryLowNotification(context.getString(R.string.notif_battery_low_percent, gbDevice.getAliasOrName(), String.valueOf(deviceEvent.level)),
+ deviceEvent.extendedInfoAvailable() ?
+ context.getString(R.string.notif_battery_low_percent, gbDevice.getAliasOrName(), String.valueOf(deviceEvent.level)) + "\n" +
+ context.getString(R.string.notif_battery_low_bigtext_last_charge_time, DateFormat.getDateTimeInstance().format(deviceEvent.lastChargeTime.getTime())) +
+ context.getString(R.string.notif_battery_low_bigtext_number_of_charges, String.valueOf(deviceEvent.numCharges))
+ : ""
+ , context);
+ } else if (batteryNotifyFullEnabled && isBatteryFull) {
+ GB.removeBatteryLowNotification(context);
+ GB.updateBatteryFullNotification(context.getString(R.string.notif_battery_full, gbDevice.getAliasOrName()), "", context);
+ } else {
+ GB.removeBatteryLowNotification(context);
+ GB.removeBatteryFullNotification(context);
+ }
+ }
+
+ gbDevice.sendDeviceUpdateIntent(context);
+ }
+
+ /**
+ * Helper method to run specific actions configured in the device preferences, upon wear state
+ * or awake/asleep events.
+ *
+ * @param actions
+ * @param message
+ */
+ private void handleDeviceAction(Set<String> actions, String message) {
+ if (actions.isEmpty()) {
+ return;
+ }
+
+ LOG.debug("Handing device actions: {}", TextUtils.join(",", actions));
+
+ final String actionBroadcast = getContext().getString(R.string.pref_device_action_broadcast_value);
+ final String actionFitnessControlStart = getContext().getString(R.string.pref_device_action_fitness_app_control_start_value);
+ final String actionFitnessControlStop = getContext().getString(R.string.pref_device_action_fitness_app_control_stop_value);
+ final String actionFitnessControlToggle = getContext().getString(R.string.pref_device_action_fitness_app_control_toggle_value);
+ final String actionMediaPlay = getContext().getString(R.string.pref_media_play_value);
+ final String actionMediaPause = getContext().getString(R.string.pref_media_pause_value);
+ final String actionMediaPlayPause = getContext().getString(R.string.pref_media_playpause_value);
+ final String actionDndOff = getContext().getString(R.string.pref_device_action_dnd_off_value);
+ final String actionDndpriority = getContext().getString(R.string.pref_device_action_dnd_priority_value);
+ final String actionDndAlarms = getContext().getString(R.string.pref_device_action_dnd_alarms_value);
+ final String actionDndOn = getContext().getString(R.string.pref_device_action_dnd_on_value);
+
+ if (actions.contains(actionBroadcast)) {
+ if (message != null) {
+ Intent in = new Intent();
+ in.setAction(message);
+ LOG.info("Sending broadcast {}", message);
+ getContext().getApplicationContext().sendBroadcast(in);
+ }
+ }
+
+ if (actions.contains(actionFitnessControlStart)) {
+ OpenTracksController.startRecording(getContext());
+ } else if (actions.contains(actionFitnessControlStop)) {
+ OpenTracksController.stopRecording(getContext());
+ } else if (actions.contains(actionFitnessControlToggle)) {
+ OpenTracksController.toggleRecording(getContext());
+ }
+
+ final String mediaAction;
+ if (actions.contains(actionMediaPlayPause)) {
+ mediaAction = actionMediaPlayPause;
+ } else if (actions.contains(actionMediaPause)) {
+ mediaAction = actionMediaPause;
+ } else if (actions.contains(actionMediaPlay)) {
+ mediaAction = actionMediaPlay;
+ } else {
+ mediaAction = null;
+ }
+
+ if (mediaAction != null) {
+ GBDeviceEventMusicControl deviceEventMusicControl = new GBDeviceEventMusicControl();
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.valueOf(mediaAction);
+ evaluateGBDeviceEvent(deviceEventMusicControl);
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+
+ final int interruptionFilter;
+ if (actions.contains(actionDndOff)) {
+ interruptionFilter = NotificationManager.INTERRUPTION_FILTER_ALL;
+ } else if (actions.contains(actionDndpriority)) {
+ interruptionFilter = NotificationManager.INTERRUPTION_FILTER_PRIORITY;
+ } else if (actions.contains(actionDndAlarms)) {
+ interruptionFilter = NotificationManager.INTERRUPTION_FILTER_ALARMS;
+ } else if (actions.contains(actionDndOn)) {
+ interruptionFilter = NotificationManager.INTERRUPTION_FILTER_NONE;
+ } else {
+ interruptionFilter = NotificationManager.INTERRUPTION_FILTER_UNKNOWN;
+ }
+
+ if (interruptionFilter != NotificationManager.INTERRUPTION_FILTER_UNKNOWN) {
+ LOG.debug("Setting do not disturb to {}", interruptionFilter);
+
+ if (!notificationManager.isNotificationPolicyAccessGranted()) {
+ LOG.warn("Do not disturb permissions not granted");
+ }
+
+ notificationManager.setInterruptionFilter(interruptionFilter);
+ }
+ }
+ }
+
+ private void handleGBDeviceEvent(GBDeviceEventSleepStateDetection event) {
+ LOG.debug("Got SLEEP_STATE_DETECTION device event, detected sleep state = {}", event.sleepState);
+
+ if (event.sleepState == SleepState.UNKNOWN) {
+ return;
+ }
+
+ String actionPreferenceKey, messagePreferenceKey;
+ int defaultBroadcastMessageResource;
+
+ switch (event.sleepState) {
+ case AWAKE:
+ actionPreferenceKey = DeviceSettingsPreferenceConst.PREF_DEVICE_ACTION_WOKE_UP_SELECTIONS;
+ messagePreferenceKey = DeviceSettingsPreferenceConst.PREF_DEVICE_ACTION_WOKE_UP_BROADCAST;
+ defaultBroadcastMessageResource = R.string.prefs_events_forwarding_wokeup_broadcast_default_value;
+ break;
+ case ASLEEP:
+ actionPreferenceKey = DeviceSettingsPreferenceConst.PREF_DEVICE_ACTION_FELL_SLEEP_SELECTIONS;
+ messagePreferenceKey = DeviceSettingsPreferenceConst.PREF_DEVICE_ACTION_FELL_SLEEP_BROADCAST;
+ defaultBroadcastMessageResource = R.string.prefs_events_forwarding_fellsleep_broadcast_default_value;
+ break;
+ default:
+ LOG.warn("Unable to deduce action and broadcast message preference key for sleep state {}", event.sleepState);
+ return;
+ }
+
+ Set<String> actions = getDevicePrefs().getStringSet(actionPreferenceKey, Collections.emptySet());
+
+ if (actions.isEmpty()) {
+ return;
+ }
+
+ String broadcastMessage = getDevicePrefs().getString(messagePreferenceKey, context.getString(defaultBroadcastMessageResource));
+ handleDeviceAction(actions, broadcastMessage);
+ }
+
+ private void handleGBDeviceEvent(GBDeviceEventWearState event) {
+ LOG.debug("Got WEAR_STATE device event, wearingState = {}", event.wearingState);
+
+ if (event.wearingState == WearingState.UNKNOWN) {
+ LOG.warn("WEAR_STATE state is UNKNOWN, aborting further evaluation");
+ return;
+ }
+
+ if (event.wearingState != WearingState.NOT_WEARING) {
+ LOG.debug("WEAR_STATE state is not NOT_WEARING, aborting further evaluation");
+ }
+
+ Set<String> actionOnUnwear = getDevicePrefs().getStringSet(
+ DeviceSettingsPreferenceConst.PREF_DEVICE_ACTION_START_NON_WEAR_SELECTIONS,
+ Collections.emptySet()
+ );
+
+ // check if an action is set
+ if (actionOnUnwear.isEmpty()) {
+ return;
+ }
+
+ String broadcastMessage = getDevicePrefs().getString(
+ DeviceSettingsPreferenceConst.PREF_DEVICE_ACTION_START_NON_WEAR_BROADCAST,
+ getContext().getString(R.string.prefs_events_forwarding_startnonwear_broadcast_default_value)
+ );
+
+ handleDeviceAction(actionOnUnwear, broadcastMessage);
+ }
+
+ private void handleGBDeviceEvent(GBDeviceMusicData deviceEvent) {
+ Context context = getContext();
+ LOG.info("Got event for ACTION_MUSIC_DATA");
+
+ Intent intent = new Intent(MusicManagerActivity.ACTION_MUSIC_DATA);
+
+ intent.putExtra("type", deviceEvent.type);
+
+ if(deviceEvent.list != null) {
+ ArrayList<GBDeviceMusic> list = new ArrayList<>(deviceEvent.list);
+ intent.putExtra("musicList", list);
+ }
+
+ if(deviceEvent.playlists != null) {
+ ArrayList<GBDeviceMusicPlaylist> list = new ArrayList<>(deviceEvent.playlists);
+ intent.putExtra("musicPlaylist", list);
+ }
+
+ if(!TextUtils.isEmpty(deviceEvent.deviceInfo)) {
+ intent.putExtra("deviceInfo", deviceEvent.deviceInfo);
+ }
+
+ if(deviceEvent.maxMusicCount > 0) {
+ intent.putExtra("maxMusicCount", deviceEvent.maxMusicCount);
+ }
+ if(deviceEvent.maxPlaylistCount > 0) {
+ intent.putExtra("maxPlaylistCount", deviceEvent.maxPlaylistCount);
+ }
+
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
+ }
+
+ private void handleGBDeviceEvent(GBDeviceMusicUpdate deviceEvent) {
+ Context context = getContext();
+ LOG.info("Got event for ACTION_MUSIC_UPDATE");
+
+ Intent intent = new Intent(MusicManagerActivity.ACTION_MUSIC_UPDATE);
+
+ intent.putExtra("success", deviceEvent.success);
+ intent.putExtra("operation", deviceEvent.operation);
+ intent.putExtra("playlistIndex", deviceEvent.playlistIndex);
+ intent.putExtra("playlistName", deviceEvent.playlistName);
+ intent.putExtra("musicIds", deviceEvent.musicIds);
+
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
+ }
+
+ private StoreDataTask createStoreTask(String task, Context context, GBDeviceEventBatteryInfo deviceEvent) {
+ return new StoreDataTask(task, context, deviceEvent);
+ }
+
+ public class StoreDataTask extends DBAccess {
+ GBDeviceEventBatteryInfo deviceEvent;
+
+ public StoreDataTask(String task, Context context, GBDeviceEventBatteryInfo deviceEvent) {
+ super(task, context);
+ this.deviceEvent = deviceEvent;
+ }
+
+ @Override
+ protected void doInBackground(DBHandler handler) {
+ DaoSession daoSession = handler.getDaoSession();
+ Device device = DBHelper.getDevice(gbDevice, daoSession);
+ int ts = (int) (System.currentTimeMillis() / 1000);
+ BatteryLevel batteryLevel = new BatteryLevel();
+ batteryLevel.setTimestamp(ts);
+ batteryLevel.setBatteryIndex(deviceEvent.batteryIndex);
+ batteryLevel.setDevice(device);
+ batteryLevel.setLevel(deviceEvent.level);
+ handler.getDaoSession().getBatteryLevelDao().insert(batteryLevel);
+ }
+ }
+
+ public void handleGBDeviceEvent(GBDeviceEventDisplayMessage message) {
+ GB.log(message.message, message.severity, null);
+
+ Intent messageIntent = new Intent(GB.ACTION_DISPLAY_MESSAGE);
+ messageIntent.putExtra(GB.DISPLAY_MESSAGE_MESSAGE, message.message);
+ messageIntent.putExtra(GB.DISPLAY_MESSAGE_DURATION, message.duration);
+ messageIntent.putExtra(GB.DISPLAY_MESSAGE_SEVERITY, message.severity);
+
+ LocalBroadcastManager.getInstance(context).sendBroadcast(messageIntent);
+ }
+
+ public DevicePrefs getDevicePrefs() {
+ return GBApplication.getDevicePrefs(gbDevice);
+ }
+
+ @Override
+ public String customStringFilter(String inputString) {
+ return inputString;
+ }
+
+
+ // Empty functions following, leaving optional implementation up to child classes
+
+ /**
+ * If the device supports a "find phone" functionality, this method can
+ * be overridden and implemented by the device support class.
+ * @param start true if starting the search, false if stopping
+ */
+ @Override
+ public void onFindPhone(boolean start) {
+
+ }
+
+ /**
+ * If the device supports a "find device" functionality, this method can
+ * be overridden and implemented by the device support class.
+ * @param start true if starting the search, false if stopping
+ */
+ @Override
+ public void onFindDevice(boolean start) {
+
+ }
+
+ /**
+ * If the device supports a "set FM frequency" functionality, this method
+ * can be overridden and implemented by the device support class.
+ * @param frequency the FM frequency to set
+ */
+ @Override
+ public void onSetFmFrequency(float frequency) {
+
+ }
+
+ /**
+ * If the device supports a "set LED color" functionality, this method
+ * can be overridden and implemented by the device support class.
+ * @param color the new color, in ARGB, with alpha = 255
+ */
+ @Override
+ public void onSetLedColor(int color) {
+
+ }
+
+ /**
+ * If the device can be turned off by sending a command, this method
+ * can be overridden and implemented by the device support class.
+ */
+ @Override
+ public void onPowerOff() {
+
+ }
+
+ /**
+ * If the device has a functionality to set the phone volume, this method
+ * can be overridden and implemented by the device support class.
+ * @param volume the volume percentage (0 to 100).
+ */
+ @Override
+ public void onSetPhoneVolume(final float volume) {
+
+ }
+
+ /**
+ * Called when the phone's interruption filter or ringer mode is changed.
+ * @param ringerMode as per {@link android.media.AudioManager#getRingerMode()}
+ */
+ @Override
+ public void onChangePhoneSilentMode(int ringerMode) {
+
+ }
+
+ /**
+ * If the device can receive the GPS location from the phone, this method
+ * can be overridden and implemented by the device support class.
+ * @param location {@link android.location.Location} object containing the current GPS coordinates
+ */
+ @Override
+ public void onSetGpsLocation(Location location) {
+
+ }
+
+ /**
+ * If reminders can be set on the device, this method can be
+ * overridden and implemented by the device support class.
+ * @param reminders {@link java.util.ArrayList} containing {@link nodomain.freeyourgadget.gadgetbridge.model.Reminder} instances
+ */
+ @Override
+ public void onSetReminders(ArrayList<? extends Reminder> reminders) {
+
+ }
+
+ /**
+ * If loyalty cards can be set on the device, this method can be
+ * overridden and implemented by the device support class.
+ * @param cards {@link java.util.ArrayList} containing {@link LoyaltyCard} instances
+ */
+ @Override
+ public void onSetLoyaltyCards(ArrayList<LoyaltyCard> cards) {
+
+ }
+
+ /**
+ * If world clocks can be configured on the device, this method can be
+ * overridden and implemented by the device support class.
+ * @param clocks {@link java.util.ArrayList} containing {@link nodomain.freeyourgadget.gadgetbridge.model.WorldClock} instances
+ */
+ @Override
+ public void onSetWorldClocks(ArrayList<? extends WorldClock> clocks) {
+
+ }
+
+ /**
+ * If contacts can be configured on the device, this method can be
+ * overridden and implemented by the device support class.
+ * @param contacts {@link java.util.ArrayList} containing {@link nodomain.freeyourgadget.gadgetbridge.model.Contact} instances
+ */
+ @Override
+ public void onSetContacts(ArrayList<? extends Contact> contacts) {
+
+ }
+
+ /**
+ * If the device can receive and display notifications, this method
+ * can be overridden and implemented by the device support class.
+ * @param notificationSpec notification details
+ */
+ @Override
+ public void onNotification(NotificationSpec notificationSpec) {
+
+ }
+
+ /**
+ * If notifications can be deleted from the device, this method can be
+ * overridden and implemented by the device support class.
+ * @param id the unique notification identifier
+ */
+ @Override
+ public void onDeleteNotification(int id) {
+
+ }
+
+ /**
+ * If the time can be set on the device, this method can be
+ * overridden and implemented by the device support class.
+ */
+ @Override
+ public void onSetTime() {
+
+ }
+
+ /**
+ * If alarms can be set on the device, this method can be
+ * overridden and implemented by the device support class.
+ * @param alarms {@link java.util.ArrayList} containing {@link nodomain.freeyourgadget.gadgetbridge.model.Alarm} instances
+ */
+ @Override
+ public void onSetAlarms(ArrayList<? extends Alarm> alarms) {
+
+ }
+
+ /**
+ * If the device can receive and show or handle phone call details, this
+ * method can be overridden and implemented by the device support class.
+ * @param callSpec the call state details
+ */
+ @Override
+ public void onSetCallState(CallSpec callSpec) {
+
+ }
+
+ /**
+ * If the device has a "canned messages" functionality, this method
+ * can be overridden and implemented by the device support class.
+ * @param cannedMessagesSpec the canned messages to send to the device
+ */
+ @Override
+ public void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) {
+
+ }
+
+ /**
+ * If the music play state can be set on the device, this method
+ * can be overridden and implemented by the device support class.
+ * @param stateSpec the current state of music playback
+ */
+ @Override
+ public void onSetMusicState(MusicStateSpec stateSpec) {
+
+ }
+
+ /**
+ * If the music information can be shown on the device, this method can be
+ * overridden and implemented by the device support class.
+ * @param musicSpec the current music information, like track name and artist
+ */
+ @Override
+ public void onSetMusicInfo(MusicSpec musicSpec) {
+
+ }
+
+ /**
+ * If apps can be installed on the device, this method can be
+ * overridden and implemented by the device support class.
+ * @param uri reference to a watch app file
+ */
+ @Override
+ public void onInstallApp(Uri uri) {
+
+ }
+
+ /**
+ * If the list of apps on the device can be retrieved, this method
+ * can be overridden and implemented by the device support class.
+ */
+ @Override
+ public void onAppInfoReq() {
+
+ }
+
+ /**
+ * If the device supports starting an app with a command, this method
+ * can be overridden and implemented by the device support class.
+ * @param uuid the Gadgetbridge internal UUID of the app
+ * @param start true to start, false to stop the app (if supported)
+ */
+ @Override
+ public void onAppStart(UUID uuid, boolean start) {
+
+ }
+
+ /**
+ * If apps can be downloaded from the device, this method can be
+ * overridden and implemented by the device support class.
+ * @param uuid the Gadgetbridge internal UUID of the app
+ */
+ @Override
+ public void onAppDownload(UUID uuid) {
+
+ }
+
+ /**
+ * If apps on the device can be deleted with a command, this method
+ * can be overridden and implemented by the device support class.
+ * @param uuid the Gadgetbridge internal UUID of the app
+ */
+ @Override
+ public void onAppDelete(UUID uuid) {
+
+ }
+
+ /**
+ * If apps on the device can be configured, this method can be
+ * overridden and implemented by the device support class.
+ * @param appUuid the Gadgetbridge internal UUID of the app
+ * @param config the configuration of the app
+ * @param id
+ */
+ @Override
+ public void onAppConfiguration(UUID appUuid, String config, Integer id) {
+
+ }
+
+ /**
+ * If apps on the device can be reordered, this method can be
+ * overridden and implemented by the device support class.
+ * @param uuids array of Gadgetbridge internal UUIDs of the apps
+ */
+ @Override
+ public void onAppReorder(UUID[] uuids) {
+
+ }
+
+ /**
+ * If recorded data can be fetched from the device, this method
+ * can be overridden and implemented by the device support class.
+ * @param dataTypes which data types to fetch
+ */
+ @Override
+ public void onFetchRecordedData(int dataTypes) {
+
+ }
+
+ /**
+ * If a device can be reset with a command, this method can be
+ * overridden and implemented by the device support class.
+ * @param flags can be used to pass flags with the reset command
+ */
+ @Override
+ public void onReset(int flags) {
+
+ }
+
+ /**
+ * If the device can perform a heart rate measurement on request, this
+ * method can be overridden and implemented by the device support class.
+ */
+ @Override
+ public void onHeartRateTest() {
+
+ }
+
+ /**
+ * If the device has the functionality to enable/disable realtime heart rate measurement,
+ * this method can be overridden and implemented by the device support class.
+ * @param enable true to enable, false to disable realtime heart rate measurement
+ */
+ @Override
+ public void onEnableRealtimeHeartRateMeasurement(boolean enable) {
+
+ }
+
+ /**
+ * If the device has the functionality to enable/disable realtime steps information,
+ * this method can be overridden and implemented by the device support class.
+ * @param enable true to enable, false to disable realtime steps
+ */
+ @Override
+ public void onEnableRealtimeSteps(boolean enable) {
+
+ }
+
+ /**
+ * If the device has a functionality to enable constant vibration, this
+ * method can be overridden and implemented by the device support class.
+ * @param integer the vibration intensity
+ */
+ @Override
+ public void onSetConstantVibration(int integer) {
+
+ }
+
+ /**
+ * If the device supports taking screenshots of the screen, this method can
+ * be overridden and implemented by the device support class.
+ */
+ @Override
+ public void onScreenshotReq() {
+
+ }
+
+ /**
+ * If the device has a toggle to enable the use of heart rate for sleep detection,
+ * this method can be overridden and implemented by the device support class.
+ * @param enable true to enable, false to disable using heart rate for sleep detection
+ */
+ @Override
+ public void onEnableHeartRateSleepSupport(boolean enable) {
+
+ }
+
+ /**
+ * If the heart rate measurement interval can be changed on the device,
+ * this method can be overridden and implemented by the device support class.
+ * @param seconds the interval to configure on the device
+ */
+ @Override
+ public void onSetHeartRateMeasurementInterval(int seconds) {
+
+ }
+
+ /**
+ * If calendar events can be sent to the device, this method can be
+ * overridden and implemented by the device support class.
+ * @param calendarEventSpec calendar event details
+ */
+ @Override
+ public void onAddCalendarEvent(CalendarEventSpec calendarEventSpec) {
+
+ }
+
+ /**
+ * If calendar events can be deleted from the device, this method can
+ * be overridden and implemented by the device support class.
+ * @param type type of calendar event
+ * @param id id of calendar event
+ */
+ @Override
+ public void onDeleteCalendarEvent(byte type, long id) {
+
+ }
+
+ /**
+ * If configuration options can be set on the device, this method
+ * can be overridden and implemented by the device support class.
+ * @param config the device specific option to set on the device
+ */
+ @Override
+ public void onSendConfiguration(String config) {
+
+ }
+
+ /**
+ * If the configuration can be retrieved from the device, this method
+ * can be overridden and implemented by the device support class.
+ * @param config the device specific option to get from the device
+ */
+ @Override
+ public void onReadConfiguration(String config) {
+
+ }
+
+ /**
+ * If the device can receive weather information, this method can be
+ * overridden and implemented by the device support class. It's guaranteed
+ * that there is always at least one weatherSpec, with the first being the
+ * primary weather (not necessarily current location).
+ * @param weatherSpecs weather information
+ */
+ @Override
+ public void onSendWeather(ArrayList<WeatherSpec> weatherSpecs) {
+
+ }
+
+ /**
+ * For testing new features, this method can be overridden and
+ * implemented by the device support class.
+ * It's called by clicking the "test new functionality" button
+ * in the Debug menu.
+ */
+ @Override
+ public void onTestNewFunction() {
+
+ }
+
+ @Override
+ public void onSetNavigationInfo(NavigationInfoSpec navigationInfoSpec) {
+
+ }
+
+ @Override
+ public void onSleepAsAndroidAction(String action, Bundle extras) {
+
+ }
+
+ @Override
+ public void onCameraStatusChange(GBDeviceEventCameraRemote.Event event, String filename) {}
+
+ @Override
+ public void onMusicListReq() {}
+
+ @Override
+ public void onMusicOperation(int operation, int playlistIndex, String playlistName, ArrayList<Integer> musicIds) {}
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractHeadphoneDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractHeadphoneDeviceSupport.java
new file mode 100644
index 0000000..97f1587
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractHeadphoneDeviceSupport.java
@@ -0,0 +1,65 @@
+/* Copyright (C) 2024 Arjan Schrijver, Daniele Gobbetti
+
+ 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;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.Context;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.AbstractSerialDeviceSupport;
+
+public abstract class AbstractHeadphoneDeviceSupport extends AbstractSerialDeviceSupport implements HeadphoneHelper.Callback {
+
+ private HeadphoneHelper headphoneHelper;
+
+ @Override
+ public void onSetCallState(CallSpec callSpec) {
+ headphoneHelper.onSetCallState(callSpec);
+ }
+
+ @Override
+ public void onNotification(NotificationSpec notificationSpec) {
+ headphoneHelper.onNotification(notificationSpec);
+ }
+
+ @Override
+ public void setContext(GBDevice gbDevice, BluetoothAdapter btAdapter, Context context) {
+ super.setContext(gbDevice, btAdapter, context);
+ headphoneHelper = new HeadphoneHelper(getContext(), getDevice(), this);
+ }
+
+ @Override
+ public boolean connect() {
+ getDeviceIOThread().start();
+ return true;
+ }
+
+ @Override
+ public void onSendConfiguration(String config) {
+ if (!headphoneHelper.onSendConfiguration(config))
+ super.onSendConfiguration(config);
+ }
+
+ @Override
+ public void dispose() {
+ if (headphoneHelper != null)
+ headphoneHelper.dispose();
+ super.dispose();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java
new file mode 100644
index 0000000..bcdf3c7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java
@@ -0,0 +1,1603 @@
+/* Copyright (C) 2015-2024 Andreas B枚hler, Andreas Shimokawa, Arjan
+ Schrijver, Avamander, Carsten Pfeiffer, Daniel Dakhno, Daniele Gobbetti,
+ Daniel Hauck, Davis Mosenkovs, Dikay900, Dmitriy Bogdanov, Frank Slezak,
+ Gabriele Monaco, Gordon Williams, ivanovlev, Joa虄o Paulo Barraca, Jos茅
+ Rebelo, Julien Pivotto, Kasha, keeshii, Martin, Matthieu Baerts, mvn23,
+ NekoBox, Nephiel, Petr Van臎k, Sebastian Kranz, Sergey Trofimov, Steffen
+ Liebergeld, Taavi Eom盲e, TylerWilliamson, Uwe Hermann, Yoran Vulker
+
+ 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;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.app.ActivityManager;
+import android.app.Service;
+import android.bluetooth.BluetoothDevice;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.content.pm.ServiceInfo;
+import android.location.Location;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCameraRemote;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.AlarmClockReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.AlarmReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.BluetoothConnectReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.BluetoothPairingRequestReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.CMWeatherReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.CalendarReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.DeviceSettingsReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.GenericWeatherReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.IntentApiReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.LineageOsWeatherReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.MusicPlaybackReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.OmniJawsObserver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.OsmandEventReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.PebbleReceiver;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.PhoneCallReceiver;
+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;
+import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.Contact;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NavigationInfoSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
+import nodomain.freeyourgadget.gadgetbridge.model.Reminder;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.WorldClock;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLEScanService;
+import nodomain.freeyourgadget.gadgetbridge.service.receivers.AutoConnectIntervalReceiver;
+import nodomain.freeyourgadget.gadgetbridge.service.receivers.GBAutoFetchReceiver;
+import nodomain.freeyourgadget.gadgetbridge.util.EmojiConverter;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import nodomain.freeyourgadget.gadgetbridge.util.language.LanguageUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.language.Transliterator;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.*;
+
+public class DeviceCommunicationService extends Service implements SharedPreferences.OnSharedPreferenceChangeListener {
+ public static class DeviceStruct{
+ private GBDevice device;
+ private DeviceCoordinator coordinator;
+ private DeviceSupport deviceSupport;
+
+ public GBDevice getDevice() {
+ return device;
+ }
+
+ public void setDevice(GBDevice device) {
+ this.device = device;
+ }
+
+ public DeviceCoordinator getCoordinator() {
+ return coordinator;
+ }
+
+ public void setCoordinator(DeviceCoordinator coordinator) {
+ this.coordinator = coordinator;
+ }
+
+ public DeviceSupport getDeviceSupport() {
+ return deviceSupport;
+ }
+
+ public void setDeviceSupport(DeviceSupport deviceSupport) {
+ this.deviceSupport = deviceSupport;
+ }
+ }
+
+ 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;
+ }
+
+ public void setSupportsWeather(boolean supportsWeather) {
+ this.supportsWeather = supportsWeather;
+ }
+
+ public boolean supportsActivityDataFetching() {
+ return supportsActivityDataFetching;
+ }
+
+ public void setSupportsActivityDataFetching(boolean supportsActivityDataFetching) {
+ this.supportsActivityDataFetching = supportsActivityDataFetching;
+ }
+
+ public boolean supportsCalendarEvents() {
+ return supportsCalendarEvents;
+ }
+
+ public void setSupportsCalendarEvents(boolean supportsCalendarEvents) {
+ this.supportsCalendarEvents = supportsCalendarEvents;
+ }
+
+ public boolean supportsMusicInfo() {
+ return supportsMusicInfo;
+ }
+
+ public void setSupportsMusicInfo(boolean supportsMusicInfo) {
+ this.supportsMusicInfo = supportsMusicInfo;
+ }
+
+ public boolean supportsNavigation() {
+ return supportsNavigation;
+ }
+
+ public void setSupportsNavigation(boolean supportsNavigation) {
+ 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);
+ }
+ if(operand.supportsWeather()){
+ setSupportsWeather(true);
+ }
+ if(operand.supportsActivityDataFetching()){
+ setSupportsActivityDataFetching(true);
+ }
+ if(operand.supportsMusicInfo()){
+ setSupportsMusicInfo(true);
+ }
+ if(operand.supportsNavigation()){
+ setSupportsNavigation(true);
+ }
+ if (operand.supportsSleepAsAndroid()) {
+ setSupportsSleepAsAndroid(true);
+ }
+ }
+ }
+
+ public static class DeviceNotFoundException extends GBException{
+ private final String address;
+
+ public DeviceNotFoundException(GBDevice device) {
+ this.address = device.getAddress();
+ }
+
+ public DeviceNotFoundException(String address) {
+ this.address = address;
+ }
+
+ @Nullable
+ @Override
+ public String getMessage() {
+ return String.format("device %s not found cached", address);
+ }
+ }
+
+ private static final Logger LOG = LoggerFactory.getLogger(DeviceCommunicationService.class);
+ @SuppressLint("StaticFieldLeak") // only used for test cases
+ private static DeviceSupportFactory DEVICE_SUPPORT_FACTORY = null;
+
+ private DeviceSupportFactory mFactory;
+ private final ArrayList<DeviceStruct> deviceStructs = new ArrayList<>(1);
+ private final HashMap<String, ArrayList<Intent>> cachedNotifications = new HashMap<>();
+
+ private PhoneCallReceiver mPhoneCallReceiver = null;
+ private SMSReceiver mSMSReceiver = null;
+ private PebbleReceiver mPebbleReceiver = null;
+ private MusicPlaybackReceiver mMusicPlaybackReceiver = null;
+ private TimeChangeReceiver mTimeChangeReceiver = null;
+ private BluetoothConnectReceiver mBlueToothConnectReceiver = null;
+ private BluetoothPairingRequestReceiver mBlueToothPairingRequestReceiver = null;
+ private AlarmClockReceiver mAlarmClockReceiver = null;
+ private SilentModeReceiver mSilentModeReceiver = null;
+ private GBAutoFetchReceiver mGBAutoFetchReceiver = null;
+ private AutoConnectIntervalReceiver mAutoConnectInvervalReceiver = null;
+
+ private AlarmReceiver mAlarmReceiver = null;
+ private final List<CalendarReceiver> mCalendarReceiver = new ArrayList<>();
+ private CMWeatherReceiver mCMWeatherReceiver = null;
+ private LineageOsWeatherReceiver mLineageOsWeatherReceiver = null;
+ private TinyWeatherForecastGermanyReceiver mTinyWeatherForecastGermanyReceiver = null;
+ private GenericWeatherReceiver mGenericWeatherReceiver = null;
+ 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 = {
+ "com.android.music.metachanged",
+ "com.android.music.playstatechanged",
+ "com.android.music.queuechanged",
+ "com.android.music.playbackcomplete",
+ "net.sourceforge.subsonic.androidapp.EVENT_META_CHANGED",
+ "com.maxmpz.audioplayer.TPOS_SYNC",
+ "com.maxmpz.audioplayer.STATUS_CHANGED",
+ "com.maxmpz.audioplayer.PLAYING_MODE_CHANGED",
+ "com.spotify.music.metadatachanged",
+ "com.spotify.music.playbackstatechanged"
+ };
+
+ private final int NOTIFICATIONS_CACHE_MAX = 10; // maximum amount of notifications to cache per device while disconnected
+ private boolean allowBluetoothIntentApi = false;
+ private boolean reconnectViaScan = GBPrefs.RECONNECT_SCAN_DEFAULT;
+
+ private final String API_LEGACY_COMMAND_BLUETOOTH_CONNECT = "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_CONNECT";
+ private final String API_LEGACY_COMMAND_BLUETOOTH_DISCONNECT = "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_DISCONNECT";
+ private final String API_LEGACY_ACTION_DEVICE_CONNECTED = "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_CONNECTED";
+ private final String API_LEGACY_ACTION_DEVICE_SCANNED = "nodomain.freeyourgadget.gadgetbridge.BLUETOOTH_SCANNED";
+
+ private void sendDeviceAPIBroadcast(String address, String action){
+ if(!allowBluetoothIntentApi){
+ LOG.debug("not sending API event due to settings");
+ return;
+ }
+ Intent intent = new Intent(action);
+ intent.putExtra("EXTRA_DEVICE_ADDRESS", address);
+
+ sendBroadcast(intent);
+ }
+
+ private void sendDeviceConnectedBroadcast(String address){
+ sendDeviceAPIBroadcast(address, API_LEGACY_ACTION_DEVICE_CONNECTED);
+ }
+
+ BroadcastReceiver bluetoothCommandReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (!allowBluetoothIntentApi){
+ GB.log("Connection API not allowed in settings", GB.ERROR, null);
+ return;
+ }
+ Bundle extras = intent.getExtras();
+ if (extras == null) {
+ GB.log("no extras provided in Intent", GB.ERROR, null);
+ return;
+ }
+ final String action = intent.getAction();
+ if (action == null) {
+ GB.log("Action for bluetooth command is null", GB.ERROR, null);
+ return;
+ }
+ String address = extras.getString("EXTRA_DEVICE_ADDRESS", "");
+ if (address.isEmpty()){
+ GB.log("no bluetooth address provided in Intent", GB.ERROR, null);
+ return;
+ }
+ GBDevice targetDevice = GBApplication.app()
+ .getDeviceManager()
+ .getDeviceByAddress(address);
+
+ if (targetDevice == null){
+ GB.log(String.format("device %s not registered", address), GB.ERROR, null);
+ return;
+ }
+
+ switch (action) {
+ case API_LEGACY_COMMAND_BLUETOOTH_CONNECT:
+ if (isDeviceConnected(address)){
+ GB.log(String.format("device %s already connected", address), GB.INFO, null);
+ sendDeviceConnectedBroadcast(address);
+
+ return;
+ }
+
+ GB.log(String.format("connecting to %s", address), GB.INFO, null);
+
+ GBApplication.deviceService(targetDevice).connect();
+ break;
+ case API_LEGACY_COMMAND_BLUETOOTH_DISCONNECT:
+ GB.log(String.format("disconnecting from %s", address), GB.INFO, null);
+
+ GBApplication.deviceService(targetDevice).disconnect();
+ break;
+ }
+ }
+ };
+
+ /**
+ * For testing!
+ *
+ * @param factory
+ */
+ @SuppressWarnings("JavaDoc")
+ public static void setDeviceSupportFactory(DeviceSupportFactory factory) {
+ DEVICE_SUPPORT_FACTORY = factory;
+ }
+
+ public static boolean isRunning(final Context context) {
+ final ActivityManager manager = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE);
+ if (manager == null) {
+ return false;
+ }
+ for (ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) {
+ if (DeviceCommunicationService.class.getName().equals(service.service.getClassName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public DeviceCommunicationService() {
+
+ }
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if(GBDevice.ACTION_DEVICE_CHANGED.equals(action)){
+ GBDevice device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+
+ // create a new instance of the changed devices coordinator, in case it's capabilities changed
+ DeviceStruct cachedStruct = getDeviceStructOrNull(device);
+ if(cachedStruct != null) {
+ cachedStruct.setDevice(device);
+ DeviceCoordinator newCoordinator = device.getDeviceCoordinator();
+ cachedStruct.setCoordinator(newCoordinator);
+ }
+ updateReceiversState();
+
+ GBDevice.DeviceUpdateSubject subject = (GBDevice.DeviceUpdateSubject) intent.getSerializableExtra(GBDevice.EXTRA_UPDATE_SUBJECT);
+
+ if(subject == GBDevice.DeviceUpdateSubject.DEVICE_STATE && device.isInitialized()){
+ sendDeviceConnectedBroadcast(device.getAddress());
+ sendCachedNotifications(device);
+ }else if(subject == GBDevice.DeviceUpdateSubject.DEVICE_STATE && (device.getState() == GBDevice.State.SCANNED)){
+ sendDeviceAPIBroadcast(device.getAddress(), API_LEGACY_ACTION_DEVICE_SCANNED);
+ }
+ }else if(BLEScanService.EVENT_DEVICE_FOUND.equals(action)){
+ String deviceAddress = intent.getStringExtra(BLEScanService.EXTRA_DEVICE_ADDRESS);
+
+ GBDevice target = GBApplication
+ .app()
+ .getDeviceManager()
+ .getDeviceByAddress(deviceAddress);
+
+ if(target == null){
+ LOG.error("onReceive: device not found");
+ return;
+ }
+
+ if(!target.getDeviceCoordinator().isConnectable()){
+ int actualRSSI = intent.getIntExtra(BLEScanService.EXTRA_RSSI, 0);
+ Prefs prefs = new Prefs(
+ GBApplication.getDeviceSpecificSharedPrefs(target.getAddress())
+ );
+ long timeoutSeconds = prefs.getLong("devicesetting_scannable_debounce", 60);
+ long minimumUnseenSeconds = prefs.getLong("devicesetting_scannable_unseen", 0);
+ int thresholdRSSI = prefs.getInt("devicesetting_scannable_rssi", -100);
+
+ if(actualRSSI < thresholdRSSI){
+ LOG.debug("ignoring {} since RSSI is too low ({} < {})", deviceAddress, actualRSSI, thresholdRSSI);
+ return;
+ }
+
+ Long lastSeenTimestamp = deviceLastScannedTimestamps.get(deviceAddress);
+ deviceLastScannedTimestamps.put(deviceAddress, System.currentTimeMillis());
+
+ if(lastSeenTimestamp != null){
+ long secondsSince = (System.currentTimeMillis() - lastSeenTimestamp) / 1000;
+ if(secondsSince < minimumUnseenSeconds){
+ LOG.debug("ignoring {}, since only {} seconds passed (< {})", deviceAddress, secondsSince, minimumUnseenSeconds);
+ return;
+ }
+ }
+
+ target.setState(GBDevice.State.SCANNED);
+ target.sendDeviceUpdateIntent(DeviceCommunicationService.this, GBDevice.DeviceUpdateSubject.DEVICE_STATE);
+ new Handler().postDelayed(() -> {
+ if(target.getState() != GBDevice.State.SCANNED){
+ return;
+ }
+ deviceLastScannedTimestamps.put(target.getAddress(), System.currentTimeMillis());
+ target.setState(GBDevice.State.WAITING_FOR_SCAN);
+ target.sendDeviceUpdateIntent(DeviceCommunicationService.this, GBDevice.DeviceUpdateSubject.DEVICE_STATE);
+ }, timeoutSeconds * 1000);
+ return;
+ }
+
+ connectToDevice(target);
+ }
+ }
+ };
+
+ private void updateReceiversState(){
+ boolean enableReceivers = false;
+ boolean anyDeviceInitialized = false;
+ List <GBDevice> devicesWithCalendar = new ArrayList<>();
+
+ FeatureSet features = new FeatureSet();
+
+ for(DeviceStruct struct: deviceStructs){
+ DeviceSupport deviceSupport = struct.getDeviceSupport();
+ if((deviceSupport != null && deviceSupport.useAutoConnect()) || isDeviceInitialized(struct.getDevice())){
+ enableReceivers = true;
+ }
+ if(isDeviceInitialized(struct.getDevice())){
+ anyDeviceInitialized = true;
+ }
+
+ DeviceCoordinator coordinator = struct.getCoordinator();
+ if(coordinator != null){
+ features.logicalOr(coordinator);
+ if (coordinator.supportsCalendarEvents()){
+ devicesWithCalendar.add(struct.getDevice());
+ }
+ }
+ }
+ setReceiversEnableState(enableReceivers, anyDeviceInitialized, features, devicesWithCalendar);
+ }
+
+ private void registerInternalReceivers(){
+ IntentFilter localFilter = new IntentFilter();
+ localFilter.addAction(GBDevice.ACTION_DEVICE_CHANGED);
+ localFilter.addAction(BLEScanService.EVENT_DEVICE_FOUND);
+ LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, localFilter);
+ }
+
+ private void registerExternalReceivers(){
+ mBlueToothConnectReceiver = new BluetoothConnectReceiver(this);
+ ContextCompat.registerReceiver(this, mBlueToothConnectReceiver, new IntentFilter(BluetoothDevice.ACTION_ACL_CONNECTED), ContextCompat.RECEIVER_EXPORTED);
+
+ mAutoConnectInvervalReceiver= new AutoConnectIntervalReceiver(this);
+ ContextCompat.registerReceiver(this, mAutoConnectInvervalReceiver, new IntentFilter("GB_RECONNECT"), ContextCompat.RECEIVER_EXPORTED);
+
+ IntentFilter bluetoothCommandFilter = new IntentFilter();
+ bluetoothCommandFilter.addAction(API_LEGACY_COMMAND_BLUETOOTH_CONNECT);
+ bluetoothCommandFilter.addAction(API_LEGACY_COMMAND_BLUETOOTH_DISCONNECT);
+ ContextCompat.registerReceiver(this, bluetoothCommandReceiver, bluetoothCommandFilter, ContextCompat.RECEIVER_EXPORTED);
+
+ final IntentFilter deviceSettingsIntentFilter = new IntentFilter();
+ deviceSettingsIntentFilter.addAction(DeviceSettingsReceiver.COMMAND);
+ ContextCompat.registerReceiver(this, deviceSettingsReceiver, deviceSettingsIntentFilter, ContextCompat.RECEIVER_EXPORTED);
+
+ ContextCompat.registerReceiver(this, intentApiReceiver, intentApiReceiver.buildFilter(), ContextCompat.RECEIVER_EXPORTED);
+ }
+
+ @Override
+ public void onCreate() {
+ LOG.debug("DeviceCommunicationService is being created");
+ super.onCreate();
+ mFactory = getDeviceSupportFactory();
+
+ registerInternalReceivers();
+ registerExternalReceivers();
+
+ if (hasPrefs()) {
+ getPrefs().getPreferences().registerOnSharedPreferenceChangeListener(this);
+ allowBluetoothIntentApi = getPrefs().getBoolean(GBPrefs.PREF_ALLOW_INTENT_API, false);
+ reconnectViaScan = getPrefs().getAutoReconnectByScan();
+ }
+
+ startForeground();
+ if(reconnectViaScan) {
+ scanAllDevices();
+
+ Intent scanServiceIntent = new Intent(this, BLEScanService.class);
+ startService(scanServiceIntent);
+ }
+ }
+
+ private void scanAllDevices(){
+ List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
+ for(GBDevice device : devices){
+ if(!device.getDeviceCoordinator().getConnectionType().usesBluetoothLE()){
+ continue;
+ }
+ if(device.getState() != GBDevice.State.NOT_CONNECTED){
+ continue;
+ }
+ boolean shouldAutoConnect = getPrefs().getAutoReconnect(device);
+ if(!shouldAutoConnect){
+ continue;
+ }
+ createDeviceStruct(device);
+ device.setState(GBDevice.State.WAITING_FOR_SCAN);
+ device.sendDeviceUpdateIntent(this, GBDevice.DeviceUpdateSubject.DEVICE_STATE);
+ }
+ }
+
+ private DeviceSupportFactory getDeviceSupportFactory() {
+ if (DEVICE_SUPPORT_FACTORY != null) {
+ return DEVICE_SUPPORT_FACTORY;
+ }
+ return new DeviceSupportFactory(this);
+ }
+
+ private DeviceStruct createDeviceStruct(GBDevice target){
+ DeviceStruct registeredStruct = new DeviceStruct();
+ registeredStruct.setDevice(target);
+ registeredStruct.setCoordinator(target.getDeviceCoordinator());
+ deviceStructs.add(registeredStruct);
+ return registeredStruct;
+ }
+
+ private void connectToDevice(GBDevice device){
+ connectToDevice(device, false);
+ }
+
+ private void connectToDevice(@Nullable final GBDevice device, boolean firstTime) {
+ final List<GBDevice> gbDevs = new ArrayList<>();
+ boolean fromExtra = false;
+
+ final Prefs prefs = getPrefs();
+
+ if (device != null) {
+ if (!device.getDeviceCoordinator().isConnectable()) {
+ GB.toast("Cannot connect to Scannable Device", Toast.LENGTH_SHORT, GB.INFO);
+ return;
+ }
+
+ gbDevs.add(device);
+ fromExtra = true;
+ } else {
+ List<GBDevice> gbAllDevs = GBApplication.app().getDeviceManager().getDevices();
+
+ if (gbAllDevs != null && !gbAllDevs.isEmpty()) {
+ if (prefs.getBoolean(GBPrefs.RECONNECT_ONLY_TO_CONNECTED, true)) {
+ Set<String> lastDeviceAddresses = prefs.getStringSet(GBPrefs.LAST_DEVICE_ADDRESSES, Collections.emptySet());
+
+ if (lastDeviceAddresses != null && !lastDeviceAddresses.isEmpty()) {
+ for (final GBDevice gbDev : gbAllDevs) {
+ // TODO volatile address
+ if (lastDeviceAddresses.contains(gbDev.getAddress())) {
+ gbDevs.add(gbDev);
+ }
+ }
+ }
+ } else {
+ gbDevs.addAll(gbAllDevs);
+ }
+ }
+ }
+
+ if (gbDevs.isEmpty()) {
+ return;
+ }
+
+ for (GBDevice gbDevice : gbDevs) {
+ if (!gbDevice.getDeviceCoordinator().isConnectable()) {
+ // we cannot connect to beacons, skip this device
+ continue;
+ }
+
+ String btDeviceAddress = gbDevice.getAddress();
+
+ boolean autoReconnect = GBPrefs.AUTO_RECONNECT_DEFAULT;
+ if (prefs != null && prefs.getPreferences() != null) {
+ autoReconnect = getPrefs().getAutoReconnect(gbDevice);
+ if (!fromExtra && !autoReconnect) {
+ continue;
+ }
+
+ final Set<String> lastDeviceAddresses = new HashSet<>(prefs.getStringSet(GBPrefs.LAST_DEVICE_ADDRESSES, Collections.emptySet()));
+
+ if (!lastDeviceAddresses.contains(btDeviceAddress)) {
+ lastDeviceAddresses.add(btDeviceAddress);
+ prefs.getPreferences().edit().putStringSet(GBPrefs.LAST_DEVICE_ADDRESSES, lastDeviceAddresses).apply();
+ }
+ }
+
+ if (!fromExtra && !autoReconnect) {
+ continue;
+ }
+
+ DeviceStruct registeredStruct = getDeviceStructOrNull(gbDevice);
+ if (registeredStruct == null) {
+ registeredStruct = createDeviceStruct(gbDevice);
+ } else {
+ final GBDevice deviceFromStruct = registeredStruct.getDevice();
+
+ if (isDeviceConnecting(deviceFromStruct) || isDeviceConnected(deviceFromStruct)) {
+ continue;
+ }
+
+ try {
+ removeDeviceSupport(gbDevice);
+ } catch (final DeviceNotFoundException e) {
+ LOG.error("connectToDevice(): Failed to remove device support: {}", e, e);
+ }
+ }
+
+ try {
+ final DeviceSupport deviceSupport = mFactory.createDeviceSupport(gbDevice);
+
+ if (deviceSupport != null) {
+ setDeviceSupport(gbDevice, deviceSupport);
+
+ if (firstTime) {
+ deviceSupport.connectFirstTime();
+ } else {
+ deviceSupport.setAutoReconnect(autoReconnect);
+ deviceSupport.setScanReconnect(reconnectViaScan);
+ deviceSupport.connect();
+ }
+ } else {
+ GB.toast(this, getString(R.string.cannot_connect, "Can't create device support"), Toast.LENGTH_SHORT, GB.ERROR);
+ }
+ } catch (Exception e) {
+ GB.toast(this, getString(R.string.cannot_connect, e.getMessage()), Toast.LENGTH_SHORT, GB.ERROR, e);
+ }
+
+ registeredStruct.getDevice().sendDeviceUpdateIntent(this);
+ }
+ }
+
+ @Override
+ public synchronized int onStartCommand(Intent intent, int flags, int startId) {
+ if (intent == null) {
+ LOG.info("no intent");
+ return START_STICKY;
+ }
+
+ String action = intent.getAction();
+
+ if (action == null) {
+ LOG.info("no action");
+ return START_STICKY;
+ }
+
+ LOG.debug("Service startcommand: " + action);
+
+ // when we get past this, we should have valid mDeviceSupport and mGBDevice instances
+
+ GBDevice targetDevice = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+
+ Prefs prefs = getPrefs();
+ switch (action) {
+ case ACTION_CONNECT:
+ boolean firstTime = intent.getBooleanExtra(EXTRA_CONNECT_FIRST_TIME, false);
+ connectToDevice(targetDevice, firstTime);
+ break;
+ default:
+ ArrayList<GBDevice> targetedDevices = new ArrayList<>();
+ if(targetDevice != null){
+ targetedDevices.add(targetDevice);
+ }else{
+ for(GBDevice device : getGBDevices()){
+ if(isDeviceInitialized(device)){
+ targetedDevices.add(device);
+ } else if (isDeviceReconnecting(device) && action.equals(ACTION_NOTIFICATION) && GBApplication.getPrefs().getBoolean("notification_cache_while_disconnected", false)) {
+ if (!cachedNotifications.containsKey(device.getAddress())) {
+ cachedNotifications.put(device.getAddress(), new ArrayList<>());
+ }
+ ArrayList<Intent> notifCache = cachedNotifications.get(device.getAddress());
+ notifCache.add(intent);
+ if (notifCache.size() > NOTIFICATIONS_CACHE_MAX) {
+ // remove the oldest notification if the maximum is reached
+ notifCache.remove(0);
+ }
+ } else if (action.equals(ACTION_DELETE_NOTIFICATION)) {
+ ArrayList<Intent> notifCache = cachedNotifications.get(device.getAddress());
+ if (notifCache != null) {
+ int notifId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1);
+ ArrayList<Intent> toRemove = new ArrayList<>();
+ for (Intent cached : notifCache) {
+ if (notifId == cached.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) {
+ toRemove.add(cached);
+ }
+ }
+ notifCache.removeAll(toRemove);
+ }
+ } else if (action.equals(ACTION_DISCONNECT) && device.getState() != GBDevice.State.NOT_CONNECTED) {
+ targetedDevices.add(device);
+ }
+ }
+ }
+ for (GBDevice device1 : targetedDevices) {
+ try {
+ handleAction(intent, action, device1);
+ } catch (DeviceNotFoundException e) {
+ e.printStackTrace();
+ } catch (Exception e) {
+ LOG.error("An exception was raised while handling the action {} for the device {}: ", action, device1, e);
+ }
+ }
+ break;
+ }
+ return START_STICKY;
+ }
+
+ /**
+ * @param text original text
+ * @return 'text' or a new String without non supported chars like emoticons, etc.
+ */
+ private String sanitizeNotifText(String text, GBDevice device) throws DeviceNotFoundException {
+ if (text == null || text.length() == 0)
+ return text;
+
+ text = getDeviceSupport(device).customStringFilter(text);
+
+ if (!getDeviceCoordinator(device).supportsUnicodeEmojis()) {
+ return EmojiConverter.convertUnicodeEmojiToAscii(text, getApplicationContext());
+ }
+
+ return text;
+ }
+
+ private DeviceCoordinator getDeviceCoordinator(GBDevice device) throws DeviceNotFoundException {
+ if(device == null){
+ throw new DeviceNotFoundException("null");
+ }
+ for(DeviceStruct struct : deviceStructs){
+ if(struct.getDevice().equals(device)){
+ return struct.getCoordinator();
+ }
+ }
+ throw new DeviceNotFoundException(device);
+ }
+
+ private void handleAction(Intent intent, String action, GBDevice device) throws DeviceNotFoundException {
+ if(ACTION_DISCONNECT.equals(intent.getAction())) {
+ try {
+ removeDeviceSupport(device);
+ } catch (DeviceNotFoundException e) {
+ LOG.error("Trying to disconnect unknown device: ", e);
+ }
+ device.setState(GBDevice.State.NOT_CONNECTED);
+ device.sendDeviceUpdateIntent(this);
+ updateReceiversState();
+ return;
+ }
+
+ DeviceSupport deviceSupport = getDeviceSupport(device);
+
+ Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
+
+ final Transliterator transliterator = LanguageUtils.getTransliterator(device);
+
+ // Copy the incoming intent to make sure we don't modify it before it gets passed to other devices
+ Intent intentCopy = (Intent) intent.clone();
+
+ for (String extra : GBDeviceService.transliterationExtras) {
+ if (intentCopy.hasExtra(extra)) {
+ // Ensure the text is sanitized (eg. emoji converted to ascii) before applying the transliterators
+ // otherwise the emoji are removed before converting them
+ String sanitizedText = sanitizeNotifText(intentCopy.getStringExtra(extra), device);
+ if (transliterator != null) {
+ sanitizedText = transliterator.transliterate(sanitizedText);
+ }
+ intentCopy.putExtra(extra, sanitizedText);
+ }
+ }
+
+ switch (action) {
+ case ACTION_REQUEST_DEVICEINFO:
+ device.sendDeviceUpdateIntent(this, GBDevice.DeviceUpdateSubject.NOTHING);
+ break;
+ case ACTION_NOTIFICATION: {
+ int desiredId = intentCopy.getIntExtra(EXTRA_NOTIFICATION_ID, -1);
+ NotificationSpec notificationSpec = new NotificationSpec(desiredId);
+ notificationSpec.phoneNumber = intentCopy.getStringExtra(EXTRA_NOTIFICATION_PHONENUMBER);
+ notificationSpec.sender = intentCopy.getStringExtra(EXTRA_NOTIFICATION_SENDER);
+ notificationSpec.subject = intentCopy.getStringExtra(EXTRA_NOTIFICATION_SUBJECT);
+ notificationSpec.title = intentCopy.getStringExtra(EXTRA_NOTIFICATION_TITLE);
+ if(notificationSpec.title == null) {
+ notificationSpec.title = "";
+ }
+ notificationSpec.key = intentCopy.getStringExtra(EXTRA_NOTIFICATION_KEY);
+ notificationSpec.body = intentCopy.getStringExtra(EXTRA_NOTIFICATION_BODY);
+ if(notificationSpec.body == null) {
+ notificationSpec.body = "";
+ }
+ notificationSpec.sourceName = intentCopy.getStringExtra(EXTRA_NOTIFICATION_SOURCENAME);
+ notificationSpec.type = (NotificationType) intentCopy.getSerializableExtra(EXTRA_NOTIFICATION_TYPE);
+ notificationSpec.attachedActions = (ArrayList<NotificationSpec.Action>) intentCopy.getSerializableExtra(EXTRA_NOTIFICATION_ACTIONS);
+ notificationSpec.pebbleColor = (byte) intentCopy.getSerializableExtra(EXTRA_NOTIFICATION_PEBBLE_COLOR);
+ notificationSpec.flags = intentCopy.getIntExtra(EXTRA_NOTIFICATION_FLAGS, 0);
+ notificationSpec.sourceAppId = intentCopy.getStringExtra(EXTRA_NOTIFICATION_SOURCEAPPID);
+ notificationSpec.iconId = intentCopy.getIntExtra(EXTRA_NOTIFICATION_ICONID, 0);
+ notificationSpec.picturePath = intent.getStringExtra(NOTIFICATION_PICTURE_PATH);
+ notificationSpec.dndSuppressed = intentCopy.getIntExtra(EXTRA_NOTIFICATION_DNDSUPPRESSED, 0);
+
+ if (notificationSpec.type == NotificationType.GENERIC_SMS && notificationSpec.phoneNumber != null) {
+ GBApplication.getIDSenderLookup().add(notificationSpec.getId(), notificationSpec.phoneNumber);
+ }
+
+ //TODO: check if at least one of the attached actions is a reply action instead?
+ if ((notificationSpec.attachedActions != null && notificationSpec.attachedActions.size() > 0)
+ || (notificationSpec.type == NotificationType.GENERIC_SMS && notificationSpec.phoneNumber != null)) {
+ // NOTE: maybe not where it belongs
+ // I would rather like to save that as an array in SharedPreferences
+ // this would work but I dont know how to do the same in the Settings Activity's xml
+ ArrayList<String> replies = new ArrayList<>();
+ for (int i = 1; i <= 16; i++) {
+ String reply = devicePrefs.getString("canned_reply_" + i, null);
+ if (reply != null && !reply.equals("")) {
+ replies.add(reply);
+ }
+ }
+ notificationSpec.cannedReplies = replies.toArray(new String[0]);
+ }
+
+ deviceSupport.onNotification(notificationSpec);
+ break;
+ }
+ case ACTION_DELETE_NOTIFICATION: {
+ deviceSupport.onDeleteNotification(intentCopy.getIntExtra(EXTRA_NOTIFICATION_ID, -1));
+ break;
+ }
+ case ACTION_ADD_CALENDAREVENT: {
+ CalendarEventSpec calendarEventSpec = new CalendarEventSpec();
+ calendarEventSpec.id = intentCopy.getLongExtra(EXTRA_CALENDAREVENT_ID, -1);
+ calendarEventSpec.type = intentCopy.getByteExtra(EXTRA_CALENDAREVENT_TYPE, (byte) -1);
+ calendarEventSpec.timestamp = intentCopy.getIntExtra(EXTRA_CALENDAREVENT_TIMESTAMP, -1);
+ calendarEventSpec.durationInSeconds = intentCopy.getIntExtra(EXTRA_CALENDAREVENT_DURATION, -1);
+ calendarEventSpec.allDay = intentCopy.getBooleanExtra(EXTRA_CALENDAREVENT_ALLDAY, false);
+ calendarEventSpec.reminders = (ArrayList<Long>) intentCopy.getSerializableExtra(EXTRA_CALENDAREVENT_REMINDERS);
+ calendarEventSpec.title = intentCopy.getStringExtra(EXTRA_CALENDAREVENT_TITLE);
+ calendarEventSpec.description = intentCopy.getStringExtra(EXTRA_CALENDAREVENT_DESCRIPTION);
+ calendarEventSpec.location = intentCopy.getStringExtra(EXTRA_CALENDAREVENT_LOCATION);
+ calendarEventSpec.calName = intentCopy.getStringExtra(EXTRA_CALENDAREVENT_CALNAME);
+ calendarEventSpec.color = intentCopy.getIntExtra(EXTRA_CALENDAREVENT_COLOR, 0);
+ deviceSupport.onAddCalendarEvent(calendarEventSpec);
+ break;
+ }
+ case ACTION_DELETE_CALENDAREVENT: {
+ long id = intentCopy.getLongExtra(EXTRA_CALENDAREVENT_ID, -1);
+ byte type = intentCopy.getByteExtra(EXTRA_CALENDAREVENT_TYPE, (byte) -1);
+ deviceSupport.onDeleteCalendarEvent(type, id);
+ break;
+ }
+ case ACTION_RESET: {
+ int flags = intentCopy.getIntExtra(EXTRA_RESET_FLAGS, 0);
+ deviceSupport.onReset(flags);
+ break;
+ }
+ case ACTION_HEARTRATE_TEST: {
+ deviceSupport.onHeartRateTest();
+ break;
+ }
+ case ACTION_FETCH_RECORDED_DATA: {
+ int dataTypes = intentCopy.getIntExtra(EXTRA_RECORDED_DATA_TYPES, 0);
+ deviceSupport.onFetchRecordedData(dataTypes);
+ break;
+ }
+ case ACTION_FIND_DEVICE: {
+ boolean start = intentCopy.getBooleanExtra(EXTRA_FIND_START, false);
+ deviceSupport.onFindDevice(start);
+ break;
+ }
+ case ACTION_PHONE_FOUND: {
+ final boolean start = intentCopy.getBooleanExtra(EXTRA_FIND_START, false);
+ deviceSupport.onFindPhone(start);
+ break;
+ }
+ case ACTION_SET_CONSTANT_VIBRATION: {
+ int intensity = intentCopy.getIntExtra(EXTRA_VIBRATION_INTENSITY, 0);
+ deviceSupport.onSetConstantVibration(intensity);
+ break;
+ }
+ case ACTION_CALLSTATE:
+ CallSpec callSpec = new CallSpec();
+ callSpec.command = intentCopy.getIntExtra(EXTRA_CALL_COMMAND, CallSpec.CALL_UNDEFINED);
+ callSpec.number = intentCopy.getStringExtra(EXTRA_CALL_PHONENUMBER);
+ callSpec.name = intentCopy.getStringExtra(EXTRA_CALL_DISPLAYNAME);
+ callSpec.sourceName = intentCopy.getStringExtra(EXTRA_CALL_SOURCENAME);
+ callSpec.sourceAppId = intentCopy.getStringExtra(EXTRA_CALL_SOURCEAPPID);
+ callSpec.dndSuppressed = intentCopy.getIntExtra(EXTRA_CALL_DNDSUPPRESSED, 0);
+ deviceSupport.onSetCallState(callSpec);
+ break;
+ case ACTION_SETCANNEDMESSAGES:
+ int type = intentCopy.getIntExtra(EXTRA_CANNEDMESSAGES_TYPE, -1);
+ String[] cannedMessages = intentCopy.getStringArrayExtra(EXTRA_CANNEDMESSAGES);
+
+ CannedMessagesSpec cannedMessagesSpec = new CannedMessagesSpec();
+ cannedMessagesSpec.type = type;
+ cannedMessagesSpec.cannedMessages = cannedMessages;
+ deviceSupport.onSetCannedMessages(cannedMessagesSpec);
+ break;
+ case ACTION_SETTIME:
+ deviceSupport.onSetTime();
+ break;
+ case ACTION_SETMUSICINFO:
+ MusicSpec musicSpec = new MusicSpec();
+ musicSpec.artist = intentCopy.getStringExtra(EXTRA_MUSIC_ARTIST);
+ musicSpec.album = intentCopy.getStringExtra(EXTRA_MUSIC_ALBUM);
+ musicSpec.track = intentCopy.getStringExtra(EXTRA_MUSIC_TRACK);
+ musicSpec.duration = intentCopy.getIntExtra(EXTRA_MUSIC_DURATION, 0);
+ musicSpec.trackCount = intentCopy.getIntExtra(EXTRA_MUSIC_TRACKCOUNT, 0);
+ musicSpec.trackNr = intentCopy.getIntExtra(EXTRA_MUSIC_TRACKNR, 0);
+ deviceSupport.onSetMusicInfo(musicSpec);
+ break;
+ case ACTION_SET_PHONE_VOLUME:
+ float phoneVolume = intentCopy.getFloatExtra(EXTRA_PHONE_VOLUME, 0);
+ deviceSupport.onSetPhoneVolume(phoneVolume);
+ break;
+ case ACTION_SET_PHONE_SILENT_MODE:
+ final int ringerMode = intentCopy.getIntExtra(EXTRA_PHONE_RINGER_MODE, -1);
+ deviceSupport.onChangePhoneSilentMode(ringerMode);
+ break;
+ case ACTION_SETMUSICSTATE:
+ MusicStateSpec stateSpec = new MusicStateSpec();
+ stateSpec.shuffle = intentCopy.getByteExtra(EXTRA_MUSIC_SHUFFLE, (byte) 0);
+ stateSpec.repeat = intentCopy.getByteExtra(EXTRA_MUSIC_REPEAT, (byte) 0);
+ stateSpec.position = intentCopy.getIntExtra(EXTRA_MUSIC_POSITION, 0);
+ stateSpec.playRate = intentCopy.getIntExtra(EXTRA_MUSIC_RATE, 0);
+ stateSpec.state = intentCopy.getByteExtra(EXTRA_MUSIC_STATE, (byte) 0);
+ deviceSupport.onSetMusicState(stateSpec);
+ break;
+ case ACTION_SETNAVIGATIONINFO:
+ NavigationInfoSpec navigationInfoSpec = new NavigationInfoSpec();
+ navigationInfoSpec.instruction = intentCopy.getStringExtra(EXTRA_NAVIGATION_INSTRUCTION);
+ navigationInfoSpec.nextAction = intentCopy.getIntExtra(EXTRA_NAVIGATION_NEXT_ACTION,0);
+ navigationInfoSpec.distanceToTurn = intentCopy.getStringExtra(EXTRA_NAVIGATION_DISTANCE_TO_TURN);
+ navigationInfoSpec.ETA = intentCopy.getStringExtra(EXTRA_NAVIGATION_ETA);
+ deviceSupport.onSetNavigationInfo(navigationInfoSpec);
+ break;
+ case ACTION_REQUEST_APPINFO:
+ deviceSupport.onAppInfoReq();
+ break;
+ case ACTION_REQUEST_SCREENSHOT:
+ deviceSupport.onScreenshotReq();
+ break;
+ case ACTION_STARTAPP: {
+ UUID uuid = (UUID) intentCopy.getSerializableExtra(EXTRA_APP_UUID);
+ boolean start = intentCopy.getBooleanExtra(EXTRA_APP_START, true);
+ deviceSupport.onAppStart(uuid, start);
+ break;
+ }
+ case ACTION_DOWNLOADAPP: {
+ UUID uuid = (UUID) intentCopy.getSerializableExtra(EXTRA_APP_UUID);
+ deviceSupport.onAppDownload(uuid);
+ break;
+ }
+ case ACTION_DELETEAPP: {
+ UUID uuid = (UUID) intentCopy.getSerializableExtra(EXTRA_APP_UUID);
+ deviceSupport.onAppDelete(uuid);
+ break;
+ }
+ case ACTION_APP_CONFIGURE: {
+ UUID uuid = (UUID) intentCopy.getSerializableExtra(EXTRA_APP_UUID);
+ String config = intentCopy.getStringExtra(EXTRA_APP_CONFIG);
+ Integer id = null;
+ if (intentCopy.hasExtra(EXTRA_APP_CONFIG_ID)) {
+ id = intentCopy.getIntExtra(EXTRA_APP_CONFIG_ID, 0);
+ }
+ deviceSupport.onAppConfiguration(uuid, config, id);
+ break;
+ }
+ case ACTION_APP_REORDER: {
+ UUID[] uuids = (UUID[]) intentCopy.getSerializableExtra(EXTRA_APP_UUID);
+ deviceSupport.onAppReorder(uuids);
+ break;
+ }
+ case ACTION_INSTALL:
+ Uri uri = intentCopy.getParcelableExtra(EXTRA_URI);
+ if (uri != null) {
+ LOG.info("will try to install app/fw");
+ deviceSupport.onInstallApp(uri);
+ }
+ break;
+ case ACTION_SET_ALARMS:
+ ArrayList<? extends Alarm> alarms = (ArrayList<? extends Alarm>) intentCopy.getSerializableExtra(EXTRA_ALARMS);
+ deviceSupport.onSetAlarms(alarms);
+ break;
+ case ACTION_SET_REMINDERS:
+ ArrayList<? extends Reminder> reminders = (ArrayList<? extends Reminder>) intentCopy.getSerializableExtra(EXTRA_REMINDERS);
+ deviceSupport.onSetReminders(reminders);
+ break;
+ case ACTION_SET_LOYALTY_CARDS:
+ final ArrayList<LoyaltyCard> loyaltyCards = (ArrayList<LoyaltyCard>) intentCopy.getSerializableExtra(EXTRA_LOYALTY_CARDS);
+ deviceSupport.onSetLoyaltyCards(loyaltyCards);
+ break;
+ case ACTION_SET_WORLD_CLOCKS:
+ ArrayList<? extends WorldClock> clocks = (ArrayList<? extends WorldClock>) intentCopy.getSerializableExtra(EXTRA_WORLD_CLOCKS);
+ deviceSupport.onSetWorldClocks(clocks);
+ break;
+ case ACTION_SET_CONTACTS:
+ ArrayList<? extends Contact> contacts = (ArrayList<? extends Contact>) intentCopy.getSerializableExtra(EXTRA_CONTACTS);
+ deviceSupport.onSetContacts(contacts);
+ break;
+ case ACTION_ENABLE_REALTIME_STEPS: {
+ boolean enable = intentCopy.getBooleanExtra(EXTRA_BOOLEAN_ENABLE, false);
+ deviceSupport.onEnableRealtimeSteps(enable);
+ break;
+ }
+ case ACTION_ENABLE_HEARTRATE_SLEEP_SUPPORT: {
+ boolean enable = intentCopy.getBooleanExtra(EXTRA_BOOLEAN_ENABLE, false);
+ deviceSupport.onEnableHeartRateSleepSupport(enable);
+ break;
+ }
+ case ACTION_SET_HEARTRATE_MEASUREMENT_INTERVAL: {
+ int seconds = intentCopy.getIntExtra(EXTRA_INTERVAL_SECONDS, 0);
+ deviceSupport.onSetHeartRateMeasurementInterval(seconds);
+ break;
+ }
+ case ACTION_ENABLE_REALTIME_HEARTRATE_MEASUREMENT: {
+ boolean enable = intentCopy.getBooleanExtra(EXTRA_BOOLEAN_ENABLE, false);
+ deviceSupport.onEnableRealtimeHeartRateMeasurement(enable);
+ break;
+ }
+ case ACTION_SEND_CONFIGURATION: {
+ String config = intentCopy.getStringExtra(EXTRA_CONFIG);
+ deviceSupport.onSendConfiguration(config);
+ break;
+ }
+ case ACTION_READ_CONFIGURATION: {
+ String config = intentCopy.getStringExtra(EXTRA_CONFIG);
+ deviceSupport.onReadConfiguration(config);
+ break;
+ }
+ case ACTION_TEST_NEW_FUNCTION: {
+ deviceSupport.onTestNewFunction();
+ break;
+ }
+ case ACTION_SEND_WEATHER: {
+ ArrayList<WeatherSpec> weatherSpecs = (ArrayList<WeatherSpec>) intentCopy.getSerializableExtra(EXTRA_WEATHER);
+ if (weatherSpecs != null && !weatherSpecs.isEmpty()) {
+ deviceSupport.onSendWeather(weatherSpecs);
+ }
+ break;
+ }
+ case ACTION_SET_LED_COLOR:
+ int color = intentCopy.getIntExtra(EXTRA_LED_COLOR, 0);
+ if (color != 0) {
+ deviceSupport.onSetLedColor(color);
+ }
+ break;
+ case ACTION_POWER_OFF:
+ deviceSupport.onPowerOff();
+ break;
+ case ACTION_SET_FM_FREQUENCY:
+ float frequency = intentCopy.getFloatExtra(EXTRA_FM_FREQUENCY, -1);
+ if (frequency != -1) {
+ deviceSupport.onSetFmFrequency(frequency);
+ }
+ break;
+ case ACTION_SET_GPS_LOCATION:
+ final Location location = intentCopy.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 = intentCopy.getStringExtra(EXTRA_SLEEP_AS_ANDROID_ACTION);
+ deviceSupport.onSleepAsAndroidAction(sleepAsAndroidAction, intentCopy.getExtras());
+ }
+ break;
+ case ACTION_CAMERA_STATUS_CHANGE:
+ final GBDeviceEventCameraRemote.Event event = GBDeviceEventCameraRemote.intToEvent(intentCopy.getIntExtra(EXTRA_CAMERA_EVENT, -1));
+ String filename = null;
+ if (event == GBDeviceEventCameraRemote.Event.TAKE_PICTURE) {
+ filename = intentCopy.getStringExtra(EXTRA_CAMERA_FILENAME);
+ }
+ deviceSupport.onCameraStatusChange(event, filename);
+ break;
+ case ACTION_REQUEST_MUSIC_LIST:
+ deviceSupport.onMusicListReq();
+ break;
+ case ACTION_REQUEST_MUSIC_OPERATION:
+ int operation = intentCopy.getIntExtra("operation", -1);
+ int playlistIndex = intentCopy.getIntExtra("playlistIndex", -1);
+ String playlistName = intentCopy.getStringExtra("playlistName");
+ ArrayList<Integer> musics = (ArrayList<Integer>) intentCopy.getSerializableExtra("musicIds");
+ deviceSupport.onMusicOperation(operation, playlistIndex, playlistName, musics);
+ break;
+ }
+ }
+
+ /**
+ * Disposes the current DeviceSupport instance (if any) and sets a new device support instance
+ * (if not null).
+ *
+ * @param deviceSupport deviceSupport to reokace/add
+ */
+ private void setDeviceSupport(GBDevice device, DeviceSupport deviceSupport) throws DeviceNotFoundException {
+ DeviceStruct deviceStruct = getDeviceStruct(device);
+ DeviceSupport cachedDeviceSupport = deviceStruct.getDeviceSupport();
+ if (deviceSupport != cachedDeviceSupport && cachedDeviceSupport != null) {
+ cachedDeviceSupport.dispose();
+ }
+ deviceStruct.setDeviceSupport(deviceSupport);
+ }
+
+ private void removeDeviceSupport(GBDevice device) throws DeviceNotFoundException {
+ DeviceStruct struct = getDeviceStruct(device);
+ if(struct.getDeviceSupport() != null){
+ struct.getDeviceSupport().dispose();
+ }
+ struct.setDeviceSupport(null);
+ }
+
+ private DeviceStruct getDeviceStructOrNull(GBDevice device){
+ DeviceStruct deviceStruct = null;
+ try {
+ deviceStruct = getDeviceStruct(device);
+ } catch (DeviceNotFoundException e) {
+ e.printStackTrace();
+ }
+ return deviceStruct;
+ }
+
+ public DeviceStruct getDeviceStruct(GBDevice device) throws DeviceNotFoundException {
+ if(device == null){
+ throw new DeviceNotFoundException("null");
+ }
+ for(DeviceStruct struct : deviceStructs){
+ if(struct.getDevice().equals(device)){
+ return struct;
+ }
+ }
+ throw new DeviceNotFoundException(device);
+ }
+
+ public GBDevice getDeviceByAddress(String deviceAddress) throws DeviceNotFoundException {
+ if(deviceAddress == null){
+ throw new DeviceNotFoundException(deviceAddress);
+ }
+ for(DeviceStruct struct : deviceStructs){
+ if(struct.getDevice().getAddress().equals(deviceAddress)){
+ return struct.getDevice();
+ }
+ }
+ throw new DeviceNotFoundException(deviceAddress);
+ }
+
+ public GBDevice getDeviceByAddressOrNull(String deviceAddress){
+ GBDevice device = null;
+ try {
+ device = getDeviceByAddress(deviceAddress);
+ } catch (DeviceNotFoundException e) {
+ e.printStackTrace();
+ }
+ return device;
+ }
+
+ private DeviceSupport getDeviceSupport(GBDevice device) throws DeviceNotFoundException {
+ if(device == null){
+ throw new DeviceNotFoundException("null");
+ }
+ for(DeviceStruct struct : deviceStructs){
+ if(struct.getDevice().equals(device)){
+ if(struct.getDeviceSupport() == null)
+ throw new DeviceNotFoundException(device);
+
+ return struct.getDeviceSupport();
+ }
+ }
+ throw new DeviceNotFoundException(device);
+ }
+
+ private void startForeground() {
+ GB.createNotificationChannels(this);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_DENIED)
+ return;
+
+ startForeground(GB.NOTIFICATION_ID, GB.createNotification(getString(R.string.gadgetbridge_running), this), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE);
+ } else {
+ startForeground(GB.NOTIFICATION_ID, GB.createNotification(getString(R.string.gadgetbridge_running), this));
+ }
+ }
+
+ private boolean isDeviceConnected(GBDevice device) {
+ return isDeviceConnected(device.getAddress());
+ }
+
+ private boolean isDeviceConnected(String deviceAddress) {
+ for(DeviceStruct struct : deviceStructs){
+ if(struct.getDevice().getAddress().compareToIgnoreCase(deviceAddress) == 0){
+ return struct.getDevice().isConnected();
+ }
+ }
+ return false;
+ }
+
+ private boolean isDeviceConnecting(GBDevice device) {
+ return isDeviceConnecting(device.getAddress());
+ }
+
+ private boolean isDeviceConnecting(String deviceAddress) {
+ for(DeviceStruct struct : deviceStructs){
+ if(struct.getDevice().getAddress().compareToIgnoreCase(deviceAddress) == 0){
+ return struct.getDevice().isConnecting();
+ }
+ }
+ return false;
+ }
+
+ private boolean isDeviceInitialized(GBDevice device) {
+ return isDeviceInitialized(device.getAddress());
+ }
+
+ private boolean isDeviceInitialized(String deviceAddress) {
+ for(DeviceStruct struct : deviceStructs){
+ if(struct.getDevice().getAddress().compareToIgnoreCase(deviceAddress) == 0){
+ return struct.getDevice().isInitialized();
+ }
+ }
+ return false;
+ }
+
+ private boolean deviceStateEquals(GBDevice device, GBDevice.State... states){
+ if((device = getDeviceByAddressOrNull(device.getAddress())) != null){
+ for(GBDevice.State possibleState : states){
+ if(device.getState() == possibleState){
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean isDeviceReconnecting(GBDevice device) {
+ if((device = getDeviceByAddressOrNull(device.getAddress())) != null){
+ return device.getState().equalsOrHigherThan(GBDevice.State.NOT_CONNECTED);
+ }
+ return false;
+ }
+
+ private boolean deviceHasCalendarReceiverRegistered(GBDevice device){
+ for (CalendarReceiver receiver: mCalendarReceiver){
+ if(receiver.getGBDevice().equals(device)){
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void setReceiversEnableState(boolean enable, boolean initialized, FeatureSet features, List <GBDevice> devicesWithCalendar) {
+ LOG.info("Setting broadcast receivers to: " + enable);
+
+ if(enable && features == null){
+ throw new RuntimeException("features cannot be null when enabling receivers");
+ }
+
+ if (enable && initialized && features.supportsCalendarEvents()) {
+ for (GBDevice deviceWithCalendar : devicesWithCalendar) {
+ if (!deviceHasCalendarReceiverRegistered(deviceWithCalendar)) {
+ if (!(GBApplication.isRunningMarshmallowOrLater() && ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_DENIED)) {
+ CalendarReceiver receiver = new CalendarReceiver(this, deviceWithCalendar);
+ receiver.registerBroadcastReceivers();
+ mCalendarReceiver.add(receiver);
+ }
+ }
+ }
+ } else {
+ for (CalendarReceiver registeredReceiver: mCalendarReceiver) {
+ registeredReceiver.dispose();
+ }
+ mCalendarReceiver.clear();
+ }
+
+ if (enable) {
+ if (mPhoneCallReceiver == null) {
+ mPhoneCallReceiver = new PhoneCallReceiver();
+ IntentFilter filter = new IntentFilter();
+ filter.addAction("android.intent.action.PHONE_STATE");
+ filter.addAction("android.intent.action.NEW_OUTGOING_CALL");
+ filter.addAction("nodomain.freeyourgadget.gadgetbridge.MUTE_CALL");
+ ContextCompat.registerReceiver(this, mPhoneCallReceiver, filter, ContextCompat.RECEIVER_EXPORTED);
+ }
+ if (mSMSReceiver == null) {
+ mSMSReceiver = new SMSReceiver();
+ ContextCompat.registerReceiver(this, mSMSReceiver, new IntentFilter("android.provider.Telephony.SMS_RECEIVED"), ContextCompat.RECEIVER_EXPORTED);
+ }
+ if (mPebbleReceiver == null) {
+ mPebbleReceiver = new PebbleReceiver();
+ ContextCompat.registerReceiver(this, mPebbleReceiver, new IntentFilter("com.getpebble.action.SEND_NOTIFICATION"), ContextCompat.RECEIVER_EXPORTED);
+ }
+ if (mMusicPlaybackReceiver == null && features.supportsMusicInfo()) {
+ mMusicPlaybackReceiver = new MusicPlaybackReceiver();
+ IntentFilter filter = new IntentFilter();
+ for (String action : mMusicActions) {
+ filter.addAction(action);
+ }
+ ContextCompat.registerReceiver(this, mMusicPlaybackReceiver, filter, ContextCompat.RECEIVER_EXPORTED);
+ }
+ if (mTimeChangeReceiver == null) {
+ mTimeChangeReceiver = new TimeChangeReceiver();
+ IntentFilter filter = new IntentFilter();
+ filter.addAction("android.intent.action.TIME_SET");
+ filter.addAction("android.intent.action.TIMEZONE_CHANGED");
+ filter.addAction(TimeChangeReceiver.ACTION_DST_CHANGED_OR_PERIODIC_SYNC);
+ ContextCompat.registerReceiver(this, mTimeChangeReceiver, filter, ContextCompat.RECEIVER_EXPORTED);
+ // Ensure alarm is scheduled after registering broadcast receiver
+ // (this is important in case receiver was unregistered when the previous alarm arrived).
+ TimeChangeReceiver.ifEnabledScheduleNextDstChangeOrPeriodicSync(this);
+ }
+ if (mBlueToothPairingRequestReceiver == null) {
+ mBlueToothPairingRequestReceiver = new BluetoothPairingRequestReceiver(this);
+ ContextCompat.registerReceiver(this, mBlueToothPairingRequestReceiver, new IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST), ContextCompat.RECEIVER_EXPORTED);
+ }
+ if (mAlarmClockReceiver == null) {
+ mAlarmClockReceiver = new AlarmClockReceiver();
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(AlarmClockReceiver.ALARM_ALERT_ACTION);
+ filter.addAction(AlarmClockReceiver.ALARM_DONE_ACTION);
+ filter.addAction(AlarmClockReceiver.GOOGLE_CLOCK_ALARM_ALERT_ACTION);
+ filter.addAction(AlarmClockReceiver.GOOGLE_CLOCK_ALARM_DONE_ACTION);
+ ContextCompat.registerReceiver(this, mAlarmClockReceiver, filter, ContextCompat.RECEIVER_EXPORTED);
+ }
+
+ if (mSilentModeReceiver == null) {
+ mSilentModeReceiver = new SilentModeReceiver();
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(AudioManager.RINGER_MODE_CHANGED_ACTION);
+ ContextCompat.registerReceiver(this, mSilentModeReceiver, filter, ContextCompat.RECEIVER_EXPORTED);
+ }
+
+ if (locationService == null) {
+ locationService = new GBLocationService(this);
+ LocalBroadcastManager.getInstance(this).registerReceiver(locationService, locationService.buildFilter());
+ }
+
+ if (mOsmandAidlHelper == null && features.supportsNavigation()) {
+ mOsmandAidlHelper = new OsmandEventReceiver(this.getApplication());
+ }
+
+ // Weather receivers
+ if (features.supportsWeather()) {
+ if (GBApplication.isRunningOreoOrLater()) {
+ if (mLineageOsWeatherReceiver == null) {
+ mLineageOsWeatherReceiver = new LineageOsWeatherReceiver();
+ ContextCompat.registerReceiver(this, mLineageOsWeatherReceiver, new IntentFilter("GB_UPDATE_WEATHER"), ContextCompat.RECEIVER_EXPORTED);
+ }
+ } else {
+ if (mCMWeatherReceiver == null) {
+ mCMWeatherReceiver = new CMWeatherReceiver();
+ ContextCompat.registerReceiver(this, mCMWeatherReceiver, new IntentFilter("GB_UPDATE_WEATHER"), ContextCompat.RECEIVER_EXPORTED);
+ }
+ }
+ if (mTinyWeatherForecastGermanyReceiver == null) {
+ mTinyWeatherForecastGermanyReceiver = new TinyWeatherForecastGermanyReceiver();
+ ContextCompat.registerReceiver(this, mTinyWeatherForecastGermanyReceiver, new IntentFilter("de.kaffeemitkoffein.broadcast.WEATHERDATA"), ContextCompat.RECEIVER_EXPORTED);
+ }
+ if (mGenericWeatherReceiver == null) {
+ mGenericWeatherReceiver = new GenericWeatherReceiver();
+ ContextCompat.registerReceiver(this, mGenericWeatherReceiver, new IntentFilter(GenericWeatherReceiver.ACTION_GENERIC_WEATHER), ContextCompat.RECEIVER_EXPORTED);
+ }
+ if (mOmniJawsObserver == null) {
+ try {
+ mOmniJawsObserver = new OmniJawsObserver(new Handler());
+ getContentResolver().registerContentObserver(OmniJawsObserver.WEATHER_URI, true, mOmniJawsObserver);
+ } catch (PackageManager.NameNotFoundException e) {
+ //Nothing wrong, it just means we're not running on omnirom.
+ }
+ }
+ }
+
+ if (features.supportsSleepAsAndroid())
+ {
+ if (mSleepAsAndroidReceiver == null) {
+ mSleepAsAndroidReceiver = new SleepAsAndroidReceiver();
+ ContextCompat.registerReceiver(this, mSleepAsAndroidReceiver, new IntentFilter(), ContextCompat.RECEIVER_EXPORTED);
+ }
+ }
+
+ if (GBApplication.getPrefs().getBoolean("auto_fetch_enabled", false) &&
+ features.supportsActivityDataFetching() && mGBAutoFetchReceiver == null) {
+ mGBAutoFetchReceiver = new GBAutoFetchReceiver();
+ ContextCompat.registerReceiver(this, mGBAutoFetchReceiver, new IntentFilter("android.intent.action.USER_PRESENT"), ContextCompat.RECEIVER_EXPORTED);
+ }
+ } else {
+ if (mPhoneCallReceiver != null) {
+ unregisterReceiver(mPhoneCallReceiver);
+ mPhoneCallReceiver = null;
+ }
+ if (mSMSReceiver != null) {
+ unregisterReceiver(mSMSReceiver);
+ mSMSReceiver = null;
+ }
+ if (mPebbleReceiver != null) {
+ unregisterReceiver(mPebbleReceiver);
+ mPebbleReceiver = null;
+ }
+ if (mMusicPlaybackReceiver != null) {
+ unregisterReceiver(mMusicPlaybackReceiver);
+ mMusicPlaybackReceiver = null;
+ }
+ if (mTimeChangeReceiver != null) {
+ unregisterReceiver(mTimeChangeReceiver);
+ mTimeChangeReceiver = null;
+ }
+
+ if (mBlueToothPairingRequestReceiver != null) {
+ unregisterReceiver(mBlueToothPairingRequestReceiver);
+ mBlueToothPairingRequestReceiver = null;
+ }
+ if (mAlarmClockReceiver != null) {
+ unregisterReceiver(mAlarmClockReceiver);
+ mAlarmClockReceiver = null;
+ }
+ if (mSilentModeReceiver != null) {
+ unregisterReceiver(mSilentModeReceiver);
+ mSilentModeReceiver = null;
+ }
+ if (locationService != null) {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(locationService);
+ locationService.stopAll();
+ locationService = null;
+ }
+ if (mCMWeatherReceiver != null) {
+ unregisterReceiver(mCMWeatherReceiver);
+ mCMWeatherReceiver = null;
+ }
+ if (mLineageOsWeatherReceiver != null) {
+ unregisterReceiver(mLineageOsWeatherReceiver);
+ mLineageOsWeatherReceiver = null;
+ }
+ if (mOmniJawsObserver != null) {
+ getContentResolver().unregisterContentObserver(mOmniJawsObserver);
+ mOmniJawsObserver = null;
+ }
+ if (mTinyWeatherForecastGermanyReceiver != null) {
+ unregisterReceiver(mTinyWeatherForecastGermanyReceiver);
+ mTinyWeatherForecastGermanyReceiver = null;
+ }
+ if (mOsmandAidlHelper != null) {
+ mOsmandAidlHelper.cleanupResources();
+ mOsmandAidlHelper = null;
+ }
+ if (mGBAutoFetchReceiver != null) {
+ unregisterReceiver(mGBAutoFetchReceiver);
+ mGBAutoFetchReceiver = null;
+ }
+ if (mGenericWeatherReceiver != null) {
+ unregisterReceiver(mGenericWeatherReceiver);
+ mGenericWeatherReceiver = null;
+ }
+ if (mSleepAsAndroidReceiver != null) {
+ unregisterReceiver(mSleepAsAndroidReceiver);
+ mSleepAsAndroidReceiver = null;
+ }
+ }
+ }
+
+ private void sendCachedNotifications(GBDevice device) {
+ ArrayList<Intent> notifCache = cachedNotifications.get(device.getAddress());
+ if (notifCache == null) return;
+ try {
+ while (notifCache.size() > 0) {
+ handleAction(notifCache.remove(0), ACTION_NOTIFICATION, device);
+ }
+ } catch (DeviceNotFoundException e) {
+ LOG.error("Error while sending cached notifications to "+device.getAliasOrName(), e);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ if (hasPrefs()) {
+ getPrefs().getPreferences().unregisterOnSharedPreferenceChangeListener(this);
+ }
+
+ LOG.debug("DeviceCommunicationService is being destroyed");
+ super.onDestroy();
+
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
+ setReceiversEnableState(false, false, null, null); // disable BroadcastReceivers
+
+ unregisterReceiver(mBlueToothConnectReceiver);
+ mBlueToothConnectReceiver = null;
+
+ unregisterReceiver(mAutoConnectInvervalReceiver);
+ mAutoConnectInvervalReceiver.destroy();
+ mAutoConnectInvervalReceiver = null;
+
+ for(GBDevice device : getGBDevices()){
+ try {
+ removeDeviceSupport(device);
+ } catch (DeviceNotFoundException e) {
+ e.printStackTrace();
+ }
+ }
+ GB.removeNotification(GB.NOTIFICATION_ID, this); // need to do this because the updated notification won't be cancelled when service stops
+
+ unregisterReceiver(bluetoothCommandReceiver);
+ unregisterReceiver(deviceSettingsReceiver);
+ unregisterReceiver(intentApiReceiver);
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ if (GBPrefs.DEVICE_AUTO_RECONNECT.equals(key)) {
+ for(DeviceStruct deviceStruct : deviceStructs){
+ boolean autoReconnect = getPrefs().getAutoReconnect(deviceStruct.getDevice());
+ deviceStruct.getDeviceSupport().setAutoReconnect(autoReconnect);
+ }
+ }
+ if (GBPrefs.CHART_MAX_HEART_RATE.equals(key) || GBPrefs.CHART_MIN_HEART_RATE.equals(key)) {
+ HeartRateUtils.getInstance().updateCachedHeartRatePreferences();
+ }
+ if (GBPrefs.PREF_ALLOW_INTENT_API.equals(key)){
+ allowBluetoothIntentApi = sharedPreferences.getBoolean(GBPrefs.PREF_ALLOW_INTENT_API, false);
+ GB.log("allowBluetoothIntentApi changed to " + allowBluetoothIntentApi, GB.INFO, null);
+ }
+ }
+
+ protected boolean hasPrefs() {
+ return getPrefs().getPreferences() != null;
+ }
+
+ public GBPrefs getPrefs() {
+ return GBApplication.getPrefs();
+ }
+
+ public GBDevice[] getGBDevices() {
+ GBDevice[] devices = new GBDevice[deviceStructs.size()];
+ for(int i = 0; i < devices.length; i++){
+ devices[i] = deviceStructs.get(i).getDevice();
+ }
+ return devices;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupport.java
new file mode 100644
index 0000000..ca4d482
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupport.java
@@ -0,0 +1,142 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.service;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.Context;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.EventHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+/**
+ * Provides the communication support for a specific device. Instances will <b>only</b>
+ * be used inside the DeviceCommunicationService. Has hooks to manage the life cycle
+ * of a device: instances of this interface will be created, initialized, and disposed
+ * as needed.
+ * <p/>
+ * Implementations need to act accordingly, in order to establish, reestablish or close
+ * the connection to the device.
+ * <p/>
+ * In principle, this interface is agnostic to the kind of transport, i.e. whether the
+ * device is connected via Bluetooth, Bluetooth LE, Wifi or something else, however at the
+ * moment, only the BluetoothAdapter is passed to implementations.
+ */
+public interface DeviceSupport extends EventHandler {
+ /**
+ * Sets all context information needed for the instance to operate.
+ *
+ * @param gbDevice the device to operate with
+ * @param btAdapter the bluetooth adapter to use
+ * @param context the android context, e.g. to look up resources
+ */
+ void setContext(GBDevice gbDevice, BluetoothAdapter btAdapter, Context context);
+
+ /**
+ * Returns whether a transport-level connection is established with the device
+ *
+ * @return whether the device is connected with the system running this software
+ */
+ boolean isConnected();
+
+ /**
+ * Attempts an initial connection to the device, typically after the user "discovered"
+ * and connects to it for the first time. Some implementations may perform an additional
+ * initialization or application-level pairing compared to the regular {@link #connect()}.
+ * <p/>
+ * Implementations may perform the connection in a synchronous or asynchronous way.
+ * Returns true if a connection attempt was made. If the implementation is synchronous
+ * it may also return true if the connection was successfully established, however
+ * callers shall not rely on that.
+ * <p/>
+ * The actual connection state change (successful or not) will be reported via the
+ * #getDevice device as a device change Intent.
+ *
+ * Note: the default implementation {@link AbstractDeviceSupport#connectFirstTime()} just
+ * calls {@link #connect()}
+ *
+ * @see #connect()
+ * @see GBDevice#ACTION_DEVICE_CHANGED
+ */
+ boolean connectFirstTime();
+
+ /**
+ * Attempts to establish a connection to the device. Implementations may perform
+ * the connection in a synchronous or asynchronous way.
+ * Returns true if a connection attempt was made. If the implementation is synchronous
+ * it may also return true if the connection was successfully established, however
+ * callers shall not rely on that.
+ * <p/>
+ * The actual connection state change (successful or not) will be reported via the
+ * #getDevice device as a device change Intent.
+ *
+ * @see #connectFirstTime()
+ * @see GBDevice#ACTION_DEVICE_CHANGED
+ */
+ boolean connect();
+
+ /**
+ * Disposes of this instance, closing all connections and freeing all resources.
+ * Instances will not be reused after having been disposed.
+ */
+ void dispose();
+
+ /**
+ * Returns true if a connection attempt shall be made automatically whenever
+ * needed (e.g. when a notification shall be sent to the device while not connected.
+ */
+ boolean useAutoConnect();
+
+ /**
+ * Configures this instance to automatically attempt to reconnect after a connection loss.
+ * How, how long, or how often is up to the implementation.
+ * Note that tome implementations may not support automatic reconnection at all.
+ * @param enable
+ */
+ void setAutoReconnect(boolean enable);
+
+ /**
+ * Returns whether this instance to configured to automatically attempt to reconnect after a
+ * connection loss.
+ */
+ boolean getAutoReconnect();
+
+ void setScanReconnect(boolean enable);
+
+ boolean getScanReconnect();
+
+ /**
+ * Returns the associated device this instance communicates with.
+ */
+ GBDevice getDevice();
+
+ /**
+ * Returns the bluetooth adapter. When we support different transports
+ * than Bluetooth, we should use a generic type T and rename this method
+ * to getTransportAdapter()
+ */
+ BluetoothAdapter getBluetoothAdapter();
+
+ /**
+ * Returns the Android context to use, e.g. to look up resources.
+ */
+ Context getContext();
+
+ /**
+ * converts String in a device specific way, e.g. re-map characters for a custom font
+ */
+ String customStringFilter(String inputString);
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java
new file mode 100644
index 0000000..06488dc
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java
@@ -0,0 +1,159 @@
+/* Copyright (C) 2015-2024 115ek, Andreas B枚hler, Andreas Shimokawa, Andrew
+ Watkins, angelpup, Carsten Pfeiffer, Cre3per, criogenic, DanialHanif, Daniel
+ Dakhno, Daniele Gobbetti, Daniel Thompson, Da Pa, Dmytro Bielik, Frank Ertl,
+ GeekosaurusR3x, Gordon Williams, Jean-Fran莽ois Greffier, jfgreffier, jhey,
+ Joa虄o Paulo Barraca, Jochen S, Johannes Krude, Jos茅 Rebelo, ladbsoft,
+ Lesur Frederic, mamucho, Manuel Ru脽, maxirnilian, mkusnierz, narektor,
+ Noodlez, odavo32nof, opavlov, pangwalla, Pavel Elagin, Petr Kadlec, Petr
+ Van臎k, protomors, Quallenauge, Quang Ng么, Raghd Hamzeh, Sami Alaoui,
+ Sebastian Kranz, sedy89, Sergey Trofimov, Sophanimus, Stefan Bora, Taavi
+ Eom盲e, thermatk, tiparega, Vadim Kaushan, x29a, xaos, Yukai Li
+
+ 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;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.Context;
+import android.util.Log;
+import android.widget.Toast;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.externalevents.BluetoothConnectReceiver;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.PebbleSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.EnumSet;
+
+public class DeviceSupportFactory {
+ private static final Logger LOG = LoggerFactory.getLogger(DeviceSupportFactory.class);
+
+ private final BluetoothAdapter mBtAdapter;
+ private final Context mContext;
+
+ DeviceSupportFactory(Context context) {
+ mContext = context;
+ mBtAdapter = BluetoothAdapter.getDefaultAdapter();
+ }
+
+ public synchronized DeviceSupport createDeviceSupport(GBDevice device) throws GBException {
+ DeviceSupport deviceSupport;
+ String deviceAddress = device.getAddress();
+ int indexFirstColon = deviceAddress.indexOf(":");
+ if (indexFirstColon > 0) {
+ if (indexFirstColon == deviceAddress.lastIndexOf(":")) { // only one colon
+ deviceSupport = createTCPDeviceSupport(device);
+ } else {
+ // multiple colons -- bt?
+ deviceSupport = createBTDeviceSupport(device);
+ }
+ } else {
+ // no colon at all, maybe a class name?
+ deviceSupport = createClassNameDeviceSupport(device);
+ }
+
+ if (deviceSupport != null) {
+ return deviceSupport;
+ }
+
+ // no device found, check transport availability and warn
+ checkBtAvailability();
+ return null;
+ }
+
+ private DeviceSupport createClassNameDeviceSupport(GBDevice device) throws GBException {
+ String className = device.getAddress();
+ try {
+ Class<?> deviceSupportClass = Class.forName(className);
+ Constructor<?> constructor = deviceSupportClass.getConstructor();
+ DeviceSupport support = (DeviceSupport) constructor.newInstance();
+ // has to create the device itself
+ support.setContext(device, null, mContext);
+ return support;
+ } catch (ClassNotFoundException e) {
+ return null; // not a class, or not known at least
+ } catch (Exception e) {
+ throw new GBException("Error creating DeviceSupport instance for " + className, e);
+ }
+ }
+
+ private void checkBtAvailability() {
+ if (mBtAdapter == null) {
+ GB.toast(mContext.getString(R.string.bluetooth_is_not_supported_), Toast.LENGTH_SHORT, GB.WARN);
+ } else if (!mBtAdapter.isEnabled()) {
+ GB.toast(mContext.getString(R.string.bluetooth_is_disabled_), Toast.LENGTH_SHORT, GB.WARN);
+ }
+ }
+
+ private ServiceDeviceSupport createServiceDeviceSupport(GBDevice device) throws GBException {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ Class supportClass = coordinator.getDeviceSupportClass();
+
+ try {
+ Constructor supportConstructor = supportClass.getConstructor(DeviceType.class);
+ DeviceSupport supportInstance = (DeviceSupport) supportConstructor.newInstance(device.getType());
+ return new ServiceDeviceSupport(supportInstance, coordinator.getInitialFlags());
+ } catch (NoSuchMethodException e) {
+ // ignore, let next call get the default, zero-argument constructor
+ } catch (ReflectiveOperationException e) {
+ LOG.error("error calling DeviceSupport constructor with argument 'DeviceType'");
+ throw new GBException(e);
+ }
+
+ try {
+ DeviceSupport supportInstance = (DeviceSupport) supportClass.newInstance();
+ return new ServiceDeviceSupport(supportInstance, coordinator.getInitialFlags());
+ } catch (ReflectiveOperationException e) {
+ LOG.error("error calling DeviceSupport constructor with zero arguments");
+ throw new GBException(e);
+ }
+ }
+
+ private DeviceSupport createBTDeviceSupport(GBDevice gbDevice) throws GBException {
+ if (mBtAdapter != null && mBtAdapter.isEnabled()) {
+ try {
+ DeviceSupport deviceSupport = createServiceDeviceSupport(gbDevice);
+ if (deviceSupport != null) {
+ deviceSupport.setContext(gbDevice, mBtAdapter, mContext);
+ return deviceSupport;
+ }
+ } catch (Exception e) {
+ throw new GBException(mContext.getString(R.string.cannot_connect_bt_address_invalid_), e);
+ }
+ }
+ return null;
+ }
+
+ private DeviceSupport createTCPDeviceSupport(GBDevice gbDevice) throws GBException {
+ try {
+ DeviceSupport deviceSupport = new ServiceDeviceSupport(new PebbleSupport(), EnumSet.of(ServiceDeviceSupport.Flags.BUSY_CHECKING));
+ deviceSupport.setContext(gbDevice, mBtAdapter, mContext);
+ return deviceSupport;
+ } catch (Exception e) {
+ throw new GBException("cannot connect to " + gbDevice, e); // FIXME: localize
+ }
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/HeadphoneHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/HeadphoneHelper.java
new file mode 100644
index 0000000..b06e413
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/HeadphoneHelper.java
@@ -0,0 +1,161 @@
+/* Copyright (C) 2024 Arjan Schrijver, Daniele Gobbetti, Martin.JM
+
+ 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;
+
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SPEAK_NOTIFICATIONS_FOCUS_EXCLUSIVE;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.media.AudioManager;
+import android.os.Handler;
+import android.os.Looper;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.util.GBTextToSpeech;
+
+public class HeadphoneHelper {
+ private static final Logger LOG = LoggerFactory.getLogger(HeadphoneHelper.class);
+
+ public interface Callback {
+ void evaluateGBDeviceEvent(GBDeviceEvent event);
+ }
+
+ private final GBDevice device;
+ private final GBTextToSpeech gbTextToSpeech;
+ private final Callback callback;
+
+ public HeadphoneHelper(Context context, GBDevice device, Callback callback) {
+ this.device = device;
+ this.callback = callback;
+ final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(this.device.getAddress());
+ gbTextToSpeech = new GBTextToSpeech(context, new UtteranceProgressListener(),
+ prefs.getBoolean(PREF_SPEAK_NOTIFICATIONS_FOCUS_EXCLUSIVE, false) ?
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE :
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
+ );
+ }
+
+ public void dispose() {
+ gbTextToSpeech.shutdown();
+ }
+
+ public void onSetCallState(CallSpec callSpec) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
+
+ if (!prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_AUTO_REPLY_INCOMING_CALL, false))
+ return;
+
+ final int delayMillis = Integer.parseInt(prefs.getString(DeviceSettingsPreferenceConst.PREF_AUTO_REPLY_INCOMING_CALL_DELAY, "15")) * 1000;
+
+ if (CallSpec.CALL_INCOMING != callSpec.command)
+ return;
+
+ if (!gbTextToSpeech.isConnected()) { // schedule the automatic reply here, if the speech to text is not connected. Else it's done by the callback, and the timeout starts after the name or number have been spoken
+ Looper mainLooper = Looper.getMainLooper();
+ LOG.debug("Incoming call, scheduling auto answer in {} seconds.", delayMillis / 1000);
+
+ new Handler(mainLooper).postDelayed(() -> {
+ GBDeviceEventCallControl callCmd = new GBDeviceEventCallControl();
+ callCmd.event = GBDeviceEventCallControl.Event.ACCEPT;
+ callback.evaluateGBDeviceEvent(callCmd);
+ }, delayMillis); //15s
+
+ return;
+ }
+ String speechText = callSpec.name;
+ if (callSpec.name.equals(callSpec.number)) {
+ StringBuilder numberSpeller = new StringBuilder();
+ for (char c : callSpec.number.toCharArray()) {
+ numberSpeller.append(c).append(" ");
+ }
+ speechText = numberSpeller.toString();
+ }
+ gbTextToSpeech.speak(speechText);
+ }
+
+ public void onNotification(NotificationSpec notificationSpec) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
+
+ if (!prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SPEAK_NOTIFICATIONS_ALOUD, false))
+ return;
+
+ if (gbTextToSpeech.isConnected()) {
+ String notificationSpeller = new StringBuilder()
+ .append(notificationSpec.sourceName == null ? "" : notificationSpec.sourceName).append(". ")
+ .append(notificationSpec.title == null ? "" : notificationSpec.title).append(": ")
+ .append(notificationSpec.body == null ? "" : notificationSpec.body).toString();
+
+ gbTextToSpeech.speakNotification(notificationSpeller);
+ }
+ }
+
+ /**
+ *
+ * @param config
+ * @return True if handled, false otherwise
+ */
+ public boolean onSendConfiguration(String config) {
+ if (PREF_SPEAK_NOTIFICATIONS_FOCUS_EXCLUSIVE.equals(config)) {
+ final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
+ gbTextToSpeech.setAudioFocus(prefs.getBoolean(PREF_SPEAK_NOTIFICATIONS_FOCUS_EXCLUSIVE, false) ?
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE :
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK);
+ return true;
+ }
+ return false;
+ }
+
+ private class UtteranceProgressListener extends android.speech.tts.UtteranceProgressListener {
+ @Override
+ public void onStart(String utteranceId) {
+// LOG.debug("UtteranceProgressListener onStart.");
+ }
+
+ @Override
+ public void onDone(String utteranceId) {
+// LOG.debug("UtteranceProgressListener onDone.");
+
+ gbTextToSpeech.abandonFocus();
+ if (utteranceId.equals("call")) {
+ SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(HeadphoneHelper.this.device.getAddress());
+ final int delayMillis = Integer.parseInt(prefs.getString(DeviceSettingsPreferenceConst.PREF_AUTO_REPLY_INCOMING_CALL_DELAY, "15")) * 1000;
+
+
+ Looper mainLooper = Looper.getMainLooper();
+ new Handler(mainLooper).postDelayed(() -> {
+ GBDeviceEventCallControl callCmd = new GBDeviceEventCallControl();
+ callCmd.event = GBDeviceEventCallControl.Event.ACCEPT;
+ callback.evaluateGBDeviceEvent(callCmd);
+ }, delayMillis); //15s
+ }
+ }
+
+ @Override
+ public void onError(String utteranceId) {
+ LOG.error("UtteranceProgressListener returned error.");
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/NotificationCollectorMonitorService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/NotificationCollectorMonitorService.java
new file mode 100644
index 0000000..15a3734
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/NotificationCollectorMonitorService.java
@@ -0,0 +1,95 @@
+/* Copyright (C) 2017-2024 Daniele Gobbetti
+
+ 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;
+
+import android.app.ActivityManager;
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.IBinder;
+import android.os.Process;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.externalevents.NotificationListener;
+
+/**
+ * Original source by xinghui - see https://gist.github.com/xinghui/b2ddd8cffe55c4b62f5d8846d5545bf9
+ * NB: no license specified in the source code as of 2017-04-19
+ */
+public class NotificationCollectorMonitorService extends Service {
+
+ private static final Logger LOG = LoggerFactory.getLogger(NotificationCollectorMonitorService.class);
+
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ ensureCollectorRunning();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ return START_STICKY;
+ }
+
+ private void ensureCollectorRunning() {
+ ComponentName collectorComponent = new ComponentName(this, NotificationListener.class);
+ LOG.info("ensureCollectorRunning collectorComponent: " + collectorComponent);
+ ActivityManager manager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
+ boolean collectorRunning = false;
+ List<ActivityManager.RunningServiceInfo> runningServices = manager.getRunningServices(Integer.MAX_VALUE);
+ if (runningServices == null) {
+ LOG.info("ensureCollectorRunning() runningServices is NULL");
+ return;
+ }
+ for (ActivityManager.RunningServiceInfo service : runningServices) {
+ if (service.service.equals(collectorComponent)) {
+ LOG.warn("ensureCollectorRunning service - pid: " + service.pid + ", currentPID: " + Process.myPid() + ", clientPackage: " + service.clientPackage + ", clientCount: " + service.clientCount
+ + ", clientLabel: " + ((service.clientLabel == 0) ? "0" : "(" + getResources().getString(service.clientLabel) + ")"));
+ if (service.pid == Process.myPid() /*&& service.clientCount > 0 && !TextUtils.isEmpty(service.clientPackage)*/) {
+ collectorRunning = true;
+ }
+ }
+ }
+ if (collectorRunning) {
+ LOG.debug("ensureCollectorRunning: collector is running");
+ return;
+ }
+ LOG.debug("ensureCollectorRunning: collector not running, reviving...");
+ toggleNotificationListenerService();
+ }
+
+ private void toggleNotificationListenerService() {
+ LOG.debug("toggleNotificationListenerService() called");
+ ComponentName thisComponent = new ComponentName(this, NotificationListener.class);
+ PackageManager pm = getPackageManager();
+ pm.setComponentEnabledSetting(thisComponent, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
+ pm.setComponentEnabledSetting(thisComponent, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
+
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java
new file mode 100644
index 0000000..407bcd6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java
@@ -0,0 +1,544 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Arjan Schrijver, Carsten
+ Pfeiffer, Daniel Dakhno, Jos茅 Rebelo, Julien Pivotto, Kasha, Sebastian Kranz,
+ Steffen Liebergeld
+
+ 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;
+
+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.EnumSet;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCameraRemote;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceMusic;
+import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.Contact;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NavigationInfoSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.Reminder;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.WorldClock;
+
+/**
+ * Wraps another device support instance and supports busy-checking and throttling of events.
+ */
+public class ServiceDeviceSupport implements DeviceSupport {
+ public static enum Flags {
+ THROTTLING,
+ BUSY_CHECKING,
+ }
+
+ private static final Logger LOG = LoggerFactory.getLogger(ServiceDeviceSupport.class);
+
+ private static final long THROTTLING_THRESHOLD = 1000; // throttle multiple events in between one second
+ private final DeviceSupport delegate;
+
+ private long lastNotificationTime = 0;
+ private String lastNotificationKind;
+ private final EnumSet<Flags> flags;
+
+ public ServiceDeviceSupport(DeviceSupport delegate, EnumSet<Flags> flags) {
+ this.delegate = delegate;
+ this.flags = flags;
+ }
+
+ @Override
+ public void setContext(GBDevice gbDevice, BluetoothAdapter btAdapter, Context context) {
+ delegate.setContext(gbDevice, btAdapter, context);
+ }
+
+ @Override
+ public boolean isConnected() {
+ return delegate.isConnected();
+ }
+
+ @Override
+ public boolean connectFirstTime() {
+ return delegate.connectFirstTime();
+ }
+
+ @Override
+ public boolean connect() {
+ return delegate.connect();
+ }
+
+ @Override
+ public void setAutoReconnect(boolean enable) {
+ delegate.setAutoReconnect(enable);
+ }
+
+ @Override
+ public boolean getAutoReconnect() {
+ return delegate.getAutoReconnect();
+ }
+
+ @Override
+ public void setScanReconnect(boolean enable) {
+ delegate.setScanReconnect(enable);
+ }
+
+ @Override
+ public boolean getScanReconnect(){
+ return delegate.getScanReconnect();
+ }
+
+ @Override
+ public void dispose() {
+ delegate.dispose();
+ }
+
+ @Override
+ public GBDevice getDevice() {
+ return delegate.getDevice();
+ }
+
+ @Override
+ public BluetoothAdapter getBluetoothAdapter() {
+ return delegate.getBluetoothAdapter();
+ }
+
+ @Override
+ public Context getContext() {
+ return delegate.getContext();
+ }
+
+ @Override
+ public String customStringFilter(String inputString) {
+ return delegate.customStringFilter(inputString);
+ }
+
+ @Override
+ public boolean useAutoConnect() {
+ return delegate.useAutoConnect();
+ }
+
+ private boolean checkBusy(String notificationKind) {
+ if (!flags.contains(Flags.BUSY_CHECKING)) {
+ return false;
+ }
+ if (getDevice().isBusy()) {
+ LOG.info("Ignoring " + notificationKind + " because we're busy with " + getDevice().getBusyTask());
+ return true;
+ }
+ return false;
+ }
+
+ private boolean checkThrottle(String notificationKind) {
+ if (!flags.contains(Flags.THROTTLING)) {
+ return false;
+ }
+ long currentTime = System.currentTimeMillis();
+ if ((currentTime - lastNotificationTime) < THROTTLING_THRESHOLD) {
+ if (notificationKind != null && notificationKind.equals(lastNotificationKind)) {
+ LOG.info("Ignoring " + notificationKind + " because of throttling threshold reached");
+ return true;
+ }
+ }
+ lastNotificationTime = currentTime;
+ lastNotificationKind = notificationKind;
+ return false;
+ }
+
+ @Override
+ public void onNotification(NotificationSpec notificationSpec) {
+ if (checkBusy("generic notification") || checkThrottle("generic notification")) {
+ return;
+ }
+ delegate.onNotification(notificationSpec);
+ }
+
+ @Override
+ public void onDeleteNotification(int id) {
+ delegate.onDeleteNotification(id);
+ }
+
+ @Override
+ public void onSetTime() {
+ if (checkBusy("set time") || checkThrottle("set time")) {
+ return;
+ }
+ delegate.onSetTime();
+ }
+
+ // No throttling for the other events
+
+ @Override
+ public void onSetCallState(CallSpec callSpec) {
+ if (checkBusy("set call state")) {
+ return;
+ }
+ delegate.onSetCallState(callSpec);
+ }
+
+ @Override
+ public void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) {
+ if (checkBusy("set canned messages")) {
+ return;
+ }
+ delegate.onSetCannedMessages(cannedMessagesSpec);
+ }
+
+ @Override
+ public void onSetMusicState(MusicStateSpec stateSpec) {
+ if (checkBusy("set music state")) {
+ return;
+ }
+ delegate.onSetMusicState(stateSpec);
+ }
+
+ @Override
+ public void onSetMusicInfo(MusicSpec musicSpec) {
+ if (checkBusy("set music info")) {
+ return;
+ }
+ delegate.onSetMusicInfo(musicSpec);
+ }
+
+ @Override
+ public void onSetPhoneVolume(float volume) {
+ if (checkBusy("set phone volume")) {
+ return;
+ }
+ delegate.onSetPhoneVolume(volume);
+ }
+
+ @Override
+ public void onChangePhoneSilentMode(final int ringerMode) {
+ if (checkBusy("change phone silent mode")) {
+ return;
+ }
+ delegate.onChangePhoneSilentMode(ringerMode);
+ }
+
+ @Override
+ public void onSetNavigationInfo(NavigationInfoSpec navigationInfoSpec) {
+ if (checkBusy("set navigation info")) {
+ return;
+ }
+ delegate.onSetNavigationInfo(navigationInfoSpec);
+ }
+
+ @Override
+ public void onInstallApp(Uri uri) {
+ if (checkBusy("install app")) {
+ return;
+ }
+ delegate.onInstallApp(uri);
+ }
+
+ @Override
+ public void onAppInfoReq() {
+ if (checkBusy("app info request")) {
+ return;
+ }
+ delegate.onAppInfoReq();
+ }
+
+ @Override
+ public void onAppStart(UUID uuid, boolean start) {
+ if (checkBusy("app start")) {
+ return;
+ }
+ delegate.onAppStart(uuid, start);
+ }
+
+ @Override
+ public void onAppDownload(UUID uuid) {
+ if (checkBusy("app download")) {
+ return;
+ }
+ delegate.onAppDownload(uuid);
+ }
+
+ @Override
+ public void onAppDelete(UUID uuid) {
+ if (checkBusy("app delete")) {
+ return;
+ }
+ delegate.onAppDelete(uuid);
+ }
+
+ @Override
+ public void onAppConfiguration(UUID uuid, String config, Integer id) {
+ if (checkBusy("app configuration")) {
+ return;
+ }
+ delegate.onAppConfiguration(uuid, config, id);
+ }
+
+ @Override
+ public void onAppReorder(UUID[] uuids) {
+ if (checkBusy("app reorder")) {
+ return;
+ }
+ delegate.onAppReorder(uuids);
+ }
+
+ @Override
+ public void onFetchRecordedData(int dataTypes) {
+ if (checkBusy("fetch activity data")) {
+ return;
+ }
+ delegate.onFetchRecordedData(dataTypes);
+ }
+
+ @Override
+ public void onReset(int flags) {
+ if (checkBusy("reset")) {
+ return;
+ }
+ delegate.onReset(flags);
+ }
+
+ @Override
+ public void onHeartRateTest() {
+ if (checkBusy("heartrate")) {
+ return;
+ }
+ delegate.onHeartRateTest();
+ }
+
+ @Override
+ public void onFindDevice(boolean start) {
+ if (checkBusy("find device")) {
+ return;
+ }
+ delegate.onFindDevice(start);
+ }
+
+ @Override
+ public void onFindPhone(final boolean start) {
+ if (checkBusy("phone found")) {
+ return;
+ }
+ delegate.onFindPhone(start);
+ }
+
+ @Override
+ public void onSetConstantVibration(int intensity) {
+ if (checkBusy("set constant vibration")) {
+ return;
+ }
+ delegate.onSetConstantVibration(intensity);
+ }
+
+ @Override
+ public void onScreenshotReq() {
+ if (checkBusy("request screenshot")) {
+ return;
+ }
+ delegate.onScreenshotReq();
+ }
+
+ @Override
+ public void onSetAlarms(ArrayList<? extends Alarm> alarms) {
+ if (checkBusy("set alarms")) {
+ return;
+ }
+ delegate.onSetAlarms(alarms);
+ }
+
+ @Override
+ public void onSetReminders(ArrayList<? extends Reminder> reminders) {
+ if (checkBusy("set reminders")) {
+ return;
+ }
+ delegate.onSetReminders(reminders);
+ }
+
+ @Override
+ public void onSetContacts(ArrayList<? extends Contact> contacts) {
+ if (checkBusy("set contacts")) {
+ return;
+ }
+ delegate.onSetContacts(contacts);
+ }
+
+ public void onSetLoyaltyCards(final ArrayList<LoyaltyCard> cards) {
+ if (checkBusy("set loyalty cards")) {
+ return;
+ }
+ delegate.onSetLoyaltyCards(cards);
+ }
+
+ @Override
+ public void onSetWorldClocks(ArrayList<? extends WorldClock> clocks) {
+ if (checkBusy("set world clocks")) {
+ return;
+ }
+ delegate.onSetWorldClocks(clocks);
+ }
+
+ @Override
+ public void onEnableRealtimeSteps(boolean enable) {
+ if (checkBusy("enable realtime steps: " + enable)) {
+ return;
+ }
+ delegate.onEnableRealtimeSteps(enable);
+ }
+
+ @Override
+ public void onEnableHeartRateSleepSupport(boolean enable) {
+ if (checkBusy("enable heart rate sleep support: " + enable)) {
+ return;
+ }
+ delegate.onEnableHeartRateSleepSupport(enable);
+ }
+
+ @Override
+ public void onSetHeartRateMeasurementInterval(int seconds) {
+ if (checkBusy("set heart rate measurement interval: " + seconds + "s")) {
+ return;
+ }
+ delegate.onSetHeartRateMeasurementInterval(seconds);
+ }
+
+ @Override
+ public void onEnableRealtimeHeartRateMeasurement(boolean enable) {
+ if (checkBusy("enable realtime heart rate measurement: " + enable)) {
+ return;
+ }
+ delegate.onEnableRealtimeHeartRateMeasurement(enable);
+ }
+
+ @Override
+ public void onAddCalendarEvent(CalendarEventSpec calendarEventSpec) {
+ if (checkBusy("add calendar event")) {
+ return;
+ }
+ delegate.onAddCalendarEvent(calendarEventSpec);
+ }
+
+ @Override
+ public void onDeleteCalendarEvent(byte type, long id) {
+ if (checkBusy("delete calendar event")) {
+ return;
+ }
+ delegate.onDeleteCalendarEvent(type, id);
+ }
+
+ @Override
+ public void onSendConfiguration(String config) {
+ if (checkBusy("send configuration: " + config)) {
+ return;
+ }
+ delegate.onSendConfiguration(config);
+ }
+
+ @Override
+ public void onReadConfiguration(String config) {
+ if (checkBusy("read configuration: " + config)) {
+ return;
+ }
+ delegate.onReadConfiguration(config);
+ }
+
+ @Override
+ public void onTestNewFunction() {
+ if (checkBusy("test new function event")) {
+ return;
+ }
+ delegate.onTestNewFunction();
+ }
+
+ @Override
+ public void onSendWeather(ArrayList<WeatherSpec> weatherSpecs) {
+ if (checkBusy("send weather events")) {
+ return;
+ }
+ delegate.onSendWeather(weatherSpecs);
+ }
+
+ @Override
+ public void onSetFmFrequency(float frequency) {
+ if (checkBusy("set frequency event")) {
+ return;
+ }
+ delegate.onSetFmFrequency(frequency);
+ }
+
+ @Override
+ public void onSetLedColor(int color) {
+ if (checkBusy("set led color event")) {
+ return;
+ }
+ delegate.onSetLedColor(color);
+ }
+
+ @Override
+ public void onPowerOff() {
+ if (checkBusy("power off event")) {
+ return;
+ }
+ delegate.onPowerOff();
+ }
+
+ @Override
+ public void onSetGpsLocation(Location location) {
+ if (checkBusy("set gps location")) {
+ return;
+ }
+ delegate.onSetGpsLocation(location);
+ }
+
+ @Override
+ public void onSleepAsAndroidAction(String action, Bundle extras) {
+ if (checkBusy("sleep as android")) {
+ return;
+ }
+ delegate.onSleepAsAndroidAction(action, extras);
+ }
+
+ @Override
+ public void onCameraStatusChange(GBDeviceEventCameraRemote.Event event, String filename) {
+ if (checkBusy("camera status")) {
+ return;
+ }
+ delegate.onCameraStatusChange(event, filename);
+ }
+
+ @Override
+ public void onMusicListReq() {
+ if (checkBusy("music list request")) {
+ return;
+ }
+ delegate.onMusicListReq();
+ }
+
+ @Override
+ public void onMusicOperation(int operation, int playlistIndex, String playlistName, ArrayList<Integer> musicIds) {
+ if (checkBusy("music operation")) {
+ return;
+ }
+ delegate.onMusicOperation(operation, playlistIndex, playlistName, musicIds);
+ }
+}
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 0000000..ec37627
--- /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<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;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java
new file mode 100644
index 0000000..479a484
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractBTBRDeviceSupport.java
@@ -0,0 +1,154 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr;
+
+import org.slf4j.Logger;
+
+import java.io.IOException;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.Logging;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport;
+
+/**
+ * Abstract base class for devices connected through a serial protocol, like RFCOMM BT or TCP socket.
+ * <p/>
+ * The connection to the device and all communication is made with a generic
+ * {@link nodomain.freeyourgadget.gadgetbridge.service.btclassic.BtClassicIoThread}.
+ * Messages to the device are encoded
+ * sent via {@link nodomain.freeyourgadget.gadgetbridge.service.btclassic.BtClassicIoThread}.
+ *
+ * @see nodomain.freeyourgadget.gadgetbridge.service.btclassic.BtClassicIoThread
+ */
+public abstract class AbstractBTBRDeviceSupport extends AbstractDeviceSupport implements SocketCallback {
+ private BtBRQueue mQueue;
+ private UUID mSupportedService = null;
+ private int mBufferSize = 1024;
+ private final Logger logger;
+
+ public AbstractBTBRDeviceSupport(Logger logger) {
+ this.logger = logger;
+ if (logger == null) {
+ throw new IllegalArgumentException("logger must not be null");
+ }
+ }
+
+ @Override
+ public boolean connect() {
+ if (mQueue == null) {
+ mQueue = new BtBRQueue(getBluetoothAdapter(), getDevice(), getContext(), this, getSupportedService(), getBufferSize());
+ }
+ return mQueue.connect();
+ }
+
+ public void disconnect() {
+ if (mQueue != null) {
+ mQueue.disconnect();
+ }
+ }
+
+ /**
+ * Subclasses should populate the given builder to initialize the device (if necessary).
+ *
+ * @return the same builder as passed as the argument
+ */
+ protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
+ return builder;
+ }
+
+ @Override
+ public void dispose() {
+ if (mQueue != null) {
+ mQueue.dispose();
+ mQueue = null;
+ }
+ }
+
+ public TransactionBuilder createTransactionBuilder(String taskName) {
+ return new TransactionBuilder(taskName);
+ }
+
+ /**
+ * Ensures that the device is connected and (only then) performs the actions of the given
+ * transaction builder.
+ * <p>
+ * In contrast to {@link #performInitialized(String)}, no initialization sequence is performed
+ * with the device, only the actions of the given builder are executed.
+ * @param transaction
+ * @throws IOException if connection to the device fails
+ * @see #performInitialized(String)
+ */
+ public void performConnected(Transaction transaction) throws IOException {
+ if (!isConnected()) {
+ if (!connect()) {
+ throw new IOException("2: Unable to connect to device: " + getDevice());
+ }
+ }
+ getQueue().add(transaction);
+ }
+
+ public BtBRQueue getQueue() {
+ return mQueue;
+ }
+
+ /**
+ * Subclasses should call this method to add services they support.
+ * Only supported services will be queried for characteristics.
+ *
+ * @param aSupportedService the supported service uuid
+ */
+ protected void addSupportedService(UUID aSupportedService) {
+ mSupportedService = aSupportedService;
+ }
+
+ protected UUID getSupportedService() {
+ return mSupportedService;
+ }
+
+ protected void setBufferSize(int bufferSize) {
+ mBufferSize = bufferSize;
+ }
+
+ protected int getBufferSize() {
+ return mBufferSize;
+ }
+
+ /**
+ * Utility method that may be used to log incoming messages when we don't know how to deal with them yet.
+ */
+ public void logMessageContent(byte[] value) {
+ logger.info("RECEIVED DATA WITH LENGTH: {}", (value != null) ? value.length : "(null)");
+ Logging.logBytes(logger, value);
+ }
+
+ public void onConnectionEstablished() {
+ try {
+ initializeDevice(createTransactionBuilder("Initializing device")).queue(getQueue());
+ } catch (final Exception ex) {
+ final GBDevice device = getDevice();
+
+ if (device != null) {
+ logger.error("Exception raised while initializing device {} (address {}), disconnecting", device.getName(), device.getAddress(), ex);
+ device.setState(GBDevice.State.WAITING_FOR_RECONNECT);
+ device.sendDeviceUpdateIntent(getContext());
+ } else {
+ logger.error("Exception raised while initializing unknown device", ex);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractTransaction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractTransaction.java
new file mode 100644
index 0000000..1801eed
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/AbstractTransaction.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr;
+
+import java.text.DateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+public abstract class AbstractTransaction {
+ private final String mName;
+ private final long creationTimestamp = System.currentTimeMillis();
+
+ public AbstractTransaction(String taskName) {
+ this.mName = taskName;
+ }
+
+ public String getTaskName() {
+ return mName;
+ }
+
+ protected String getCreationTime() {
+ return DateFormat.getTimeInstance(DateFormat.MEDIUM).format(new Date(creationTimestamp));
+ }
+
+ public abstract int getActionCount();
+
+ @Override
+ public String toString() {
+ return String.format(Locale.US, "%s: Transaction task: %s with %d actions", getCreationTime(), getTaskName(), getActionCount());
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRAction.java
new file mode 100644
index 0000000..6f80b7f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRAction.java
@@ -0,0 +1,64 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr;
+
+import android.bluetooth.BluetoothSocket;
+
+import java.util.Date;
+
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+/**
+ * This allows performing one socket request at a time.
+ * As they are asynchronous anyway, we encapsulate every socket request (only write)
+ * inside a runnable action.
+ * <p/>
+ * These actions are then executed one after another, ensuring that every action's result
+ * has been posted before invoking the next action.
+ * <p/>
+ * As there is only write action, this class will be needed for later usage.
+ */
+public abstract class BtBRAction {
+ private final long creationTimestamp;
+
+ public BtBRAction() {
+ creationTimestamp = System.currentTimeMillis();
+ }
+
+ /**
+ * Returns true if this action expects an (async) result which must
+ * be waited for, before continuing with other actions.
+ * <p/>
+ */
+ public abstract boolean expectsResult();
+
+ /**
+ * Executes this action, e.g. reads or write a Socket characteristic.
+ *
+ * @param socket the characteristic to manipulate, or null if none.
+ * @return true if the action was successful, false otherwise
+ */
+ public abstract boolean run(BluetoothSocket socket);
+
+ protected String getCreationTime() {
+ return DateTimeUtils.formatDateTime(new Date(creationTimestamp));
+ }
+
+ public String toString() {
+ return getCreationTime() + ": " + getClass().getSimpleName();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java
new file mode 100644
index 0000000..d7f9897
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/BtBRQueue.java
@@ -0,0 +1,279 @@
+/* Copyright (C) 2022-2024 Damien Gaignon, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.service.btbr;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothSocket;
+import android.content.Context;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.os.Process;
+
+import androidx.annotation.NonNull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public final class BtBRQueue {
+ private static final Logger LOG = LoggerFactory.getLogger(BtBRQueue.class);
+ public static final int HANDLER_SUBJECT_CONNECT = 0;
+ public static final int HANDLER_SUBJECT_PERFORM_TRANSACTION = 1;
+
+ private BluetoothAdapter mBtAdapter = null;
+ private BluetoothSocket mBtSocket = null;
+ private final GBDevice mGbDevice;
+ private final SocketCallback mCallback;
+ private final UUID mService;
+
+ private volatile boolean mDisposed;
+
+ private final Context mContext;
+ private final int mBufferSize;
+
+ private final Handler mWriteHandler;
+ private final HandlerThread mWriteHandlerThread = new HandlerThread("Write Thread", Process.THREAD_PRIORITY_BACKGROUND);
+
+ private Thread readThread = new Thread("Read Thread") {
+ @Override
+ public void run() {
+ byte[] buffer = new byte[mBufferSize];
+ int nRead;
+
+ LOG.debug("Read thread started, entering loop");
+
+ while (!mDisposed) {
+ try {
+ nRead = mBtSocket.getInputStream().read(buffer);
+
+ // safety measure
+ if (nRead == -1) {
+ throw new IOException("End of stream");
+ }
+ } catch (IOException ex) {
+ LOG.error("IO exception while reading message from socket, breaking out of read thread: ", ex);
+ break;
+ }
+
+ LOG.debug("Received {} bytes: {}", nRead, GB.hexdump(buffer, 0, nRead));
+
+ try {
+ mCallback.onSocketRead(Arrays.copyOf(buffer, nRead));
+ } catch (Throwable ex) {
+ LOG.error("Failed to process received bytes in onSocketRead callback: ", ex);
+ }
+ }
+
+ LOG.debug("Exited read thread loop, disconnecting");
+ GBApplication.deviceService(mGbDevice).disconnect();
+ }
+ };
+
+ public BtBRQueue(BluetoothAdapter btAdapter, GBDevice gbDevice, Context context, SocketCallback socketCallback, UUID supportedService, int bufferSize) {
+ mBtAdapter = btAdapter;
+ mGbDevice = gbDevice;
+ mContext = context;
+ mCallback = socketCallback;
+ mService = supportedService;
+ mBufferSize = bufferSize;
+
+ mWriteHandlerThread.start();
+
+ LOG.debug("Write handler thread is prepared, creating write handler");
+ mWriteHandler = new Handler(mWriteHandlerThread.getLooper()) {
+ @SuppressLint("MissingPermission")
+ @Override
+ public void handleMessage(@NonNull Message msg) {
+ switch (msg.what) {
+ case HANDLER_SUBJECT_CONNECT: {
+ try {
+ mBtSocket.connect();
+
+ LOG.info("Connected to RFCOMM socket for {}", mGbDevice.getName());
+ setDeviceConnectionState(GBDevice.State.CONNECTED);
+
+ // update thread names to show device names in logs
+ readThread.setName(String.format(Locale.ENGLISH,
+ "Read Thread for %s", mGbDevice.getName()));
+ mWriteHandlerThread.setName(String.format(Locale.ENGLISH,
+ "Write Thread for %s", mGbDevice.getName()));
+
+ // now that connect has been created, start the threads
+ readThread.start();
+ onConnectionEstablished();
+ } catch (IOException e) {
+ LOG.error("IO exception while establishing socket connection: ", e);
+ setDeviceConnectionState(GBDevice.State.NOT_CONNECTED);
+ }
+
+ return;
+ }
+ case HANDLER_SUBJECT_PERFORM_TRANSACTION: {
+ try {
+ if (!isConnected()) {
+ LOG.debug("Not connected, updating device state to WAITING_FOR_RECONNECT");
+ setDeviceConnectionState(GBDevice.State.WAITING_FOR_RECONNECT);
+ return;
+ }
+
+ if (!(msg.obj instanceof Transaction)) {
+ LOG.error("msg.obj is not an instance of Transaction");
+ return;
+ }
+
+ Transaction transaction = (Transaction) msg.obj;
+
+ for (BtBRAction action : transaction.getActions()) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("About to run action: {}", action);
+ }
+
+ if (action.run(mBtSocket)) {
+ LOG.debug("Action ok: {}", action);
+ } else {
+ LOG.error("Action returned false, cancelling further actions in transaction: {}", action);
+ break;
+ }
+ }
+ } catch (Throwable ex) {
+ LOG.error("IO Write Thread died: " + ex.getMessage(), ex);
+ }
+
+ return;
+ }
+ }
+
+ LOG.warn("Unhandled write handler message {}", msg.what);
+ }
+ };
+ }
+
+ /**
+ * Connects to the given remote device. Note that this does not perform any device
+ * specific initialization. This should be done in the specific {@link DeviceSupport}
+ * class.
+ *
+ * @return <code>true</code> whether the connection attempt was successfully triggered and <code>false</code> if that failed or if there is already a connection
+ */
+ @SuppressLint("MissingPermission")
+ public boolean connect() {
+ if (isConnected()) {
+ LOG.warn("Ignoring connect() because already connected.");
+ return false;
+ }
+
+ LOG.info("Attempting to connect to {} ({})", mGbDevice.getName(), mGbDevice.getAddress());
+
+ // stop discovery before connection is made
+ mBtAdapter.cancelDiscovery();
+
+ // revert to original state upon exception
+ GBDevice.State originalState = mGbDevice.getState();
+ setDeviceConnectionState(GBDevice.State.CONNECTING);
+
+ try {
+ BluetoothDevice btDevice = mBtAdapter.getRemoteDevice(mGbDevice.getAddress());
+ mBtSocket = btDevice.createRfcommSocketToServiceRecord(mService);
+ } catch (IOException e) {
+ LOG.error("Unable to connect to RFCOMM endpoint: ", e);
+ setDeviceConnectionState(originalState);
+ mBtSocket = null;
+ return false;
+ }
+
+ LOG.debug("Socket created, connecting in handler");
+ mWriteHandler.sendMessageAtFrontOfQueue(mWriteHandler.obtainMessage(HANDLER_SUBJECT_CONNECT));
+ return true;
+ }
+
+ protected void onConnectionEstablished() {
+ mCallback.onConnectionEstablished();
+ }
+
+ public void disconnect() {
+ if (mWriteHandlerThread.isAlive()) {
+ mWriteHandlerThread.quit();
+ }
+
+ if (mBtSocket != null && mBtSocket.isConnected()) {
+ try {
+ mBtSocket.close();
+ } catch (IOException e) {
+ LOG.error("IO exception while closing socket in disconnect(): ", e);
+ }
+ }
+
+ mBtSocket = null;
+ setDeviceConnectionState(GBDevice.State.NOT_CONNECTED);
+ }
+
+ /**
+ * Check whether a connection to the device exists and whether a socket connection has been
+ * initialized and connected
+ * @return true if the Bluetooth device is connected and the socket is ready, false otherwise
+ */
+ private boolean isConnected() {
+ return mGbDevice.isConnected() &&
+ mBtSocket != null &&
+ mBtSocket.isConnected();
+ }
+
+ /**
+ * Add a finalized {@link Transaction} to the write handler's queue
+ *
+ * @param transaction The transaction to be run in the handler thread's looper
+ */
+ public void add(Transaction transaction) {
+ LOG.debug("Adding transaction to looper message queue: {}", transaction);
+
+ if (!transaction.isEmpty()) {
+ mWriteHandler.obtainMessage(HANDLER_SUBJECT_PERFORM_TRANSACTION, transaction).sendToTarget();
+ }
+ }
+
+ private void setDeviceConnectionState(GBDevice.State newState) {
+ LOG.debug("New device connection state: " + newState);
+ mGbDevice.setState(newState);
+ mGbDevice.sendDeviceUpdateIntent(mContext, GBDevice.DeviceUpdateSubject.CONNECTION_STATE);
+ }
+
+ public void dispose() {
+ if (mDisposed) {
+ return;
+ }
+
+ mDisposed = true;
+ disconnect();
+
+ if (readThread != null && readThread.isAlive()) {
+ readThread.interrupt();
+ readThread = null;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/SocketCallback.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/SocketCallback.java
new file mode 100644
index 0000000..e8aeaae
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/SocketCallback.java
@@ -0,0 +1,34 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr;
+
+/**
+ * Callback interface handling io events of a BluetoothSocket.
+ * It is the counterpart of GattCallback interface designed for GB.
+*/
+public interface SocketCallback {
+
+ void onConnectionEstablished();
+
+ /**
+ * Read data from InputStream of BluetoothSocket
+ *
+ * @param data
+ */
+ void onSocketRead(byte[] data);
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/Transaction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/Transaction.java
new file mode 100644
index 0000000..3b1f5ad
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/Transaction.java
@@ -0,0 +1,70 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Groups a bunch of {@link BtBRAction actions} together, making sure
+ * that upon failure of one action, all subsequent actions are discarded.
+ *
+ * @author TREND
+ */
+public class Transaction extends AbstractTransaction {
+ private final List<BtBRAction> mActions = new ArrayList<>(4);
+ private
+ @Nullable
+ SocketCallback socketCallback;
+
+ public Transaction(String taskName) {
+ super(taskName);
+ }
+
+ public void add(BtBRAction action) {
+ mActions.add(action);
+ }
+
+ public List<BtBRAction> getActions() {
+ return Collections.unmodifiableList(mActions);
+ }
+
+ public boolean isEmpty() {
+ return mActions.isEmpty();
+ }
+
+ public void setCallback(@Nullable SocketCallback callback) {
+ socketCallback = callback;
+ }
+
+ /**
+ * Returns the GattCallback for this transaction, or null if none.
+ */
+ public
+ @Nullable
+ SocketCallback getSocketCallback() {
+ return socketCallback;
+ }
+
+ @Override
+ public int getActionCount() {
+ return mActions.size();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/TransactionBuilder.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/TransactionBuilder.java
new file mode 100644
index 0000000..62489f1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/TransactionBuilder.java
@@ -0,0 +1,86 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import androidx.annotation.Nullable;
+
+import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.WaitAction;
+import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.WriteAction;
+
+public class TransactionBuilder {
+ private static final Logger LOG = LoggerFactory.getLogger(TransactionBuilder.class);
+
+ private final Transaction mTransaction;
+ private boolean mQueued;
+
+ public TransactionBuilder(String taskName) {
+ mTransaction = new Transaction(taskName);
+ }
+
+ public TransactionBuilder write(byte[] data) {
+ WriteAction action = new WriteAction(data);
+ return add(action);
+ }
+
+
+ /**
+ * Causes the queue to sleep for the specified time.
+ * Note that this is usually a bad idea, since it will not be able to process messages
+ * during that time. It is also likely to cause race conditions.
+ * @param millis the number of milliseconds to sleep
+ */
+ public TransactionBuilder wait(int millis) {
+ WaitAction action = new WaitAction(millis);
+ return add(action);
+ }
+
+ public TransactionBuilder add(BtBRAction action) {
+ mTransaction.add(action);
+ return this;
+ }
+
+ /**
+ * Sets a SocketCallback instance that will be called when the transaction is executed,
+ * resulting in SocketCallback events.
+ *
+ * @param callback the callback to set, may be null
+ */
+ public void setCallback(@Nullable SocketCallback callback) {
+ mTransaction.setCallback(callback);
+ }
+
+ /**
+ * To be used as the final step to execute the transaction by the given queue.
+ *
+ * @param queue
+ */
+ public void queue(BtBRQueue queue) {
+ if (mQueued) {
+ throw new IllegalStateException("This builder had already been queued. You must not reuse it.");
+ }
+ mQueued = true;
+ queue.add(mTransaction);
+ }
+
+ public Transaction getTransaction() {
+ return mTransaction;
+ }
+
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/AbortTransactionAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/AbortTransactionAction.java
new file mode 100644
index 0000000..a5736a8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/AbortTransactionAction.java
@@ -0,0 +1,49 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr.actions;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import android.bluetooth.BluetoothSocket;
+
+/**
+ * A special action that checks for an abort-condition, and if met, the currently
+ * executing transaction will be aborted by returning false.
+ */
+public abstract class AbortTransactionAction extends PlainAction {
+ private static final Logger LOG = LoggerFactory.getLogger(AbortTransactionAction.class);
+
+ public AbortTransactionAction() {
+ }
+
+ @Override
+ public boolean run(BluetoothSocket socket) {
+ if (shouldAbort()) {
+ LOG.info("Aborting transaction because abort criteria met.");
+ return false;
+ }
+ return true;
+ }
+
+ protected abstract boolean shouldAbort();
+
+ @Override
+ public String toString() {
+ return getCreationTime() + ": " + getClass().getSimpleName() + ": aborting? " + shouldAbort();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/CheckInitializedAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/CheckInitializedAction.java
new file mode 100644
index 0000000..37db3d9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/CheckInitializedAction.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr.actions;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+/**
+ * A special action that is executed at the very front of the initialization
+ * sequence (transaction). It will abort the entire initialization sequence
+ * by returning false, when the device is already initialized.
+ */
+public class CheckInitializedAction extends AbortTransactionAction {
+ private static final Logger LOG = LoggerFactory.getLogger(CheckInitializedAction.class);
+
+ private final GBDevice device;
+
+ public CheckInitializedAction(GBDevice gbDevice) {
+ device = gbDevice;
+ }
+
+ @Override
+ protected boolean shouldAbort() {
+ boolean abort = device.isInitialized();
+ if (abort) {
+ LOG.info("Aborting device initialization, because already initialized: " + device);
+ }
+ return abort;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/PlainAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/PlainAction.java
new file mode 100644
index 0000000..53dc280
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/PlainAction.java
@@ -0,0 +1,39 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr.actions;
+
+import nodomain.freeyourgadget.gadgetbridge.service.btbr.BtBRAction;
+
+/**
+ * An abstract non-BTBR action. It performs no bluetooth operation,
+ * does not have a BluetoothSocketCharacteristic instance and expects no result.
+ */
+public abstract class PlainAction extends BtBRAction {
+
+ public PlainAction() {
+ }
+
+ @Override
+ public boolean expectsResult() {
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return getCreationTime() + ": " + getClass().getSimpleName();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetDeviceBusyAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetDeviceBusyAction.java
new file mode 100644
index 0000000..e985897
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetDeviceBusyAction.java
@@ -0,0 +1,53 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr.actions;
+
+import android.bluetooth.BluetoothSocket;
+import android.content.Context;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class SetDeviceBusyAction extends PlainAction {
+ private final GBDevice device;
+ private final Context context;
+ private final String busyTask;
+
+ /**
+ * When run, will mark the device as busy (or not busy).
+ *
+ * @param device the device to mark
+ * @param busyTask the task name to set as busy task, or null to mark as not busy
+ * @param context
+ */
+ public SetDeviceBusyAction(GBDevice device, String busyTask, Context context) {
+ this.device = device;
+ this.busyTask = busyTask;
+ this.context = context;
+ }
+
+ @Override
+ public boolean run(BluetoothSocket socket) {
+ device.setBusyTask(busyTask);
+ device.sendDeviceUpdateIntent(context);
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return getCreationTime() + ": " + getClass().getName() + ": " + busyTask;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetDeviceStateAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetDeviceStateAction.java
new file mode 100644
index 0000000..6d7b9ea
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetDeviceStateAction.java
@@ -0,0 +1,53 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr.actions;
+
+import android.bluetooth.BluetoothSocket;
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class SetDeviceStateAction extends PlainAction {
+ private final GBDevice device;
+ private final GBDevice.State deviceState;
+ private final Context context;
+
+ public SetDeviceStateAction(GBDevice device, GBDevice.State deviceState, Context context) {
+ this.device = device;
+ this.deviceState = deviceState;
+ this.context = context;
+ }
+
+ @Override
+ public boolean run(BluetoothSocket socket) {
+ device.setState(deviceState);
+ device.sendDeviceUpdateIntent(getContext(), GBDevice.DeviceUpdateSubject.DEVICE_STATE);
+ return true;
+ }
+
+ public Context getContext() {
+ return context;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return super.toString() + " to " + deviceState;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetProgressAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetProgressAction.java
new file mode 100644
index 0000000..4a5e56d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/SetProgressAction.java
@@ -0,0 +1,73 @@
+/* Copyright (C) 2015-2023 Andreas Shimokawa, Carsten Pfeiffer, Daniele
+ Gobbetti, Yoran Vulker
+
+ 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 <http://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.service.btbr.actions;
+
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothSocket;
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.annotation.NonNull;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.PlainAction;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class SetProgressAction extends PlainAction {
+ private static final Logger LOG = LoggerFactory.getLogger(SetProgressAction.class);
+
+ private final String text;
+ private final boolean ongoing;
+ private final int percentage;
+ private final Context context;
+
+ /**
+ * When run, will update the progress notification.
+ *
+ * @param text Text shown in the notification
+ * @param ongoing State of action, true when the action is still being performed
+ * @param percentage Current percentage indicating how far along the action has progressed
+ * @param context Context in which to create the notification
+ */
+ public SetProgressAction(String text, boolean ongoing, int percentage, Context context) {
+ this.text = text;
+ this.ongoing = ongoing;
+ this.percentage = percentage;
+ this.context = context;
+ }
+
+ @Override
+ public boolean run(BluetoothSocket unused) {
+ LOG.info(toString());
+ GB.updateInstallNotification(this.text, this.ongoing, this.percentage, this.context);
+
+ final LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(context);
+ broadcastManager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_BAR).putExtra(GB.PROGRESS_BAR_PROGRESS, percentage));
+
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getCreationTime() + ": " + getClass().getSimpleName() + ": " + text + "; " + percentage + "%";
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WaitAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WaitAction.java
new file mode 100644
index 0000000..fd36b33
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WaitAction.java
@@ -0,0 +1,43 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr.actions;
+
+import android.bluetooth.BluetoothSocket;
+
+/**
+ * An action that will cause the queue to sleep for the specified time.
+ * Note that this is usually a bad idea, since it will not be able to process messages
+ * during that time. It is also likely to cause race conditions.
+ */
+public class WaitAction extends PlainAction {
+
+ private final int mMillis;
+
+ public WaitAction(int millis) {
+ mMillis = millis;
+ }
+
+ @Override
+ public boolean run(BluetoothSocket socket) {
+ try {
+ Thread.sleep(mMillis);
+ return true;
+ } catch (InterruptedException e) {
+ return false;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WriteAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WriteAction.java
new file mode 100644
index 0000000..1125ea4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btbr/actions/WriteAction.java
@@ -0,0 +1,82 @@
+/* Copyright (C) 2022-2024 Damien Gaignon
+
+ 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.btbr.actions;
+
+import android.bluetooth.BluetoothSocket;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import nodomain.freeyourgadget.gadgetbridge.Logging;
+import nodomain.freeyourgadget.gadgetbridge.service.btbr.BtBRAction;
+
+/**
+ * Invokes a write operation on a given socket.
+ * The result status will be made available asynchronously through the
+ * {@link SocketCallback}
+ */
+public class WriteAction extends BtBRAction {
+ private static final Logger LOG = LoggerFactory.getLogger(WriteAction.class);
+
+ private final byte[] value;
+ private OutputStream mOutputStream = null;
+
+ public WriteAction(byte[] value) {
+ this.value = value;
+ }
+
+ @Override
+ public boolean run(BluetoothSocket socket) {
+ try {
+ mOutputStream = socket.getOutputStream();
+ if (mOutputStream == null) {
+ LOG.error("mOutStream is null");
+ return false;
+ }
+ return writeValue(value);
+ } catch (IOException e) {
+ LOG.error("Can not get the output stream");
+ }
+ return false;
+ }
+
+ protected boolean writeValue(byte[] value) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("writing to socket: " + Logging.formatBytes(value));
+ }
+ try {
+ mOutputStream.write(value);
+ mOutputStream.flush();
+ return true;
+ } catch (IOException e) {
+ LOG.error("Error writing to socket: ", e);
+ }
+ return false;
+ }
+
+ protected final byte[] getValue() {
+ return value;
+ }
+
+ @Override
+ public boolean expectsResult() {
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btclassic/BtClassicIoThread.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btclassic/BtClassicIoThread.java
new file mode 100644
index 0000000..6c91b54
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btclassic/BtClassicIoThread.java
@@ -0,0 +1,214 @@
+/* Copyright (C) 2017-2024 Carsten Pfeiffer, Daniele Gobbetti, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.service.btclassic;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothSocket;
+import android.content.Context;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.SocketTimeoutException;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.AbstractSerialDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceIoThread;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public abstract class BtClassicIoThread extends GBDeviceIoThread {
+ private static final Logger LOG = LoggerFactory.getLogger(BtClassicIoThread.class);
+
+ private final GBDeviceProtocol mProtocol;
+ private final AbstractSerialDeviceSupport mDeviceSupport;
+
+ private BluetoothAdapter mBtAdapter = null;
+ private BluetoothSocket mBtSocket = null;
+ private InputStream mInStream = null;
+ private OutputStream mOutStream = null;
+ private boolean mQuit = false;
+
+ @Override
+ public void quit() {
+ mQuit = true;
+ if (mBtSocket != null) {
+ try {
+ mBtSocket.close();
+ } catch (IOException e) {
+ LOG.error(e.getMessage());
+ }
+ }
+ }
+
+ private boolean mIsConnected = false;
+
+
+ public BtClassicIoThread(GBDevice gbDevice, Context context, GBDeviceProtocol deviceProtocol, AbstractSerialDeviceSupport deviceSupport, BluetoothAdapter btAdapter) {
+ super(gbDevice, context);
+ mProtocol = deviceProtocol;
+ mDeviceSupport = deviceSupport;
+ mBtAdapter = btAdapter;
+ }
+
+ @Override
+ public synchronized void write(byte[] bytes) {
+ if (null == bytes)
+ return;
+ if (mOutStream == null) {
+ LOG.error("mOutStream is null");
+ return;
+ }
+ LOG.debug("writing: {}", GB.hexdump(bytes, 0, bytes.length));
+ try {
+ mOutStream.write(bytes);
+ mOutStream.flush();
+ } catch (IOException e) {
+ LOG.error("Error writing.", e);
+ }
+ }
+
+ @Override
+ public void run() {
+ mIsConnected = connect();
+ if (!mIsConnected) {
+ setUpdateState(GBDevice.State.NOT_CONNECTED);
+ return;
+ }
+ mQuit = false;
+
+ while (!mQuit) {
+ LOG.info("Ready for a new message exchange.");
+
+ try {
+ GBDeviceEvent deviceEvents[] = mProtocol.decodeResponse(parseIncoming(mInStream));
+ if (deviceEvents == null) {
+ LOG.info("unhandled message");
+ } else {
+ for (GBDeviceEvent deviceEvent : deviceEvents) {
+ if (deviceEvent == null) {
+ continue;
+ }
+ mDeviceSupport.evaluateGBDeviceEvent(deviceEvent);
+ }
+ }
+ } catch (SocketTimeoutException ignore) {
+ LOG.debug("socket timeout, we can't help but ignore this");
+ } catch (IOException e) {
+ LOG.info(e.getMessage());
+ mIsConnected = false;
+ mBtSocket = null;
+ mInStream = null;
+ mOutStream = null;
+ LOG.info("Bluetooth socket closed, will quit IO Thread");
+ break;
+ }
+ }
+
+ mIsConnected = false;
+ if (mBtSocket != null) {
+ try {
+ mBtSocket.close();
+ } catch (IOException e) {
+ LOG.error(e.getMessage());
+ }
+ mBtSocket = null;
+ }
+ setUpdateState(GBDevice.State.NOT_CONNECTED);
+ }
+
+ @Override
+ protected boolean connect() {
+ GBDevice.State originalState = gbDevice.getState();
+ setUpdateState(GBDevice.State.CONNECTING);
+
+ try {
+ BluetoothDevice btDevice = mBtAdapter.getRemoteDevice(gbDevice.getAddress());
+ ParcelUuid uuids[] = btDevice.getUuids();
+ if (uuids == null) {
+ LOG.warn("Device provided no UUIDs to connect to, giving up: " + gbDevice);
+ return false;
+ }
+ for (ParcelUuid uuid : uuids) {
+ LOG.info("found service UUID " + uuid);
+ }
+ mBtSocket = btDevice.createRfcommSocketToServiceRecord(getUuidToConnect(uuids));
+ mBtSocket.connect();
+ mInStream = mBtSocket.getInputStream();
+ mOutStream = mBtSocket.getOutputStream();
+ setUpdateState(GBDevice.State.CONNECTED);
+ } catch (IOException e) {
+ LOG.error("Server socket cannot be started.", e);
+ //LOG.error(e.getMessage());
+ setUpdateState(originalState);
+ mInStream = null;
+ mOutStream = null;
+ mBtSocket = null;
+ return false;
+ } catch (SecurityException e) {
+ LOG.error("Could not connect to device.", e);
+ setUpdateState(originalState);
+ mInStream = null;
+ mOutStream = null;
+ mBtSocket = null;
+ return false;
+ }
+
+ initialize();
+
+ return true;
+ }
+
+ protected void initialize() {
+ write(mProtocol.encodeSetTime());
+ setUpdateState(GBDevice.State.INITIALIZED);
+ }
+
+ /**
+ * Returns the uuid to connect to.
+ * Default implementation returns the first of the given uuids that were
+ * read from the remote device.
+ * @param uuids
+ * @return
+ */
+ @NonNull
+ protected UUID getUuidToConnect(@NonNull ParcelUuid[] uuids) {
+ return uuids[0].getUuid();
+ }
+
+ protected void setUpdateState(GBDevice.State state) {
+ gbDevice.setState(state);
+ gbDevice.sendDeviceUpdateIntent(getContext());
+ }
+
+ /**
+ * Returns an incoming message for consuming by the GBDeviceProtocol
+ * @return
+ * @throws IOException
+ * @param inStream
+ */
+ protected abstract byte[] parseIncoming(InputStream inStream) throws IOException;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java
new file mode 100644
index 0000000..a0d5352
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java
@@ -0,0 +1,478 @@
+/* Copyright (C) 2015-2024 Andreas B枚hler, Arjan Schrijver, Carsten Pfeiffer,
+ Daniel Dakhno, Daniele Gobbetti, Johannes Krude, JohnnySun, Jos茅 Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>. */
+package nodomain.freeyourgadget.gadgetbridge.service.btle;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.content.Context;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.Logging;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.CheckInitializedAction;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.AbstractBleProfile;
+
+/**
+ * Abstract base class for all devices connected through Bluetooth Low Energy (LE) aka
+ * Bluetooth Smart.
+ * <p/>
+ * The connection to the device and all communication is made with a generic {@link BtLEQueue}.
+ * Messages to the device are encoded as {@link BtLEAction actions} or {@link BtLEServerAction actions}
+ * that are grouped with a {@link Transaction} or {@link ServerTransaction} and sent via {@link BtLEQueue}.
+ *
+ * @see TransactionBuilder
+ * @see BtLEQueue
+ */
+public abstract class AbstractBTLEDeviceSupport extends AbstractDeviceSupport implements GattCallback, GattServerCallback {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractBTLEDeviceSupport.class);
+
+ private int mMTU = 23;
+ private BtLEQueue mQueue;
+ private Map<UUID, BluetoothGattCharacteristic> mAvailableCharacteristics;
+ private final Set<UUID> mSupportedServices = new HashSet<>(4);
+ private final Set<BluetoothGattService> mSupportedServerServices = new HashSet<>(4);
+ private final Logger logger;
+
+ private final List<AbstractBleProfile<?>> mSupportedProfiles = new ArrayList<>();
+ public static final String BASE_UUID = "0000%s-0000-1000-8000-00805f9b34fb"; //this is common for all BTLE devices. see http://stackoverflow.com/questions/18699251/finding-out-android-bluetooth-le-gatt-profiles
+ private final Object characteristicsMonitor = new Object();
+
+ private BleIntentApi bleApi = null;
+
+ public AbstractBTLEDeviceSupport(Logger logger) {
+ this.logger = logger;
+ if (logger == null) {
+ throw new IllegalArgumentException("logger must not be null");
+ }
+ }
+
+ @Override
+ public boolean connect() {
+ if (mQueue == null) {
+ mQueue = new BtLEQueue(getBluetoothAdapter(), getDevice(), this, this, getContext(), mSupportedServerServices);
+ if(bleApi != null) {
+ bleApi.setQueue(mQueue);
+ }
+ mQueue.setAutoReconnect(getAutoReconnect());
+ mQueue.setScanReconnect(getScanReconnect());
+ mQueue.setImplicitGattCallbackModify(getImplicitCallbackModify());
+ mQueue.setSendWriteRequestResponse(getSendWriteRequestResponse());
+ }
+
+ return mQueue.connect();
+ }
+
+ public void disconnect() {
+ if (mQueue != null) {
+ mQueue.disconnect();
+ }
+ }
+
+ public BleIntentApi getBleApi() {
+ return bleApi;
+ }
+
+ @Override
+ public void onSendConfiguration(String config) {
+ if(bleApi != null) {
+ bleApi.onSendConfiguration(config);
+ }
+ }
+
+ @Override
+ public void setContext(GBDevice gbDevice, BluetoothAdapter btAdapter, Context context) {
+ super.setContext(gbDevice, btAdapter, context);
+
+ if(BleIntentApi.isEnabled(gbDevice)) {
+ bleApi = new BleIntentApi(context, gbDevice);
+ bleApi.handleBLEApiPrefs();
+ }
+ }
+
+
+
+ /**
+ * Returns whether the gatt callback should be implicitly set to the one on the transaction,
+ * even if it was not set directly on the transaction. If true, the gatt callback will always
+ * be set to the one in the transaction, even if null and not explicitly set to null.
+ * See <a href="https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/2912">#2912</a> for
+ * more information. This is false by default, but we are making it configurable to avoid breaking
+ * older devices that rely on this behavior, so all older devices got this overridden to true.
+ */
+ public boolean getImplicitCallbackModify() {
+ return false;
+ }
+
+ /**
+ * Whether to send a write request response to the device, if requested. The standard actually
+ * expects this to happen, but Gadgetbridge did not originally support it. This is set to true
+ * on all older devices that were not confirmed to handle the response well after this was introduced.
+ * <p>
+ * See also: <a href="https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/2831#issuecomment-941568">#2831#issuecomment-941568</a>
+ *
+ * @return whether to send write request responses, if a response is requested
+ */
+ public boolean getSendWriteRequestResponse() {
+ return true;
+ }
+
+ @Override
+ public void setAutoReconnect(boolean enable) {
+ super.setAutoReconnect(enable);
+ if (mQueue != null) {
+ mQueue.setAutoReconnect(enable);
+ }
+ }
+
+ /**
+ * Subclasses should populate the given builder to initialize the device (if necessary).
+ *
+ * @return the same builder as passed as the argument
+ */
+ protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
+ return builder;
+ }
+
+ @Override
+ public void dispose() {
+ if (mQueue != null) {
+ mQueue.dispose();
+ mQueue = null;
+ }
+
+ if(bleApi != null) {
+ bleApi.dispose();
+ }
+ }
+
+ public TransactionBuilder createTransactionBuilder(String taskName) {
+ return new TransactionBuilder(taskName);
+ }
+
+ /**
+ * Send commands like this to the device:
+ * <p>
+ * <code>performInitialized("sms notification").write(someCharacteristic, someByteArray).queue(getQueue());</code>
+ * </p>
+ * This will asynchronously
+ * <ul>
+ * <li>connect to the device (if necessary)</li>
+ * <li>initialize the device (if necessary)</li>
+ * <li>execute the commands collected with the returned transaction builder</li>
+ * </ul>
+ *
+ * @see #performConnected(Transaction)
+ * @see #initializeDevice(TransactionBuilder)
+ */
+ public TransactionBuilder performInitialized(String taskName) throws IOException {
+ if (!isConnected()) {
+ LOG.debug("Connecting to device for {}", taskName);
+ if (!connect()) {
+ throw new IOException("1: Unable to connect to device: " + getDevice());
+ }
+ }
+ if (!isInitialized()) {
+ LOG.debug("Initializing device for {}", taskName);
+ // first, add a transaction that performs device initialization
+ TransactionBuilder builder = createTransactionBuilder("Initialize device");
+ builder.add(new CheckInitializedAction(gbDevice));
+ initializeDevice(builder).queue(getQueue());
+ }
+ return createTransactionBuilder(taskName);
+ }
+
+ public ServerTransactionBuilder createServerTransactionBuilder(String taskName) {
+ return new ServerTransactionBuilder(taskName);
+ }
+
+ public ServerTransactionBuilder performServer(String taskName) throws IOException {
+ if (!isConnected()) {
+ if(!connect()) {
+ throw new IOException("1: Unable to connect to device: " + getDevice());
+ }
+ }
+ return createServerTransactionBuilder(taskName);
+ }
+
+ /**
+ * Ensures that the device is connected and (only then) performs the actions of the given
+ * transaction builder.
+ * <p>
+ * In contrast to {@link #performInitialized(String)}, no initialization sequence is performed
+ * with the device, only the actions of the given builder are executed.
+ * @throws IOException if unable to connect to the device
+ * @see #performInitialized(String)
+ */
+ public void performConnected(Transaction transaction) throws IOException {
+ if (!isConnected()) {
+ if (!connect()) {
+ throw new IOException("2: Unable to connect to device: " + getDevice());
+ }
+ }
+ getQueue().add(transaction);
+ }
+
+ /**
+ * Performs the actions of the given transaction as soon as possible,
+ * that is, before any other queued transactions, but after the actions
+ * of the currently executing transaction.
+ */
+ public void performImmediately(TransactionBuilder builder) throws IOException {
+ if (!isConnected()) {
+ throw new IOException("Not connected to device: " + getDevice());
+ }
+ getQueue().insert(builder.getTransaction());
+ }
+
+ public BtLEQueue getQueue() {
+ return mQueue;
+ }
+
+ /**
+ * Subclasses should call this method to add services they support.
+ * Only supported services will be queried for characteristics.
+ *
+ * @param aSupportedService supported service uuid
+ * @see #getCharacteristic(UUID)
+ */
+ protected void addSupportedService(UUID aSupportedService) {
+ mSupportedServices.add(aSupportedService);
+ }
+
+ protected void addSupportedProfile(AbstractBleProfile<?> profile) {
+ mSupportedProfiles.add(profile);
+ }
+
+ /**
+ * Subclasses should call this method to add server services they support.
+ */
+ protected void addSupportedServerService(BluetoothGattService service) {
+ mSupportedServerServices.add(service);
+ }
+
+ /**
+ * Returns the characteristic matching the given UUID. Only characteristics
+ * are returned whose service is marked as supported.
+ *
+ * @param uuid characteristic uuid
+ * @return the characteristic for the given UUID or <code>null</code>
+ * @see #addSupportedService(UUID)
+ */
+ public BluetoothGattCharacteristic getCharacteristic(UUID uuid) {
+ synchronized (characteristicsMonitor) {
+ if (mAvailableCharacteristics == null) {
+ return null;
+ }
+ return mAvailableCharacteristics.get(uuid);
+ }
+ }
+
+ private void gattServicesDiscovered(List<BluetoothGattService> discoveredGattServices) {
+ if (discoveredGattServices == null) {
+ logger.warn("No gatt services discovered: null!");
+ return;
+ }
+ Set<UUID> supportedServices = getSupportedServices();
+ Map<UUID, BluetoothGattCharacteristic> newCharacteristics = new HashMap<>();
+ for (BluetoothGattService service : discoveredGattServices) {
+ if(bleApi != null) {
+ bleApi.addService(service);
+ }
+
+ if (supportedServices.contains(service.getUuid())) {
+ logger.debug("discovered supported service: {}: {}", BleNamesResolver.resolveServiceName(service.getUuid().toString()), service.getUuid());
+ List<BluetoothGattCharacteristic> characteristics = service.getCharacteristics();
+ if (characteristics == null || characteristics.isEmpty()) {
+ logger.warn("Supported LE service {} did not return any characteristics", service.getUuid());
+ continue;
+ }
+ HashMap<UUID, BluetoothGattCharacteristic> intmAvailableCharacteristics = new HashMap<>(characteristics.size());
+ for (BluetoothGattCharacteristic characteristic : characteristics) {
+ intmAvailableCharacteristics.put(characteristic.getUuid(), characteristic);
+ logger.info(" characteristic: {}: {}", BleNamesResolver.resolveCharacteristicName(characteristic.getUuid().toString()), characteristic.getUuid());
+ }
+ newCharacteristics.putAll(intmAvailableCharacteristics);
+
+ synchronized (characteristicsMonitor) {
+ mAvailableCharacteristics = newCharacteristics;
+ }
+ } else {
+ logger.debug("discovered unsupported service: {}: {}", BleNamesResolver.resolveServiceName(service.getUuid().toString()), service.getUuid());
+ }
+ }
+ }
+
+ protected Set<UUID> getSupportedServices() {
+ return mSupportedServices;
+ }
+
+ /**
+ * Utility method that may be used to log incoming messages when we don't know how to deal with them yet.
+ */
+ public void logMessageContent(byte[] value) {
+ logger.info("RECEIVED DATA WITH LENGTH: {}", (value != null) ? value.length : "(null)");
+ Logging.logBytes(logger, value);
+ }
+
+ // default implementations of event handler methods (gatt callbacks)
+ @Override
+ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
+ for (AbstractBleProfile<?> profile : mSupportedProfiles) {
+ profile.onConnectionStateChange(gatt, status, newState);
+ }
+ }
+
+ @Override
+ public void onServicesDiscovered(BluetoothGatt gatt) {
+ gattServicesDiscovered(gatt.getServices());
+
+ if (getDevice().getState().compareTo(GBDevice.State.INITIALIZING) >= 0) {
+ logger.warn("Services discovered, but device state is already " + getDevice().getState() + " for device: " + getDevice() + ", so ignoring");
+ return;
+ }
+ TransactionBuilder builder = createTransactionBuilder("Initializing device");
+
+ if(bleApi != null) {
+ bleApi.initializeDevice(builder);
+ }
+
+ initializeDevice(builder).queue(getQueue());
+ }
+
+ @Override
+ public boolean onCharacteristicRead(BluetoothGatt gatt,
+ BluetoothGattCharacteristic characteristic, int status) {
+ if(bleApi != null) {
+ bleApi.onCharacteristicChanged(characteristic);
+ }
+
+ for (AbstractBleProfile<?> profile : mSupportedProfiles) {
+ if (profile.onCharacteristicRead(gatt, characteristic, status)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onCharacteristicWrite(BluetoothGatt gatt,
+ BluetoothGattCharacteristic characteristic, int status) {
+ for (AbstractBleProfile<?> profile : mSupportedProfiles) {
+ if (profile.onCharacteristicWrite(gatt, characteristic, status)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ for (AbstractBleProfile<?> profile : mSupportedProfiles) {
+ if (profile.onDescriptorRead(gatt, descriptor, status)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ for (AbstractBleProfile<?> profile : mSupportedProfiles) {
+ if (profile.onDescriptorWrite(gatt, descriptor, status)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onCharacteristicChanged(BluetoothGatt gatt,
+ BluetoothGattCharacteristic characteristic) {
+ if(bleApi != null) {
+ bleApi.onCharacteristicChanged(characteristic);
+ }
+
+ for (AbstractBleProfile<?> profile : mSupportedProfiles) {
+ if (profile.onCharacteristicChanged(gatt, characteristic)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
+ for (AbstractBleProfile<?> profile : mSupportedProfiles) {
+ profile.onReadRemoteRssi(gatt, rssi, status);
+ }
+ }
+
+ @Override
+ public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+ this.mMTU = mtu;
+ }
+
+ @Override
+ public void onConnectionStateChange(BluetoothDevice device, int status, int newState) {
+
+ }
+
+ @Override
+ public boolean onCharacteristicReadRequest(BluetoothDevice device, int requestId, int offset, BluetoothGattCharacteristic characteristic) {
+ return false;
+ }
+
+ @Override
+ public boolean onCharacteristicWriteRequest(BluetoothDevice device, int requestId, BluetoothGattCharacteristic characteristic, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) {
+ return false;
+ }
+
+ @Override
+ public boolean onDescriptorReadRequest(BluetoothDevice device, int requestId, int offset, BluetoothGattDescriptor descriptor) {
+ return false;
+ }
+
+ @Override
+ public boolean onDescriptorWriteRequest(BluetoothDevice device, int requestId, BluetoothGattDescriptor descriptor, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) {
+ return false;
+ }
+
+ /**
+ * Gets the current MTU, or 0 if unknown
+ * @return the current MTU, 0 if unknown
+ */
+ public int getMTU() {
+ return mMTU;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEOperation.java
new file mode 100644
index 0000000..8cf49a8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEOperation.java
@@ -0,0 +1,218 @@
+/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien
+ Gaignon, Daniel Dakhno, Uwe Hermann
+
+ 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.btle;
+
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.content.Context;
+
+import java.io.IOException;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.operations.OperationStatus;
+
+/**
+ * Abstract base class for a BTLEOperation, i.e. an operation that does more than
+ * just sending a few bytes to the device. It typically involves exchanging many messages
+ * between the mobile and the device.
+ * <p/>
+ * One operation may execute multiple @{link Transaction transactions} with each
+ * multiple @{link BTLEAction actions}.
+ * <p/>
+ * This class implements GattCallback so that subclasses may override those methods
+ * to handle those events.
+ * Note: by default all Gatt events are forwarded to AbstractBTLEDeviceSupport, subclasses may override
+ * this behavior.
+ */
+public abstract class AbstractBTLEOperation<T extends AbstractBTLEDeviceSupport> implements GattCallback, BTLEOperation {
+ private final T mSupport;
+ protected OperationStatus operationStatus = OperationStatus.INITIAL;
+ private String name;
+
+ protected AbstractBTLEOperation(T support) {
+ mSupport = support;
+ }
+
+ /**
+ * Performs this operation. The whole operation is asynchronous, i.e.
+ * this method quickly returns before the actual operation is finished.
+ * Calls #prePerform() and, if successful, #doPerform().
+ *
+ * @throws IOException
+ */
+ @Override
+ public final void perform() throws IOException {
+ operationStatus = OperationStatus.STARTED;
+ prePerform();
+ operationStatus = OperationStatus.RUNNING;
+ doPerform();
+ }
+
+ /**
+ * Hook for subclasses to perform something before #doPerform() is invoked.
+ *
+ * @throws IOException
+ */
+ protected void prePerform() throws IOException {
+ }
+
+ /**
+ * Subclasses must implement this. When invoked, #prePerform() returned
+ * successfully.
+ * Note that subclasses HAVE TO call #operationFinished() when the entire
+ * operation is done (successful or not).
+ *
+ * @throws IOException
+ */
+ protected abstract void doPerform() throws IOException;
+
+ /**
+ * You MUST call this method when the operation has finished, either
+ * successfully or unsuccessfully.
+ *
+ * Subclasses must ensure that the {@link BtLEQueue queue's}'s gatt callback (set on the transaction builder by {@link #performInitialized(String)})
+ * is being unset, otherwise it will continue to receive events until another transaction is being executed by the queue.
+ *
+ * @throws IOException
+ */
+ protected void operationFinished() throws IOException {
+ }
+
+ /**
+ * Delegates to the DeviceSupport instance and additionally sets this instance as the Gatt
+ * callback for the transaction.
+ *
+ * @param taskName
+ * @return
+ * @throws IOException
+ */
+ public TransactionBuilder performInitialized(String taskName) throws IOException {
+ TransactionBuilder builder = mSupport.performInitialized(taskName);
+ builder.setCallback(this);
+ return builder;
+ }
+
+ public TransactionBuilder createTransactionBuilder(String taskName) {
+ TransactionBuilder builder = getSupport().createTransactionBuilder(taskName);
+ builder.setCallback(this);
+ return builder;
+ }
+
+ public void performImmediately(TransactionBuilder builder) throws IOException {
+ mSupport.performImmediately(builder);
+ }
+
+ protected Context getContext() {
+ return mSupport.getContext();
+ }
+
+ protected GBDevice getDevice() {
+ return mSupport.getDevice();
+ }
+
+ protected void setName(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String getName() {
+ if (name != null) {
+ return name;
+ }
+ String busyTask = getDevice().getBusyTask();
+ if (busyTask != null) {
+ return busyTask;
+ }
+ return getClass().getSimpleName();
+ }
+
+ protected BluetoothGattCharacteristic getCharacteristic(UUID uuid) {
+ return mSupport.getCharacteristic(uuid);
+ }
+
+ protected BtLEQueue getQueue() {
+ return mSupport.getQueue();
+ }
+
+ protected void unsetBusy() {
+ if (getDevice().isBusy()) {
+ getDevice().unsetBusyTask();
+ getDevice().sendDeviceUpdateIntent(getContext());
+ }
+ }
+
+ public boolean isOperationRunning() {
+ return operationStatus == OperationStatus.RUNNING;
+ }
+
+ public boolean isOperationFinished() {
+ return operationStatus == OperationStatus.FINISHED;
+ }
+
+ public T getSupport() {
+ return mSupport;
+ }
+
+ // All Gatt callbacks delegated to MiBandSupport
+ @Override
+ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
+ mSupport.onConnectionStateChange(gatt, status, newState);
+ }
+
+ @Override
+ public void onServicesDiscovered(BluetoothGatt gatt) {
+ mSupport.onServicesDiscovered(gatt);
+ }
+
+ @Override
+ public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ return mSupport.onCharacteristicRead(gatt, characteristic, status);
+ }
+
+ @Override
+ public boolean onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ return mSupport.onCharacteristicWrite(gatt, characteristic, status);
+ }
+
+ @Override
+ public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+ return mSupport.onCharacteristicChanged(gatt, characteristic);
+ }
+
+ @Override
+ public boolean onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ return mSupport.onDescriptorRead(gatt, descriptor, status);
+ }
+
+ @Override
+ public boolean onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ return mSupport.onDescriptorWrite(gatt, descriptor, status);
+ }
+
+ @Override
+ public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
+ mSupport.onReadRemoteRssi(gatt, rssi, status);
+ }
+
+ @Override
+ public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+ mSupport.onMtuChanged(gatt, mtu, status);
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractGattCallback.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractGattCallback.java
new file mode 100644
index 0000000..3a004a2
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractGattCallback.java
@@ -0,0 +1,67 @@
+/* Copyright (C) 2015-2024 Carsten Pfeiffer, Daniel Dakhno
+
+ 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.btle;
+
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+
+/**
+ * Base class for GattCallbacks wishing to just implement a few of the methods.
+ */
+public abstract class AbstractGattCallback implements GattCallback {
+ @Override
+ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
+ }
+
+ @Override
+ public void onServicesDiscovered(BluetoothGatt gatt) {
+ }
+
+ @Override
+ public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ return false;
+ }
+
+ @Override
+ public boolean onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ return false;
+ }
+
+ @Override
+ public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+ return false;
+ }
+
+ @Override
+ public boolean onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ return false;
+ }
+
+ @Override
+ public boolean onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ return false;
+ }
+
+ @Override
+ public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
+ }
+
+ @Override
+ public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractTransaction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractTransaction.java
new file mode 100644
index 0000000..b2b6309
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractTransaction.java
@@ -0,0 +1,46 @@
+/* Copyright (C) 2019-2024 Andreas B枚hler
+
+